From 6cb15cda5e862938c1ddb2b14281e10e5473c1c4 Mon Sep 17 00:00:00 2001 From: Abhilash <70062455+abhilash-sivan@users.noreply.github.com> Date: Tue, 25 Nov 2025 18:42:04 +0530 Subject: [PATCH 01/11] refactor: updated span.data.x.error assignment (#2178) refs INSTA-63749 --- .../cloud/aws-sdk/v3/instana_aws_product.js | 5 ++--- .../tracing/instrumentation/databases/memcached.js | 5 ++--- .../tracing/instrumentation/protocols/httpClient.js | 11 +++++------ 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/core/src/tracing/instrumentation/cloud/aws-sdk/v3/instana_aws_product.js b/packages/core/src/tracing/instrumentation/cloud/aws-sdk/v3/instana_aws_product.js index 5e39f8b041..96181df944 100644 --- a/packages/core/src/tracing/instrumentation/cloud/aws-sdk/v3/instana_aws_product.js +++ b/packages/core/src/tracing/instrumentation/cloud/aws-sdk/v3/instana_aws_product.js @@ -48,9 +48,8 @@ class InstanaAWSProduct { addErrorToSpan(err, span) { if (err) { span.ec = 1; - const spanData = span.data && span.data[this.spanName]; - if (spanData) { - spanData.error = err.message || err.code || JSON.stringify(err); + if (span.data?.[this.spanName]) { + span.data[this.spanName].error = err.message || err.code || JSON.stringify(err); } } } diff --git a/packages/core/src/tracing/instrumentation/databases/memcached.js b/packages/core/src/tracing/instrumentation/databases/memcached.js index 42ae33da55..5392aa64bf 100644 --- a/packages/core/src/tracing/instrumentation/databases/memcached.js +++ b/packages/core/src/tracing/instrumentation/databases/memcached.js @@ -136,9 +136,8 @@ function finishSpan(err, span) { function addErrorToSpan(err, span) { if (err) { span.ec = 1; - const spanData = span.data && span.data[SPAN_NAME]; - if (spanData) { - spanData.error = err.message || err.code || JSON.stringify(err); + if (span.data?.[SPAN_NAME]) { + span.data[SPAN_NAME].error = err.message || err.code || JSON.stringify(err); } } } diff --git a/packages/core/src/tracing/instrumentation/protocols/httpClient.js b/packages/core/src/tracing/instrumentation/protocols/httpClient.js index d3d3ba9ea4..28909342a9 100644 --- a/packages/core/src/tracing/instrumentation/protocols/httpClient.js +++ b/packages/core/src/tracing/instrumentation/protocols/httpClient.js @@ -262,10 +262,9 @@ function instrument(coreModule, forceHttps) { } catch (e) { // A synchronous exception indicates a failure that is not covered by the listeners. Using a malformed URL for // example is a case that triggers a synchronous exception. - span.data.http = { - url: completeCallUrl, - error: e ? e.message : '' - }; + span.data.http = {}; + span.data.http.url = completeCallUrl; + span.data.http.error = e ? e.message : ''; span.d = Date.now() - span.ts; span.ec = 1; span.transmit(); @@ -311,9 +310,9 @@ function instrument(coreModule, forceHttps) { } span.data.http = { method: clientRequest.method, - url: completeCallUrl, - error: errorMessage + url: completeCallUrl }; + span.data.http.error = errorMessage; span.d = Date.now() - span.ts; span.ec = 1; span.transmit(); From ef1f7d1f7b3c3e6e1b967f9a2a19c77568733146 Mon Sep 17 00:00:00 2001 From: Abhilash <70062455+abhilash-sivan@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:54:28 +0530 Subject: [PATCH 02/11] refactor: added error stack replacement logic (#2167) --- .../test/tracing/databases/couchbase/test.js | 2 +- .../test/tracing/databases/mysql/app.js | 51 +++++++++++++++++++ .../test/tracing/databases/mysql/test.js | 32 ++++++++++++ .../test/tracing/databases/pg_native/test.js | 1 + .../messaging/node-rdkafka/test_definition.js | 4 +- .../test/tracing/misc/stack_trace/test.js | 1 + .../tracing/protocols/http/client/test.js | 3 +- .../protocols/http/native_fetch/test.js | 5 +- .../instrumentation/cloud/aws-sdk/v2/sqs.js | 2 +- .../cloud/aws-sdk/v3/sqs-consumer.js | 5 +- .../instrumentation/cloud/azure/blob.js | 2 +- .../instrumentation/cloud/gcp/pubsub.js | 6 +-- .../instrumentation/cloud/gcp/storage.js | 2 +- .../instrumentation/databases/couchbase.js | 6 +-- .../tracing/instrumentation/databases/db2.js | 33 +++++++----- .../databases/elasticsearch.js | 2 +- .../instrumentation/databases/ioredis.js | 8 +-- .../instrumentation/databases/mongodb.js | 4 +- .../instrumentation/databases/mssql.js | 2 +- .../instrumentation/databases/mysql.js | 4 +- .../tracing/instrumentation/databases/pg.js | 2 +- .../instrumentation/databases/pgNative.js | 2 +- .../instrumentation/databases/prisma.js | 6 +-- .../instrumentation/databases/redis.js | 8 ++- .../instrumentation/frameworks/express.js | 2 +- .../tracing/instrumentation/messaging/bull.js | 7 +-- .../instrumentation/messaging/kafkaJs.js | 4 +- .../instrumentation/messaging/kafkaNode.js | 2 +- .../tracing/instrumentation/messaging/nats.js | 1 + .../messaging/natsStreaming.js | 1 + .../instrumentation/messaging/rdkafka.js | 6 +-- .../instrumentation/protocols/grpcJs.js | 5 ++ .../instrumentation/protocols/httpClient.js | 6 ++- .../instrumentation/protocols/nativeFetch.js | 2 +- packages/core/src/tracing/sdk/sdk.js | 2 + packages/core/src/tracing/tracingUtil.js | 29 +++++++++++ 36 files changed, 192 insertions(+), 68 deletions(-) diff --git a/packages/collector/test/tracing/databases/couchbase/test.js b/packages/collector/test/tracing/databases/couchbase/test.js index 01aa0f93e1..f552343fe4 100644 --- a/packages/collector/test/tracing/databases/couchbase/test.js +++ b/packages/collector/test/tracing/databases/couchbase/test.js @@ -46,7 +46,7 @@ const verifyCouchbaseSpan = (controls, entrySpan, options = {}) => [ span => expect(span.data.couchbase.sql).to.contain(options.sql || 'GET'), span => options.error - ? expect(span.data.couchbase.error).to.equal(options.error) + ? expect(span.data.couchbase.error).to.contain(options.error) : expect(span.data.couchbase.error).to.not.exist ]; diff --git a/packages/collector/test/tracing/databases/mysql/app.js b/packages/collector/test/tracing/databases/mysql/app.js index 05d8ce333a..265ef82ab9 100644 --- a/packages/collector/test/tracing/databases/mysql/app.js +++ b/packages/collector/test/tracing/databases/mysql/app.js @@ -200,6 +200,14 @@ app.post('/valuesAndCall', (req, res) => { } }); +app.post('/error', (req, res) => { + if (driver === 'mysql2/promise') { + triggerErrorWithPromises(req, res); + } else { + triggerError(req, res); + } +}); + app.listen(port, () => { log( `Listening on port: ${process.env.APP_PORT} (driver: ${driver}, access: ${accessFunction}, cluster: ${useCluster})` @@ -305,6 +313,49 @@ function insertValuesWithPromisesAndCall(req, res) { }); } +function triggerError(req, res) { + pool.getConnection((err, connection) => { + if (err) { + log('Failed to get connection', err); + res.sendStatus(500); + return; + } + + connection[accessFunction]('SELECT * FROM non_existent_table', queryError => { + connection.release(); + + if (queryError) { + log('Expected error occurred', queryError); + res.sendStatus(500); + return; + } + + res.sendStatus(200); + }); + }); +} + +function triggerErrorWithPromises(req, res) { + pool + .getConnection() + .then(connection => { + wrapAccess(connection, 'SELECT * FROM non_existent_table', null, queryError => { + connection.release(); + + if (queryError) { + log('Expected error occurred', queryError); + res.sendStatus(500); + } else { + res.sendStatus(200); + } + }); + }) + .catch(err => { + log('Failed to get connection', err); + res.sendStatus(500); + }); +} + function log() { const args = Array.prototype.slice.call(arguments); args[0] = logPrefix + args[0]; diff --git a/packages/collector/test/tracing/databases/mysql/test.js b/packages/collector/test/tracing/databases/mysql/test.js index 0b0c4f2f69..6d80793414 100644 --- a/packages/collector/test/tracing/databases/mysql/test.js +++ b/packages/collector/test/tracing/databases/mysql/test.js @@ -320,4 +320,36 @@ function test(env, agentControls) { }) ); })); + + it('must replace stack trace with error stack when query fails', () => + controls + .sendRequest({ + method: 'POST', + path: '/error' + }) + .then(() => + testUtils.retry(() => + agentControls.getSpans().then(spans => { + expect(spans.length).to.equal(2); + const entrySpan = testUtils.expectAtLeastOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.server'), + span => expect(span.f.e).to.equal(String(controls.getPid())), + span => expect(span.f.h).to.equal('agent-stub-uuid') + ]); + + const mysqlSpan = testUtils.expectAtLeastOneMatching(spans, [ + span => expect(span.t).to.equal(entrySpan.t), + span => expect(span.p).to.equal(entrySpan.s), + span => expect(span.n).to.equal('mysql'), + span => expect(span.k).to.equal(constants.EXIT), + span => expect(span.f.e).to.equal(String(controls.getPid())), + span => expect(span.f.h).to.equal('agent-stub-uuid'), + span => expect(span.ec).to.equal(1), + span => expect(span.data.mysql.error).to.exist + ]); + + expect(mysqlSpan.stack).to.exist; + }) + ) + )); } diff --git a/packages/collector/test/tracing/databases/pg_native/test.js b/packages/collector/test/tracing/databases/pg_native/test.js index b3cf30b6ae..61cdb6cd97 100644 --- a/packages/collector/test/tracing/databases/pg_native/test.js +++ b/packages/collector/test/tracing/databases/pg_native/test.js @@ -290,6 +290,7 @@ mochaSuiteFn('tracing/pg-native', function () { return expectAtLeastOneMatching(spans, span => { verifyPgExitBase(span, parent, statement); expect(span.error).to.not.exist; + expect(span.stack).to.exist; expect(span.ec).to.equal(1); expect(span.data.pg.error).to.contain(errorMessage); }); diff --git a/packages/collector/test/tracing/messaging/node-rdkafka/test_definition.js b/packages/collector/test/tracing/messaging/node-rdkafka/test_definition.js index 5fef745288..529928a867 100644 --- a/packages/collector/test/tracing/messaging/node-rdkafka/test_definition.js +++ b/packages/collector/test/tracing/messaging/node-rdkafka/test_definition.js @@ -272,10 +272,10 @@ module.exports.run = function ({ span => expect(span.ec).to.equal(!withError ? 0 : 1), span => (!withError ? expect(span.data.kafka.error).to.not.exist : ''), span => - withError === 'deliveryErrorSender' ? expect(span.data.kafka.error).to.equal('delivery fake error') : '', + withError === 'deliveryErrorSender' ? expect(span.data.kafka.error).to.contain('delivery fake error') : '', span => withError === 'bufferErrorSender' - ? expect(span.data.kafka.error).to.equal('Message must be a buffer or null') + ? expect(span.data.kafka.error).to.contain('Message must be a buffer or null') : '' ]); } diff --git a/packages/collector/test/tracing/misc/stack_trace/test.js b/packages/collector/test/tracing/misc/stack_trace/test.js index 1b7592f0da..d3c9c480f2 100644 --- a/packages/collector/test/tracing/misc/stack_trace/test.js +++ b/packages/collector/test/tracing/misc/stack_trace/test.js @@ -134,6 +134,7 @@ const mochaSuiteFn = supportedVersion(process.versions.node) ? describe : descri testUtils.expectAtLeastOneMatching(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(201), span => expect(span.stack[2].m).to.equal('fetch'), span => expect(span.stack[2].c).to.contains('node-fetch') ]); diff --git a/packages/collector/test/tracing/protocols/http/client/test.js b/packages/collector/test/tracing/protocols/http/client/test.js index 7bd5ad978b..1ba90a4a47 100644 --- a/packages/collector/test/tracing/protocols/http/client/test.js +++ b/packages/collector/test/tracing/protocols/http/client/test.js @@ -633,7 +633,8 @@ function registerTests(appUsesHttps) { expect(span.data.http.error).to.match(/Invalid URL/); }, span => expect(span.t).to.equal(entrySpan.t), - span => expect(span.p).to.equal(entrySpan.s) + span => expect(span.p).to.equal(entrySpan.s), + span => expect(span.stack).to.be.a('string') ]); expectExactlyOneMatching(spans, span => { expect(span.n).to.equal('node.http.client'); diff --git a/packages/collector/test/tracing/protocols/http/native_fetch/test.js b/packages/collector/test/tracing/protocols/http/native_fetch/test.js index a6b4748098..78d395efa8 100644 --- a/packages/collector/test/tracing/protocols/http/native_fetch/test.js +++ b/packages/collector/test/tracing/protocols/http/native_fetch/test.js @@ -853,12 +853,11 @@ function verifyHttpExit({ expectedClientError = `Failed to parse URL from http:127.0.0.1:${serverControls.port}malformed-url`; } expectations.push(span => expect(span.data.http.status).to.not.exist); - expectations.push(span => expect(span.data.http.error).to.equal(expectedClientError)); + expectations.push(span => expect(span.data.http.error).to.contain(expectedClientError)); } else if (withTimeout) { expectations.push(span => expect(span.data.http.status).to.not.exist); // Early v18.x Node.js versions had "The operation was aborted", the message later changed to - // "The operation was aborted due to timeout". - expectations.push(span => expect(span.data.http.error).to.match(/^The operation was aborted(?: due to timeout)?/)); + expectations.push(span => expect(span.data.http.error).to.contain('The operation was aborted due to timeout')); } else { expectations.push(span => expect(span.data.http.status).to.equal(status)); expectations.push(span => expect(span.data.http.error).to.not.exist); diff --git a/packages/core/src/tracing/instrumentation/cloud/aws-sdk/v2/sqs.js b/packages/core/src/tracing/instrumentation/cloud/aws-sdk/v2/sqs.js index 0590f65c54..d9aa07007f 100644 --- a/packages/core/src/tracing/instrumentation/cloud/aws-sdk/v2/sqs.js +++ b/packages/core/src/tracing/instrumentation/cloud/aws-sdk/v2/sqs.js @@ -392,7 +392,7 @@ function finishSpan(err, data, span) { function addErrorToSpan(err, span) { if (err) { span.ec = 1; - span.data.sqs.error = err.message || err.code || JSON.stringify(err); + tracingUtil.setErrorDetails(span, err, 'sqs'); } } diff --git a/packages/core/src/tracing/instrumentation/cloud/aws-sdk/v3/sqs-consumer.js b/packages/core/src/tracing/instrumentation/cloud/aws-sdk/v3/sqs-consumer.js index 28c3ce826d..cd29f1aba6 100644 --- a/packages/core/src/tracing/instrumentation/cloud/aws-sdk/v3/sqs-consumer.js +++ b/packages/core/src/tracing/instrumentation/cloud/aws-sdk/v3/sqs-consumer.js @@ -7,6 +7,7 @@ const shimmer = require('../../../../shimmer'); const hook = require('../../../../../util/hook'); const cls = require('../../../../cls'); +const tracingUtil = require('../../../../tracingUtil'); function init() { hook.onModuleLoad('sqs-consumer', instrument); @@ -30,7 +31,7 @@ function instrument(SQSConsumer) { }) .catch(err => { span.ec = 1; - span.data.sqs.error = err.message || err.code || JSON.stringify(err); + tracingUtil.setErrorDetails(span, err, 'sqs'); span.d = Date.now() - span.ts; span.transmitManual(); }); @@ -62,7 +63,7 @@ function instrument(SQSConsumer) { }) .catch(err => { span.ec = 1; - span.data.sqs.error = err.message || err.code || JSON.stringify(err); + tracingUtil.setErrorDetails(span, err, 'sqs'); span.d = Date.now() - span.ts; span.transmitManual(); }); diff --git a/packages/core/src/tracing/instrumentation/cloud/azure/blob.js b/packages/core/src/tracing/instrumentation/cloud/azure/blob.js index 2ecd9f97ab..d2b076cf13 100644 --- a/packages/core/src/tracing/instrumentation/cloud/azure/blob.js +++ b/packages/core/src/tracing/instrumentation/cloud/azure/blob.js @@ -105,7 +105,7 @@ function instrumentingOperation({ function finishSpan(error, span) { if (error) { span.ec = 1; - span.data.azstorage.error = tracingUtil.getErrorDetails(error); + tracingUtil.setErrorDetails(span, error, 'azstorage'); } span.d = Date.now() - span.ts; span.transmit(); diff --git a/packages/core/src/tracing/instrumentation/cloud/gcp/pubsub.js b/packages/core/src/tracing/instrumentation/cloud/gcp/pubsub.js index 1e4a4f030f..a2adfec562 100644 --- a/packages/core/src/tracing/instrumentation/cloud/gcp/pubsub.js +++ b/packages/core/src/tracing/instrumentation/cloud/gcp/pubsub.js @@ -254,11 +254,7 @@ function finishSpan(err, messageId, span) { function addErrorToSpan(err, span) { if (err) { span.ec = 1; - if (err.message) { - span.data.gcps.error = err.message; - } else if (typeof err === 'string') { - span.data.gcps.error = err; - } + tracingUtil.setErrorDetails(span, err, 'gcps'); } } diff --git a/packages/core/src/tracing/instrumentation/cloud/gcp/storage.js b/packages/core/src/tracing/instrumentation/cloud/gcp/storage.js index 733f15b2c5..c1d4fbb43d 100644 --- a/packages/core/src/tracing/instrumentation/cloud/gcp/storage.js +++ b/packages/core/src/tracing/instrumentation/cloud/gcp/storage.js @@ -458,7 +458,7 @@ function instrumentedCreateStream(operation, bindEvent, finalEvent, ctx, origina function finishSpan(error, result, span, extractorPost) { if (error) { span.ec = 1; - span.data.gcs.error = tracingUtil.getErrorDetails(error); + tracingUtil.setErrorDetails(span, error, 'gcs'); } if (extractorPost) { diff --git a/packages/core/src/tracing/instrumentation/databases/couchbase.js b/packages/core/src/tracing/instrumentation/databases/couchbase.js index cb10cd29ff..cd800234b0 100644 --- a/packages/core/src/tracing/instrumentation/databases/couchbase.js +++ b/packages/core/src/tracing/instrumentation/databases/couchbase.js @@ -430,7 +430,7 @@ function instrumentTransactions(cluster, connectionStr) { result .catch(err => { span.ec = 1; - span.data.couchbase.error = tracingUtil.getErrorDetails(err); + tracingUtil.setErrorDetails(span, err, 'couchbase'); }) .finally(() => { span.d = Date.now() - span.ts; @@ -497,7 +497,7 @@ function instrumentOperation({ connectionStr, bucketName, getBucketTypeFn, sql, }) .catch(err => { span.ec = 1; - span.data.couchbase.error = tracingUtil.getErrorDetails(err); + tracingUtil.setErrorDetails(span, err, 'couchbase'); }) .finally(() => { span.d = Date.now() - span.ts; @@ -510,7 +510,7 @@ function instrumentOperation({ connectionStr, bucketName, getBucketTypeFn, sql, originalArgs[callbackIndex] = cls.ns.bind(function instanaCallback(err, result) { if (err) { span.ec = 1; - span.data.couchbase.error = tracingUtil.getErrorDetails(err); + tracingUtil.setErrorDetails(span, err, 'couchbase'); } if (resultHandler) { diff --git a/packages/core/src/tracing/instrumentation/databases/db2.js b/packages/core/src/tracing/instrumentation/databases/db2.js index 2b163095bc..c6819d219c 100644 --- a/packages/core/src/tracing/instrumentation/databases/db2.js +++ b/packages/core/src/tracing/instrumentation/databases/db2.js @@ -222,7 +222,7 @@ function captureFetchError(result, span) { argsFetch[fetchIndex] = function instanaFetchCb(fetchCbErr) { if (fetchCbErr) { span.ec = 1; - span.data.db2.error = tracingUtil.getErrorDetails(fetchCbErr); + tracingUtil.setErrorDetails(span, fetchCbErr, 'db2'); } return fetchCb.apply(this, arguments); @@ -232,7 +232,7 @@ function captureFetchError(result, span) { return originalFn.apply(this, arguments); } catch (caughtErr) { span.ec = 1; - span.data.db2.error = tracingUtil.getErrorDetails(caughtErr); + tracingUtil.setErrorDetails(span, caughtErr, 'db2'); throw caughtErr; } }; @@ -249,12 +249,12 @@ function captureFetchError(result, span) { if (res instanceof Error) { span.ec = 1; - span.data.db2.error = tracingUtil.getErrorDetails(res); + tracingUtil.setErrorDetails(span, res, 'db2'); } return res; } catch (err) { span.ec = 1; - span.data.db2.error = tracingUtil.getErrorDetails(err); + tracingUtil.setErrorDetails(span, err, 'db2'); return err; } @@ -278,14 +278,14 @@ function instrumentQueryHelper(ctx, originalArgs, originalFunction, stmt, isAsyn if (result instanceof Error) { span.ec = 1; - span.data.db2.error = tracingUtil.getErrorDetails(result); + tracingUtil.setErrorDetails(span, result, 'db2'); } finishSpan(ctx, result, span); return result; } catch (e) { span.ec = 1; - span.data.db2.error = tracingUtil.getErrorDetails(e); + tracingUtil.setErrorDetails(span, e, 'db2'); finishSpan(ctx, null, span); throw e; } @@ -304,7 +304,7 @@ function instrumentQueryHelper(ctx, originalArgs, originalFunction, stmt, isAsyn originalArgs[customerCallbackIndex] = function instanaCallback(err) { if (err) { span.ec = 1; - span.data.db2.error = tracingUtil.getErrorDetails(err); + tracingUtil.setErrorDetails(span, err, 'db2'); } finishSpan(ctx, null, span); @@ -324,7 +324,7 @@ function instrumentQueryHelper(ctx, originalArgs, originalFunction, stmt, isAsyn }) .catch(err => { span.ec = 1; - span.data.db2.error = tracingUtil.getErrorDetails(err); + tracingUtil.setErrorDetails(span, err, 'db2'); finishSpan(ctx, null, span); return err; }); @@ -394,7 +394,7 @@ function instrumentExecuteHelper(ctx, originalArgs, stmtObject, prepareCallParen return result; } catch (err) { span.ec = 1; - span.data.db2.error = tracingUtil.getErrorDetails(err); + tracingUtil.setErrorDetails(span, err, 'db2'); finishSpan(ctx, null, span); return err; } @@ -441,7 +441,7 @@ function instrumentExecuteHelper(ctx, originalArgs, stmtObject, prepareCallParen args[origCallbackIndex] = function instanaExecuteCallback(executeErr, result) { if (executeErr) { span.ec = 1; - span.data.db2.error = tracingUtil.getErrorDetails(executeErr); + tracingUtil.setErrorDetails(span, executeErr, 'db2'); finishSpan(ctx, null, span); return origCallback.apply(this, arguments); } @@ -470,7 +470,7 @@ function instrumentQueryResultHelper(ctx, originalArgs, originalFunction, stmt, return result; } catch (err) { span.ec = 1; - span.data.db2.error = tracingUtil.getErrorDetails(err); + tracingUtil.setErrorDetails(span, err, 'db2'); finishSpan(ctx, null, span); return err; } @@ -489,7 +489,7 @@ function instrumentQueryResultHelper(ctx, originalArgs, originalFunction, stmt, originalArgs[customerCallbackIndex] = function instanaCallback(err) { if (err) { span.ec = 1; - span.data.db2.error = tracingUtil.getErrorDetails(err); + tracingUtil.setErrorDetails(span, err, 'db2'); } const result = customerCallback.apply(this, arguments); @@ -551,6 +551,11 @@ function finishSpan(ctx, result, span) { closeSyncCalled = true; span.ec = 1; span.data.db2.error = `'result.closeSync' was not called within ${CLOSE_TIMEOUT_IN_MS}ms.`; + tracingUtil.setErrorDetails( + span, + new Error(`'result.closeSync' was not called within ${CLOSE_TIMEOUT_IN_MS}ms.`), + 'db2' + ); span.d = Date.now() - span.ts; span.transmit(); } @@ -578,7 +583,7 @@ function handleTransaction(ctx, span) { arguments[1] = function instanaOnEndOverride(onEndErr) { if (onEndErr) { span.ec = 1; - span.data.db2.error = tracingUtil.getErrorDetails(onEndErr) || 'Error not available.'; + tracingUtil.setErrorDetails(span, onEndErr, 'db2'); } span.d = Date.now() - span.ts; @@ -597,7 +602,7 @@ function handleTransaction(ctx, span) { return result; } catch (err) { span.ec = 1; - span.data.db2.error = tracingUtil.getErrorDetails(err) || 'Error not available.'; + tracingUtil.setErrorDetails(span, err, 'db2'); span.transmit(); throw err; } diff --git a/packages/core/src/tracing/instrumentation/databases/elasticsearch.js b/packages/core/src/tracing/instrumentation/databases/elasticsearch.js index 5e04f941fd..4877c53604 100644 --- a/packages/core/src/tracing/instrumentation/databases/elasticsearch.js +++ b/packages/core/src/tracing/instrumentation/databases/elasticsearch.js @@ -200,7 +200,7 @@ function onError(span, error) { span.d = Date.now() - span.ts; span.ec = 1; if (error) { - span.data.elasticsearch.error = tracingUtil.getErrorDetails(error); + tracingUtil.setErrorDetails(span, error, 'elasticsearch'); } if (error.meta && error.meta.meta) { getConnectionDetailsFromResultMeta(span, error.meta); diff --git a/packages/core/src/tracing/instrumentation/databases/ioredis.js b/packages/core/src/tracing/instrumentation/databases/ioredis.js index 8bbb02dea8..e88a1c69e3 100644 --- a/packages/core/src/tracing/instrumentation/databases/ioredis.js +++ b/packages/core/src/tracing/instrumentation/databases/ioredis.js @@ -115,7 +115,7 @@ function instrumentSendCommand(original) { if (error) { span.ec = 1; - span.data.redis.error = error.message; + tracingUtil.setErrorDetails(span, error, 'redis'); } span.transmit(); @@ -221,7 +221,7 @@ function multiCommandEndCallback(clsContextForMultiOrPipeline, span, error) { if (error) { span.ec = commandCount; - span.data.redis.error = error.message; + tracingUtil.setErrorDetails(span, error, 'redis'); } span.transmit(); @@ -241,7 +241,7 @@ function pipelineCommandEndCallback(clsContextForMultiOrPipeline, span, error, r if (error) { // ioredis docs mention that this should never be possible, but better be safe than sorry span.ec = commandCount; - span.data.redis.error = tracingUtil.getErrorDetails(error); + tracingUtil.setErrorDetails(span, error, 'redis'); } else { let numberOfErrors = 0; let sampledError; @@ -257,7 +257,7 @@ function pipelineCommandEndCallback(clsContextForMultiOrPipeline, span, error, r if (numberOfErrors > 0) { span.ec = numberOfErrors; - span.data.redis.error = tracingUtil.getErrorDetails(sampledError); + tracingUtil.setErrorDetails(span, sampledError, 'redis'); } } diff --git a/packages/core/src/tracing/instrumentation/databases/mongodb.js b/packages/core/src/tracing/instrumentation/databases/mongodb.js index 15ca92270c..7582e1917c 100644 --- a/packages/core/src/tracing/instrumentation/databases/mongodb.js +++ b/packages/core/src/tracing/instrumentation/databases/mongodb.js @@ -438,7 +438,7 @@ function createWrappedCallback(span, originalCallback) { return cls.ns.bind(function (error) { if (error) { span.ec = 1; - span.data.mongo.error = tracingUtil.getErrorDetails(error); + tracingUtil.setErrorDetails(span, error, 'mongo'); } span.d = Date.now() - span.ts; @@ -466,7 +466,7 @@ function handleCallbackOrPromise(ctx, originalArgs, originalFunction, span) { }) .catch(err => { span.ec = 1; - span.data.mongo.error = tracingUtil.getErrorDetails(err); + tracingUtil.setErrorDetails(span, err, 'mongo'); span.d = Date.now() - span.ts; span.transmit(); return err; diff --git a/packages/core/src/tracing/instrumentation/databases/mssql.js b/packages/core/src/tracing/instrumentation/databases/mssql.js index 9fca976a40..d1cf837774 100644 --- a/packages/core/src/tracing/instrumentation/databases/mssql.js +++ b/packages/core/src/tracing/instrumentation/databases/mssql.js @@ -161,7 +161,7 @@ function shimBeginTransaction(originalFunction) { function finishSpan(error, span) { if (error) { span.ec = 1; - span.data.mssql.error = tracingUtil.getErrorDetails(error); + tracingUtil.setErrorDetails(span, error, 'mssql'); } span.d = Date.now() - span.ts; diff --git a/packages/core/src/tracing/instrumentation/databases/mysql.js b/packages/core/src/tracing/instrumentation/databases/mysql.js index afee7877bc..a33688fa86 100644 --- a/packages/core/src/tracing/instrumentation/databases/mysql.js +++ b/packages/core/src/tracing/instrumentation/databases/mysql.js @@ -190,7 +190,7 @@ function instrumentedAccessFunction( }) .catch(error => { span.ec = 1; - span.data.mysql.error = tracingUtil.getErrorDetails(error); + tracingUtil.setErrorDetails(span, error, exports.spanName); span.d = Date.now() - span.ts; span.transmit(); @@ -205,7 +205,7 @@ function instrumentedAccessFunction( function onResult(error) { if (error) { span.ec = 1; - span.data.mysql.error = tracingUtil.getErrorDetails(error); + tracingUtil.setErrorDetails(span, error, exports.spanName); } span.d = Date.now() - span.ts; diff --git a/packages/core/src/tracing/instrumentation/databases/pg.js b/packages/core/src/tracing/instrumentation/databases/pg.js index d0979c58da..eed210ad70 100644 --- a/packages/core/src/tracing/instrumentation/databases/pg.js +++ b/packages/core/src/tracing/instrumentation/databases/pg.js @@ -103,7 +103,7 @@ function instrumentedQuery(ctx, originalQuery, argsForOriginalQuery) { function finishSpan(error, span) { if (error) { span.ec = 1; - span.data.pg.error = tracingUtil.getErrorDetails(error); + tracingUtil.setErrorDetails(span, error, 'pg'); } span.d = Date.now() - span.ts; diff --git a/packages/core/src/tracing/instrumentation/databases/pgNative.js b/packages/core/src/tracing/instrumentation/databases/pgNative.js index ec75bde81e..8407f5b5a9 100644 --- a/packages/core/src/tracing/instrumentation/databases/pgNative.js +++ b/packages/core/src/tracing/instrumentation/databases/pgNative.js @@ -197,7 +197,7 @@ function startSpanBeforeSync(ctx, originalFn, originalArgs, statement, stackTrac function finishSpan(error, span) { if (error) { span.ec = 1; - span.data.pg.error = tracingUtil.getErrorDetails(error); + tracingUtil.setErrorDetails(span, error, 'pg'); } span.d = Date.now() - span.ts; diff --git a/packages/core/src/tracing/instrumentation/databases/prisma.js b/packages/core/src/tracing/instrumentation/databases/prisma.js index 01d3ff8aef..126b2e6080 100644 --- a/packages/core/src/tracing/instrumentation/databases/prisma.js +++ b/packages/core/src/tracing/instrumentation/databases/prisma.js @@ -6,7 +6,7 @@ const shimmer = require('../../shimmer'); const hook = require('../../../util/hook'); -const { getErrorDetails, getStackTrace } = require('../../tracingUtil'); +const tracingUtil = require('../../tracingUtil'); const { EXIT } = require('../../constants'); const cls = require('../../cls'); @@ -156,7 +156,7 @@ function instrumentedRequest(ctx, originalRequest, argsForOriginalRequest) { spanName: 'prisma', kind: EXIT }); - span.stack = getStackTrace(instrumentedRequest, 1); + span.stack = tracingUtil.getStackTrace(instrumentedRequest, 1); const params = argsForOriginalRequest[0] || {}; const providerAndDataSourceUri = ctx._engine ? providerAndDataSourceUriMap.get(ctx._engine) || {} : {}; @@ -237,7 +237,7 @@ function redactPasswordFromMsSQLUrl(url) { function finishSpan(error, span) { if (error) { span.ec = 1; - span.data.prisma.error = getErrorDetails(error); + tracingUtil.setErrorDetails(span, error, 'prisma'); } span.d = Date.now() - span.ts; diff --git a/packages/core/src/tracing/instrumentation/databases/redis.js b/packages/core/src/tracing/instrumentation/databases/redis.js index 05b7a22007..809c667e6b 100644 --- a/packages/core/src/tracing/instrumentation/databases/redis.js +++ b/packages/core/src/tracing/instrumentation/databases/redis.js @@ -368,7 +368,7 @@ function instrumentCommand(original, command, address, cbStyle) { if (error) { span.ec = 1; - span.data.redis.error = tracingUtil.getErrorDetails(error); + tracingUtil.setErrorDetails(span, error, 'redis'); } span.transmit(); @@ -515,6 +515,7 @@ function instrumentMultiExec(origCtx, origArgs, original, address, isAtomic, cbS // v3 = provides sub errors if (err.errors && err.errors.length) { + // TODO: Not updating now as special case span.data.redis.error = err.errors.map(subErr => subErr.message).join('\n'); } } @@ -534,10 +535,7 @@ function buildSubCommandCallback(span, userProvidedCallback) { return function subCommandCallback(err) { if (err) { span.ec++; - - if (!span.data.redis.error) { - span.data.redis.error = tracingUtil.getErrorDetails(err); - } + tracingUtil.setErrorDetails(span, err, 'redis'); } if (typeof userProvidedCallback === 'function') { diff --git a/packages/core/src/tracing/instrumentation/frameworks/express.js b/packages/core/src/tracing/instrumentation/frameworks/express.js index 208daa0885..2f4a89ff17 100644 --- a/packages/core/src/tracing/instrumentation/frameworks/express.js +++ b/packages/core/src/tracing/instrumentation/frameworks/express.js @@ -116,7 +116,7 @@ function annotateHttpEntrySpanWithError(err) { return; } - span.data.http.error = tracingUtil.getErrorDetails(err); + tracingUtil.setErrorDetails(span, err, 'http'); } function shimHandlerRegistration(original) { diff --git a/packages/core/src/tracing/instrumentation/messaging/bull.js b/packages/core/src/tracing/instrumentation/messaging/bull.js index 21115672a6..d0bb2951d3 100644 --- a/packages/core/src/tracing/instrumentation/messaging/bull.js +++ b/packages/core/src/tracing/instrumentation/messaging/bull.js @@ -325,11 +325,8 @@ function finishSpan(err, data, span) { function addErrorToSpan(err, span) { if (err) { span.ec = 1; - if (err.code) { - span.data.bull.error = err.code; - } else if (typeof err === 'string') { - span.data.bull.error = err; - } + + tracingUtil.setErrorDetails(span, err, 'bull'); } } diff --git a/packages/core/src/tracing/instrumentation/messaging/kafkaJs.js b/packages/core/src/tracing/instrumentation/messaging/kafkaJs.js index 16f44c27e3..f887225f13 100644 --- a/packages/core/src/tracing/instrumentation/messaging/kafkaJs.js +++ b/packages/core/src/tracing/instrumentation/messaging/kafkaJs.js @@ -105,7 +105,7 @@ function instrumentedSend(ctx, originalSend, originalArgs, topic, messages) { }) .catch(error => { span.ec = 1; - span.data.kafka.error = error.message; + tracingUtil.setErrorDetails(span, error, 'kafka'); span.d = Date.now() - span.ts; span.transmit(); throw error; @@ -184,7 +184,7 @@ function instrumentedSendBatch(ctx, originalSendBatch, originalArgs, topicMessag }) .catch(error => { span.ec = 1; - span.data.kafka.error = error.message; + tracingUtil.setErrorDetails(span, error, 'kafka'); span.d = Date.now() - span.ts; span.transmit(); throw error; diff --git a/packages/core/src/tracing/instrumentation/messaging/kafkaNode.js b/packages/core/src/tracing/instrumentation/messaging/kafkaNode.js index a243cc3806..0721f7ce35 100644 --- a/packages/core/src/tracing/instrumentation/messaging/kafkaNode.js +++ b/packages/core/src/tracing/instrumentation/messaging/kafkaNode.js @@ -81,7 +81,7 @@ function instrumentedSend(ctx, originalSend, produceRequests, cb) { cls.ns.bind(function onSendCompleted(err) { if (err) { span.ec = 1; - span.data.kafka.error = err.message; + tracingUtil.setErrorDetails(span, err, 'kafka'); } span.d = Date.now() - span.ts; diff --git a/packages/core/src/tracing/instrumentation/messaging/nats.js b/packages/core/src/tracing/instrumentation/messaging/nats.js index 2f9a3e0465..0e10caa626 100644 --- a/packages/core/src/tracing/instrumentation/messaging/nats.js +++ b/packages/core/src/tracing/instrumentation/messaging/nats.js @@ -397,6 +397,7 @@ function addErrorToSpan(err, span) { if (err) { span.ec = 1; + // TODO: Special logic of appending error let errMsg = null; if (err.message) { errMsg = err.message; diff --git a/packages/core/src/tracing/instrumentation/messaging/natsStreaming.js b/packages/core/src/tracing/instrumentation/messaging/natsStreaming.js index cc72ff8777..b844b11b62 100644 --- a/packages/core/src/tracing/instrumentation/messaging/natsStreaming.js +++ b/packages/core/src/tracing/instrumentation/messaging/natsStreaming.js @@ -194,6 +194,7 @@ function addErrorToSpan(err, span) { if (err) { span.ec = 1; + // TODO: special logic of appending error let errMsg = null; if (err.message) { errMsg = err.message; diff --git a/packages/core/src/tracing/instrumentation/messaging/rdkafka.js b/packages/core/src/tracing/instrumentation/messaging/rdkafka.js index d41a88d1c6..58251265f4 100644 --- a/packages/core/src/tracing/instrumentation/messaging/rdkafka.js +++ b/packages/core/src/tracing/instrumentation/messaging/rdkafka.js @@ -163,7 +163,7 @@ function instrumentedProduce(ctx, originalProduce, originalArgs) { if (err) { span.ec = 1; - span.data.kafka.error = err.message; + tracingUtil.setErrorDetails(span, err, 'kafka'); } span.transmit(); @@ -183,7 +183,7 @@ function instrumentedProduce(ctx, originalProduce, originalArgs) { // e.g. cannot send message because format is byte // "Message must be a buffer or null" span.ec = 1; - span.data.kafka.error = error.message; + tracingUtil.setErrorDetails(span, error, 'kafka'); if (!deliveryCb) { span.d = Date.now() - span.ts; @@ -301,7 +301,7 @@ function instrumentedConsumerEmit(ctx, originalEmit, originalArgs) { delete messageData.headers; span.ec = 1; - span.data.kafka.error = messageData.message; + tracingUtil.setErrorDetails(span, messageData, 'kafka'); } setImmediate(() => { diff --git a/packages/core/src/tracing/instrumentation/protocols/grpcJs.js b/packages/core/src/tracing/instrumentation/protocols/grpcJs.js index 63c720a821..6967156fe8 100644 --- a/packages/core/src/tracing/instrumentation/protocols/grpcJs.js +++ b/packages/core/src/tracing/instrumentation/protocols/grpcJs.js @@ -115,9 +115,11 @@ function modifyArgs(name, originalArgs, span) { // No-op, we do not want to mark cancelled calls as erroneous. } else { span.ec = 1; + // TODO: special case of error message if (errorMessage) { span.data.rpc.error = errorMessage; } + tracingUtil.setErrorDetails(span, err, 'rpc'); } } @@ -347,6 +349,7 @@ function createInstrumentedServerHandler(name, type, originalHandler) { if (err.message || err.details) { span.data.rpc.error = err.message || err.details; } + tracingUtil.setErrorDetails(span, err, 'rpc'); } span.d = Date.now() - span.ts; span.transmit(); @@ -372,6 +375,7 @@ function createInstrumentedServerHandler(name, type, originalHandler) { if (err.message || err.details) { span.data.rpc.error = err.message || err.details; } + tracingUtil.setErrorDetails(span, err, 'rpc'); }); call.on('cancelled', () => { @@ -434,6 +438,7 @@ function instrumentedClientMethod( if (errorMessage) { span.data.rpc.error = errorMessage; } + tracingUtil.setErrorDetails(span, err, 'rpc'); } span.transmit(); }); diff --git a/packages/core/src/tracing/instrumentation/protocols/httpClient.js b/packages/core/src/tracing/instrumentation/protocols/httpClient.js index 28909342a9..7b94593db9 100644 --- a/packages/core/src/tracing/instrumentation/protocols/httpClient.js +++ b/packages/core/src/tracing/instrumentation/protocols/httpClient.js @@ -242,6 +242,7 @@ function instrument(coreModule, forceHttps) { span.d = Date.now() - span.ts; span.ec = res.statusCode >= 500 ? 1 : 0; + span.transmit(); if (callback) { @@ -264,7 +265,7 @@ function instrument(coreModule, forceHttps) { // example is a case that triggers a synchronous exception. span.data.http = {}; span.data.http.url = completeCallUrl; - span.data.http.error = e ? e.message : ''; + tracingUtil.setErrorDetails(span, e, 'http'); span.d = Date.now() - span.ts; span.ec = 1; span.transmit(); @@ -312,9 +313,12 @@ function instrument(coreModule, forceHttps) { method: clientRequest.method, url: completeCallUrl }; + // TODO: special-case, we remove this in separate PR span.data.http.error = errorMessage; span.d = Date.now() - span.ts; span.ec = 1; + tracingUtil.setErrorDetails(span, err, 'http'); + span.transmit(); }); }); diff --git a/packages/core/src/tracing/instrumentation/protocols/nativeFetch.js b/packages/core/src/tracing/instrumentation/protocols/nativeFetch.js index dc4650c992..b1ce5b1f93 100644 --- a/packages/core/src/tracing/instrumentation/protocols/nativeFetch.js +++ b/packages/core/src/tracing/instrumentation/protocols/nativeFetch.js @@ -183,7 +183,7 @@ function instrument() { }) .catch(err => { span.ec = 1; - span.data.http.error = err.message; + tracingUtil.setErrorDetails(span, err, 'http'); }) .finally(() => { span.d = Date.now() - span.ts; diff --git a/packages/core/src/tracing/sdk/sdk.js b/packages/core/src/tracing/sdk/sdk.js index 4ba6b6ec75..cc072053ed 100644 --- a/packages/core/src/tracing/sdk/sdk.js +++ b/packages/core/src/tracing/sdk/sdk.js @@ -330,6 +330,8 @@ exports.generate = function (isCallbackApi) { span.data.sdk.custom.tags = {}; } if (span.data.sdk.custom.tags.message == null) { + // TODO: fix sdk error capture and handling + // This fn used getErrorDetails for now and will need to change span.data.sdk.custom.tags.message = tracingUtil.getErrorDetails(error); } } diff --git a/packages/core/src/tracing/tracingUtil.js b/packages/core/src/tracing/tracingUtil.js index 9e0768130b..323ea0cf8d 100644 --- a/packages/core/src/tracing/tracingUtil.js +++ b/packages/core/src/tracing/tracingUtil.js @@ -276,3 +276,32 @@ exports.findCallback = (/** @type {string | any[]} */ originalArgs) => { callbackIndex }; }; + +/** + * @param {import('../core').InstanaBaseSpan} span - The span to update + * @param {Error} error + * @param {string} technology - The technology name (e.g., 'mysql', 'pg', 'http') + */ +// @ts-ignore +exports.setErrorDetails = function setErrorDetails(span, error, technology) { + if (!error) { + return; + } + + if (technology && span.data?.[technology]) { + // This extra check allows instrumentations to override the error property (not recommended) + if (!span.data[technology].error) { + const combinedMessage = error?.message + ? `${error.name || 'Error'}: ${error.message}` + : // @ts-ignore + error?.code || 'No error message found.'; + + span.data[technology].error = combinedMessage.substring(0, 200); + } + } + + // TODO: Change substring usage to frame capturing - see util/stackTrace.js + if (error.stack) { + span.stack = String(error.stack).substring(500); + } +}; From c5ff1133f84f94a009528bf35f07315ab1bcae0b Mon Sep 17 00:00:00 2001 From: Abhilash <70062455+abhilash-sivan@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:59:21 +0530 Subject: [PATCH 03/11] test: added test for span.stack replacement logic (#2213) --- .../test/tracing/misc/stack_trace/test.js | 27 +++++++++++++++++++ .../protocols/http/proxy/expressProxy.js | 16 +++++++++++ packages/core/src/tracing/tracingUtil.js | 2 +- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/collector/test/tracing/misc/stack_trace/test.js b/packages/collector/test/tracing/misc/stack_trace/test.js index d3c9c480f2..b3a4f0cfdf 100644 --- a/packages/collector/test/tracing/misc/stack_trace/test.js +++ b/packages/collector/test/tracing/misc/stack_trace/test.js @@ -141,6 +141,33 @@ const mochaSuiteFn = supportedVersion(process.versions.node) ? describe : descri }) ) )); + + it('must replace error.stack with span.stack when error occurs', () => + expressProxyControls + .sendRequest({ + method: 'GET', + path: '/trigger-error' + }) + .then(() => + testUtils.retry(() => + agentControls.getSpans().then(spans => { + testUtils.expectAtLeastOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.client'), + span => expect(span.k).to.equal(constants.EXIT), + span => expect(span.ec).to.equal(1), + span => expect(span.data.http.error).to.exist, + span => { + // When setErrorDetails is called with an error that has a stack, + // the error.stack should be captured in span.stack as a string (first 500 chars) + expect(span.stack).to.be.a('string'); + expect(span.stack.length).to.be.greaterThan(0); + expect(span.stack.length).to.be.at.most(500); + expect(span.stack).to.match(/Error|ECONNREFUSED/); + } + ]); + }) + ) + )); }); }); }); diff --git a/packages/collector/test/tracing/protocols/http/proxy/expressProxy.js b/packages/collector/test/tracing/protocols/http/proxy/expressProxy.js index 56382c5d35..f47aafefcb 100644 --- a/packages/collector/test/tracing/protocols/http/proxy/expressProxy.js +++ b/packages/collector/test/tracing/protocols/http/proxy/expressProxy.js @@ -35,6 +35,22 @@ app.get('/', (req, res) => { res.sendStatus(200); }); +// eslint-disable-next-line no-unused-vars +app.get('/trigger-error', (req, res) => { + // This endpoint makes an HTTP client call that will fail + // This will trigger setErrorDetails on the http.client span + fetch('http://localhost:1/non-existent-endpoint', { + method: 'GET', + timeout: 100 + }) + .then(response => { + res.sendStatus(response.status); + }) + .catch(err => { + res.status(500).send(err); + }); +}); + app.use((req, res) => { log(req.method, req.url); const delay = parseInt(req.query.delay || 0, 10); diff --git a/packages/core/src/tracing/tracingUtil.js b/packages/core/src/tracing/tracingUtil.js index 323ea0cf8d..5be2ce10af 100644 --- a/packages/core/src/tracing/tracingUtil.js +++ b/packages/core/src/tracing/tracingUtil.js @@ -302,6 +302,6 @@ exports.setErrorDetails = function setErrorDetails(span, error, technology) { // TODO: Change substring usage to frame capturing - see util/stackTrace.js if (error.stack) { - span.stack = String(error.stack).substring(500); + span.stack = String(error.stack).substring(0, 500); } }; From f68be41e65de9958dda006f8fee71e75ee957907 Mon Sep 17 00:00:00 2001 From: Abhilash <70062455+abhilash-sivan@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:52:56 +0530 Subject: [PATCH 04/11] refactor: updated setErrorStack to handle string error case for nats, etc (#2214) --- .../test/tracing/messaging/nats/test.js | 12 ++- .../tracing/instrumentation/messaging/nats.js | 14 +-- .../messaging/natsStreaming.js | 14 +-- packages/core/src/tracing/tracingUtil.js | 28 ++++-- .../core/test/tracing/tracingUtil_test.js | 99 ++++++++++++++++++- 5 files changed, 127 insertions(+), 40 deletions(-) diff --git a/packages/collector/test/tracing/messaging/nats/test.js b/packages/collector/test/tracing/messaging/nats/test.js index ddd44bc068..58902ce7bc 100644 --- a/packages/collector/test/tracing/messaging/nats/test.js +++ b/packages/collector/test/tracing/messaging/nats/test.js @@ -147,8 +147,8 @@ mochaSuiteFn('tracing/nats', function () { ? expect(res).to.equal('Subject must be supplied') : expect(res).to.equal('BAD_SUBJECT'); } else if (typeof res === 'object') { - expect(res.code).to.equal('BAD_SUBJECT'); - expect(res.message).to.equal('Subject must be supplied'); + expect(res.code).to.equal('NatsError: BAD_SUBJECT'); + expect(res.message).to.equal('NatsError: Subject must be supplied'); } else { fail(`Unexpected response of type "${typeof res}": ${JSON.stringify(res)}`); } @@ -198,9 +198,11 @@ mochaSuiteFn('tracing/nats', function () { version === 'v1' ? expectations.push(span => - expect(span.data.nats.error).to.equal('Subject must be supplied') + expect(span.data.nats.error).to.equal('NatsError: Subject must be supplied') ) - : expectations.push(span => expect(span.data.nats.error).to.equal('BAD_SUBJECT')); + : expectations.push(span => + expect(span.data.nats.error).to.equal('NatsError: BAD_SUBJECT') + ); } else { expectations.push(span => expect(span.ec).to.equal(0)); expectations.push(span => expect(span.data.nats.error).to.not.exist); @@ -375,7 +377,7 @@ mochaSuiteFn('tracing/nats', function () { if (withError) { expectations.push(span => expect(span.error).to.not.exist); expectations.push(span => expect(span.ec).to.equal(1)); - expectations.push(span => expect(span.data.nats.error).to.equal('Boom!')); + expectations.push(span => expect(span.data.nats.error).to.contain('Boom!')); } else { expectations.push(span => expect(span.error).to.not.exist); expectations.push(span => expect(span.ec).to.equal(0)); diff --git a/packages/core/src/tracing/instrumentation/messaging/nats.js b/packages/core/src/tracing/instrumentation/messaging/nats.js index 0e10caa626..0d9a4eace5 100644 --- a/packages/core/src/tracing/instrumentation/messaging/nats.js +++ b/packages/core/src/tracing/instrumentation/messaging/nats.js @@ -396,19 +396,7 @@ function instrumentedSubscribeCallback(natsUrl, subject, originalSubscribeCallba function addErrorToSpan(err, span) { if (err) { span.ec = 1; - - // TODO: Special logic of appending error - let errMsg = null; - if (err.message) { - errMsg = err.message; - } else if (typeof err === 'string') { - errMsg = err; - } - if (errMsg && span.data.nats.error) { - span.data.nats.error += `, ${errMsg}`; - } else if (errMsg) { - span.data.nats.error = errMsg; - } + tracingUtil.setErrorDetails(span, err, 'nats'); } } diff --git a/packages/core/src/tracing/instrumentation/messaging/natsStreaming.js b/packages/core/src/tracing/instrumentation/messaging/natsStreaming.js index b844b11b62..db0310477a 100644 --- a/packages/core/src/tracing/instrumentation/messaging/natsStreaming.js +++ b/packages/core/src/tracing/instrumentation/messaging/natsStreaming.js @@ -193,19 +193,7 @@ function captureErrorInCurrentSpan(ctx, originalEmit, originalArgs) { function addErrorToSpan(err, span) { if (err) { span.ec = 1; - - // TODO: special logic of appending error - let errMsg = null; - if (err.message) { - errMsg = err.message; - } else if (typeof err === 'string') { - errMsg = err; - } - if (errMsg && span.data.nats.error) { - span.data.nats.error += `, ${errMsg}`; - } else if (errMsg) { - span.data.nats.error = errMsg; - } + tracingUtil.setErrorDetails(span, err, 'nats'); } } diff --git a/packages/core/src/tracing/tracingUtil.js b/packages/core/src/tracing/tracingUtil.js index 5be2ce10af..2ffd304515 100644 --- a/packages/core/src/tracing/tracingUtil.js +++ b/packages/core/src/tracing/tracingUtil.js @@ -279,29 +279,41 @@ exports.findCallback = (/** @type {string | any[]} */ originalArgs) => { /** * @param {import('../core').InstanaBaseSpan} span - The span to update - * @param {Error} error + * @param {Error | string} error * @param {string} technology - The technology name (e.g., 'mysql', 'pg', 'http') */ -// @ts-ignore exports.setErrorDetails = function setErrorDetails(span, error, technology) { if (!error) { return; } + // Normalize error to object format at the beginning + /** @type {{ message?: string, stack?: object | string | null, name?: string, code?: string }} */ + let normalizedError; + + if (typeof error === 'string') { + normalizedError = { message: error, stack: null }; + } else { + normalizedError = error; + } + if (technology && span.data?.[technology]) { // This extra check allows instrumentations to override the error property (not recommended) if (!span.data[technology].error) { - const combinedMessage = error?.message - ? `${error.name || 'Error'}: ${error.message}` - : // @ts-ignore - error?.code || 'No error message found.'; + let combinedMessage; + + if (normalizedError?.message) { + combinedMessage = `${normalizedError.name || 'Error'}: ${normalizedError.message}`; + } else { + combinedMessage = normalizedError?.code || 'No error message found.'; + } span.data[technology].error = combinedMessage.substring(0, 200); } } // TODO: Change substring usage to frame capturing - see util/stackTrace.js - if (error.stack) { - span.stack = String(error.stack).substring(0, 500); + if (normalizedError.stack) { + span.stack = String(normalizedError.stack).substring(0, 500); } }; diff --git a/packages/core/test/tracing/tracingUtil_test.js b/packages/core/test/tracing/tracingUtil_test.js index 5f260ea9ec..7283717c5f 100644 --- a/packages/core/test/tracing/tracingUtil_test.js +++ b/packages/core/test/tracing/tracingUtil_test.js @@ -23,7 +23,8 @@ const { sanitizeConnectionStr, unsignedHexStringToBuffer, unsignedHexStringsToBuffer, - findCallback + findCallback, + setErrorDetails } = require('../../src/tracing/tracingUtil'); describe('tracing/tracingUtil', () => { @@ -444,4 +445,100 @@ describe('tracing/tracingUtil', () => { expect(res.callbackIndex).to.equal(1); }); }); + + describe('setErrorDetails', () => { + it('should handle Error objects with message and stack', () => { + const span = { + data: { + nats: {} + } + }; + const error = new Error('Test error message'); + setErrorDetails(span, error, 'nats'); + + expect(span.data.nats.error).to.match(/Error: Test error message/); + expect(span.stack).to.be.a('string'); + expect(span.stack).to.match(/Test error message/); + }); + + it('should handle error objects with only a message property', () => { + const span = { + data: { + mysql: {} + } + }; + const error = { message: 'Database connection failed', stack: 'some test' }; + setErrorDetails(span, error, 'mysql'); + + expect(span.data.mysql.error).to.equal('Error: Database connection failed'); + expect(span.stack).to.contain('some test'); + }); + + it('should handle error objects with code property', () => { + const span = { + data: { + http: {} + } + }; + const error = { code: 'ECONNREFUSED', stack: 'test stack' }; + setErrorDetails(span, error, 'http'); + + expect(span.data.http.error).to.equal('ECONNREFUSED'); + expect(span.stack).to.contain('test stack'); + }); + + it('should not overwrite existing error property', () => { + const span = { + data: { + nats: { + error: 'Existing error' + } + } + }; + const error = 'New error'; + setErrorDetails(span, error, 'nats'); + + expect(span.data.nats.error).to.equal('Existing error'); + }); + + it('should handle null or undefined errors gracefully', () => { + const span = { + data: { + nats: {} + } + }; + setErrorDetails(span, null, 'nats'); + expect(span.data.nats.error).to.be.undefined; + + setErrorDetails(span, undefined, 'nats'); + expect(span.data.nats.error).to.be.undefined; + }); + + it('should truncate long error messages to 200 characters', () => { + const span = { + data: { + nats: {} + } + }; + const longError = 'a'.repeat(300); + setErrorDetails(span, longError, 'nats'); + + expect(span.data.nats.error).to.have.lengthOf(200); + // The prefix `Error: ` (length 7) is excluded from the trimming logic. + expect(span.data.nats.error).to.equal(`Error: ${'a'.repeat(193)}`); + }); + + it('should truncate stack traces to 500 characters', () => { + const span = { + data: { + nats: {} + } + }; + const error = new Error('Test'); + error.stack = `Error: Test\n${'at someFunction\n'.repeat(100)}`; + setErrorDetails(span, error, 'nats'); + + expect(span.stack).to.have.lengthOf(500); + }); + }); }); From 55c63ee2645470982bbe51097802f32f84bb74dd Mon Sep 17 00:00:00 2001 From: Abhilash <70062455+abhilash-sivan@users.noreply.github.com> Date: Tue, 9 Dec 2025 18:24:57 +0530 Subject: [PATCH 05/11] refactor: updated grpc to use setErrorDetails fn (#2216) --- .../test/tracing/protocols/grpc-js/test.js | 4 +-- .../instrumentation/protocols/grpcJs.js | 13 --------- packages/core/src/tracing/tracingUtil.js | 6 +++-- .../core/test/tracing/tracingUtil_test.js | 27 +++++++++++++++++++ 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/packages/collector/test/tracing/protocols/grpc-js/test.js b/packages/collector/test/tracing/protocols/grpc-js/test.js index b9710d5e2c..aa2edce5c9 100644 --- a/packages/collector/test/tracing/protocols/grpc-js/test.js +++ b/packages/collector/test/tracing/protocols/grpc-js/test.js @@ -398,7 +398,7 @@ function checkGrpcClientSpan({ httpEntry, clientControls, url, erroneous = false expectations = expectations.concat([ span => expect(span.ec).to.be.equal(1), span => expect(span.error).to.not.exist, - span => expect(span.data.rpc.error).to.equal('Boom!') + span => expect(span.data.rpc.error).to.equal('Error: Boom!') ]); } else { expectations = expectations.concat([ @@ -426,7 +426,7 @@ function checkGrpcServerSpan({ grpcExit, serverControls, url, erroneous }) { expectations = expectations.concat([ span => expect(span.ec).to.be.equal(1), span => expect(span.error).to.not.exist, - span => expect(span.data.rpc.error).to.equal('Boom!') + span => expect(span.data.rpc.error).to.equal('Error: Boom!') ]); } else { expectations = expectations.concat([ diff --git a/packages/core/src/tracing/instrumentation/protocols/grpcJs.js b/packages/core/src/tracing/instrumentation/protocols/grpcJs.js index 6967156fe8..8bc4e83e1f 100644 --- a/packages/core/src/tracing/instrumentation/protocols/grpcJs.js +++ b/packages/core/src/tracing/instrumentation/protocols/grpcJs.js @@ -115,10 +115,6 @@ function modifyArgs(name, originalArgs, span) { // No-op, we do not want to mark cancelled calls as erroneous. } else { span.ec = 1; - // TODO: special case of error message - if (errorMessage) { - span.data.rpc.error = errorMessage; - } tracingUtil.setErrorDetails(span, err, 'rpc'); } } @@ -346,9 +342,6 @@ function createInstrumentedServerHandler(name, type, originalHandler) { originalArgs[1] = cls.ns.bind(function (err) { if (err) { span.ec = 1; - if (err.message || err.details) { - span.data.rpc.error = err.message || err.details; - } tracingUtil.setErrorDetails(span, err, 'rpc'); } span.d = Date.now() - span.ts; @@ -372,9 +365,6 @@ function createInstrumentedServerHandler(name, type, originalHandler) { call.on('error', err => { span.ec = 1; - if (err.message || err.details) { - span.data.rpc.error = err.message || err.details; - } tracingUtil.setErrorDetails(span, err, 'rpc'); }); @@ -435,9 +425,6 @@ function instrumentedClientMethod( // No-op, we do not want to mark cancelled calls as erroneous. } else { span.ec = 1; - if (errorMessage) { - span.data.rpc.error = errorMessage; - } tracingUtil.setErrorDetails(span, err, 'rpc'); } span.transmit(); diff --git a/packages/core/src/tracing/tracingUtil.js b/packages/core/src/tracing/tracingUtil.js index 2ffd304515..9fdbaacb9b 100644 --- a/packages/core/src/tracing/tracingUtil.js +++ b/packages/core/src/tracing/tracingUtil.js @@ -288,7 +288,7 @@ exports.setErrorDetails = function setErrorDetails(span, error, technology) { } // Normalize error to object format at the beginning - /** @type {{ message?: string, stack?: object | string | null, name?: string, code?: string }} */ + /** @type {{ message?: string, stack?: object | string | null, name?: string, code?: string, details?: string }} */ let normalizedError; if (typeof error === 'string') { @@ -302,7 +302,9 @@ exports.setErrorDetails = function setErrorDetails(span, error, technology) { if (!span.data[technology].error) { let combinedMessage; - if (normalizedError?.message) { + if (normalizedError?.details) { + combinedMessage = `${normalizedError.name || 'Error'}: ${normalizedError.details}`; + } else if (normalizedError?.message) { combinedMessage = `${normalizedError.name || 'Error'}: ${normalizedError.message}`; } else { combinedMessage = normalizedError?.code || 'No error message found.'; diff --git a/packages/core/test/tracing/tracingUtil_test.js b/packages/core/test/tracing/tracingUtil_test.js index 7283717c5f..e079040f11 100644 --- a/packages/core/test/tracing/tracingUtil_test.js +++ b/packages/core/test/tracing/tracingUtil_test.js @@ -540,5 +540,32 @@ describe('tracing/tracingUtil', () => { expect(span.stack).to.have.lengthOf(500); }); + + it('should handle error objects with details property (gRPC errors)', () => { + const span = { + data: { + rpc: {} + } + }; + const error = { details: 'UNAVAILABLE: Connection refused' }; + setErrorDetails(span, error, 'rpc'); + + expect(span.data.rpc.error).to.contain('UNAVAILABLE: Connection refused'); + }); + + it('should prioritize details over message for gRPC errors', () => { + const span = { + data: { + rpc: {} + } + }; + const error = { + message: 'Generic error message', + details: 'UNAVAILABLE: Connection refused' + }; + setErrorDetails(span, error, 'rpc'); + + expect(span.data.rpc.error).to.contain('UNAVAILABLE: Connection refused'); + }); }); }); From 8cb96020985b7bcaa5bab1d3fc055262dbdaee94 Mon Sep 17 00:00:00 2001 From: Abhilash <70062455+abhilash-sivan@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:13:38 +0530 Subject: [PATCH 06/11] test: fixed natsStreaming test (#2224) --- .../collector/test/tracing/messaging/nats-streaming/test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/collector/test/tracing/messaging/nats-streaming/test.js b/packages/collector/test/tracing/messaging/nats-streaming/test.js index 7620aaba40..f1075e33bc 100644 --- a/packages/collector/test/tracing/messaging/nats-streaming/test.js +++ b/packages/collector/test/tracing/messaging/nats-streaming/test.js @@ -132,7 +132,7 @@ mochaSuiteFn('tracing/nats-streaming', function () { } expect(span.data.nats.address).to.equal(process.env.NATS_STREAMING); if (withError) { - expect(span.data.nats.error).to.equal('stan: invalid publish request'); + expect(span.data.nats.error).to.equal('Error: stan: invalid publish request'); } else { expect(span.data.nats.error).to.not.exist; } @@ -246,7 +246,7 @@ mochaSuiteFn('tracing/nats-streaming', function () { expect(span.data.nats.subject).to.equal('subscribe-test-subject'); expect(span.data.nats.address).to.equal(process.env.NATS_STREAMING); if (withError) { - expect(span.data.nats.error).to.equal(`Boom: ${uniqueId}`); + expect(span.data.nats.error).to.equal(`Error: Boom: ${uniqueId}`); } else { expect(span.data.nats.error).to.not.exist; } From cbd6a90d3e8c8de60bf1ee7e6112d00dbefea890 Mon Sep 17 00:00:00 2001 From: Abhilash <70062455+abhilash-sivan@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:14:14 +0530 Subject: [PATCH 07/11] refactor: updated sdk to use setErrorDetails fn (#2222) --- packages/collector/test/tracing/sdk/test.js | 24 +++- packages/core/src/tracing/sdk/sdk.js | 16 +-- packages/core/src/tracing/tracingUtil.js | 72 +++++++---- .../core/test/tracing/tracingUtil_test.js | 112 ++++++++++++++---- 4 files changed, 161 insertions(+), 63 deletions(-) diff --git a/packages/collector/test/tracing/sdk/test.js b/packages/collector/test/tracing/sdk/test.js index ebe8ef5601..57b022f0e8 100644 --- a/packages/collector/test/tracing/sdk/test.js +++ b/packages/collector/test/tracing/sdk/test.js @@ -710,8 +710,7 @@ mochaSuiteFn('tracing/sdk', function () { if (error) { expectations = expectations.concat([ span => expect(span.ec).to.equal(1), - span => expect(span.data.sdk.custom.tags.message).to.contain('Error: Boom!\n'), - span => expect(span.data.sdk.custom.tags.message).to.contain('packages/collector/test/tracing/sdk/app.js') + span => expect(span.data.sdk.custom.tags.message).to.contain('Error: Boom!') ]); } else { expectations.push(span => expect(span.ec).to.equal(0)); @@ -721,8 +720,17 @@ mochaSuiteFn('tracing/sdk', function () { span => expect(span.data.sdk).to.exist, span => expect(span.data.sdk.name).to.equal('custom-entry'), span => expect(span.data.sdk.type).to.equal(constants.SDK.ENTRY), - span => expect(span.stack[0].c).to.match(/test\/tracing\/sdk\/app.js$/), - span => expect(span.stack[0].m).to.match(functionName) + span => { + // TODO: Remove this !error condition when the span.stack to array conversion is implemented + if (functionName && !error) { + // When there's an error, span.stack is overwritten with error stack string, not stack frames array + expect(span.stack[0].c).to.match(/test\/tracing\/sdk\/app.js$/); + expect(span.stack[0].m).to.match(functionName); + } else if (error) { + // For errors, span.stack is a string containing the error stack trace + expect(span.stack).to.be.a('string'); + } + } ]); tagsAt = tagsAt || 'none'; @@ -828,9 +836,15 @@ mochaSuiteFn('tracing/sdk', function () { : expect(span.data.sdk.name).to.equal(kind === 'INTERMEDIATE' ? 'intermediate-file-access' : 'file-access'), span => expect(span.data.sdk.type).to.equal(constants.SDK[kind]), span => { - if (functionName) { + // TODO: Remove this !error condition when the span.stack to array conversion is implemented + if (functionName && !error) { + // When there's an error, span.stack is overwritten with error stack string, not stack frames array expect(span.stack[0].c).to.match(/test\/tracing\/sdk\/app.js$/); expect(span.stack[0].m).to.match(functionName); + } else if (error) { + // For errors, span.stack is a string containing the error stack trace + expect(span.stack).to.be.a('string'); + expect(span.stack).to.contain('Error: ENOENT'); } }, span => expect(span.data.sdk.custom).to.be.an('object'), diff --git a/packages/core/src/tracing/sdk/sdk.js b/packages/core/src/tracing/sdk/sdk.js index cc072053ed..d939c08ffa 100644 --- a/packages/core/src/tracing/sdk/sdk.js +++ b/packages/core/src/tracing/sdk/sdk.js @@ -321,19 +321,9 @@ exports.generate = function (isCallbackApi) { if (error) { span.ec = 1; - if (!span.data.sdk.custom) { - span.data.sdk.custom = { - tags: {} - }; - } - if (!span.data.sdk.custom.tags) { - span.data.sdk.custom.tags = {}; - } - if (span.data.sdk.custom.tags.message == null) { - // TODO: fix sdk error capture and handling - // This fn used getErrorDetails for now and will need to change - span.data.sdk.custom.tags.message = tracingUtil.getErrorDetails(error); - } + // since the key for error message is different in sdk, we pass the complete path as dot(.) separated string + // also it can be passed as an array like ["sdk", "custom", "tags", "message"] + tracingUtil.setErrorDetails(span, error, 'sdk.custom.tags.message'); } if (span.data.sdk.custom && tags) { diff --git a/packages/core/src/tracing/tracingUtil.js b/packages/core/src/tracing/tracingUtil.js index 9fdbaacb9b..734209b688 100644 --- a/packages/core/src/tracing/tracingUtil.js +++ b/packages/core/src/tracing/tracingUtil.js @@ -159,17 +159,6 @@ exports.renderTraceContextToBuffer = function renderTraceContextToBuffer(span) { return exports.unsignedHexStringsToBuffer(span.t, span.s); }; -/** - * @param {Error} err - * @returns {string} - */ -exports.getErrorDetails = function getErrorDetails(err) { - if (err == null) { - return undefined; - } - return String(err.stack || err.message || err).substring(0, 500); -}; - /** * @param {string} stmt * @returns {string} @@ -278,9 +267,18 @@ exports.findCallback = (/** @type {string | any[]} */ originalArgs) => { }; /** + * Sets error details on a span for a specific technology. + * Handles different error formats: strings, objects with details/message/code properties. + * Supports nested paths for SDK spans via dot-separated strings or arrays. + * + * Examples: + * - setErrorDetails(span, error, 'nats') // flat key + * - setErrorDetails(span, error, 'sdk.custom.tags.message') // dot-separated string + * - setErrorDetails(span, error, ['sdk', 'custom', 'tags', 'message']) // array path + * * @param {import('../core').InstanaBaseSpan} span - The span to update - * @param {Error | string} error - * @param {string} technology - The technology name (e.g., 'mysql', 'pg', 'http') + * @param {Error | string | Object} error - The error object, error string, or object with error properties + * @param {string | Array} technology - The technology name or nested path */ exports.setErrorDetails = function setErrorDetails(span, error, technology) { if (!error) { @@ -297,20 +295,44 @@ exports.setErrorDetails = function setErrorDetails(span, error, technology) { normalizedError = error; } - if (technology && span.data?.[technology]) { - // This extra check allows instrumentations to override the error property (not recommended) - if (!span.data[technology].error) { - let combinedMessage; - - if (normalizedError?.details) { - combinedMessage = `${normalizedError.name || 'Error'}: ${normalizedError.details}`; - } else if (normalizedError?.message) { - combinedMessage = `${normalizedError.name || 'Error'}: ${normalizedError.message}`; - } else { - combinedMessage = normalizedError?.code || 'No error message found.'; + const extractErrorMessage = () => { + if (normalizedError?.details) { + return `${normalizedError.name || 'Error'}: ${normalizedError.details}`; + } else if (normalizedError?.message) { + return `${normalizedError.name || 'Error'}: ${normalizedError.message}`; + } else { + return normalizedError?.code || 'No error message found.'; + } + }; + + let errorPath = null; + if (Array.isArray(technology)) { + errorPath = technology; + } else if (typeof technology === 'string' && technology.includes('.')) { + errorPath = technology.split('.'); + } + + if (errorPath) { + let target = span.data; + + // Traverse the object path and create missing nested objects along the way + // Without this, deeper properties would fail to assign if their parent objects don't exist + for (let i = 0; i < errorPath.length - 1; i++) { + const key = errorPath[i]; + if (!target[key]) { + target[key] = {}; } + target = target[key]; + } + + const errorKey = errorPath[errorPath.length - 1]; - span.data[technology].error = combinedMessage.substring(0, 200); + if (!target[errorKey]) { + target[errorKey] = extractErrorMessage().substring(0, 200); + } + } else if (typeof technology === 'string' && technology && span.data?.[technology]) { + if (!span.data[technology].error) { + span.data[technology].error = extractErrorMessage().substring(0, 200); } } diff --git a/packages/core/test/tracing/tracingUtil_test.js b/packages/core/test/tracing/tracingUtil_test.js index e079040f11..825117f9a2 100644 --- a/packages/core/test/tracing/tracingUtil_test.js +++ b/packages/core/test/tracing/tracingUtil_test.js @@ -18,7 +18,6 @@ const { generateRandomId, generateRandomSpanId, generateRandomTraceId, - getErrorDetails, readTraceContextFromBuffer, sanitizeConnectionStr, unsignedHexStringToBuffer, @@ -357,25 +356,6 @@ describe('tracing/tracingUtil', () => { } }); - describe('getErrorDetails', () => { - it('must not fail on null/undefined', () => { - expect(getErrorDetails(null)).to.equal(undefined); - expect(getErrorDetails(undefined)).to.equal(undefined); - }); - - it('must use error stack when available', () => { - expect(getErrorDetails(new Error('Whhoooopppppss'))).to.match(/Whhoooopppppss/); - }); - - it('must use error message when available', () => { - expect(getErrorDetails({ message: 'Whhoooopppppss' })).to.match(/Whhoooopppppss/); - }); - - it('must use the whole provided error when all else fails', () => { - expect(getErrorDetails('Whhoooopppppss')).to.match(/Whhoooopppppss/); - }); - }); - describe('sanitizeConnectionStr', () => { it('should redact password at the start of the connection string', () => { expect( @@ -567,5 +547,97 @@ describe('tracing/tracingUtil', () => { expect(span.data.rpc.error).to.contain('UNAVAILABLE: Connection refused'); }); + + describe('SDK spans with nested error paths', () => { + it('should not overwrite existing SDK error', () => { + const span = { + data: { + sdk: { + custom: { + tags: { + message: 'Existing SDK error' + } + } + } + } + }; + const error = 'New SDK error'; + setErrorDetails(span, error, ['sdk', 'custom', 'tags', 'message']); + + expect(span.data.sdk.custom.tags.message).to.equal('Existing SDK error'); + }); + + it('should truncate SDK error messages to 200 characters', () => { + const span = { + data: {} + }; + const longError = 'b'.repeat(600); + setErrorDetails(span, longError, ['sdk', 'custom', 'tags', 'message']); + + expect(span.data.sdk.custom.tags.message).to.have.lengthOf(200); + expect(span.data.sdk.custom.tags.message).to.equal(`Error: ${'b'.repeat(193)}`); + }); + + it('should create nested structure if it does not exist', () => { + const span = { + data: {} + }; + const error = 'Test error'; + setErrorDetails(span, error, ['sdk', 'custom', 'tags', 'error']); + + expect(span.data.sdk).to.be.an('object'); + expect(span.data.sdk.custom).to.be.an('object'); + expect(span.data.sdk.custom.tags).to.be.an('object'); + expect(span.data.sdk.custom.tags.error).to.equal('Error: Test error'); + }); + + it('should handle different nested paths', () => { + const span = { + data: {} + }; + const error = 'Custom path error'; + setErrorDetails(span, error, ['custom', 'nested', 'path', 'errorField']); + + expect(span.data.custom.nested.path.errorField).to.equal('Error: Custom path error'); + }); + + it('should handle Error object with name and message for SDK spans', () => { + const span = { + data: {} + }; + const error = new TypeError('Type mismatch'); + setErrorDetails(span, error, ['sdk', 'custom', 'tags', 'message']); + + expect(span.data.sdk.custom.tags.message).to.match(/TypeError: Type mismatch/); + expect(span.stack).to.match(/Type mismatch/); + }); + + it('should handle SDK error with dot-separated string path', () => { + const span = { + data: {} + }; + const error = new Error('SDK error via string path'); + setErrorDetails(span, error, 'sdk.custom.tags.message'); + + expect(span.data.sdk).to.exist; + expect(span.data.sdk.custom).to.exist; + expect(span.data.sdk.custom.tags).to.exist; + expect(span.data.sdk.custom.tags.message).to.match(/Error: SDK error via string path/); + expect(span.stack).to.be.a('string'); + expect(span.stack).to.match(/SDK error via string path/); + }); + + it('should handle both array and string paths equivalently', () => { + const span1 = { data: {} }; + const span2 = { data: {} }; + const error = new Error('Test error'); + + setErrorDetails(span1, error, ['sdk', 'custom', 'tags', 'message']); + setErrorDetails(span2, error, 'sdk.custom.tags.message'); + + expect(span1.data.sdk.custom.tags.message).to.equal(span2.data.sdk.custom.tags.message); + expect(span1.data.sdk.custom.tags.message).to.match(/Error: Test error/); + }); + }); }); }); From 6430c983910bee3d8252a76ccb64fda679687422 Mon Sep 17 00:00:00 2001 From: Abhilash <70062455+abhilash-sivan@users.noreply.github.com> Date: Mon, 15 Dec 2025 17:46:16 +0530 Subject: [PATCH 08/11] refactor: added utility to convert V8 stack string into Instana callsite object (#2226) --- .../test/tracing/misc/stack_trace/test.js | 9 +- .../tracing/protocols/http/client/test.js | 2 +- packages/collector/test/tracing/sdk/test.js | 19 +- packages/core/src/tracing/tracingUtil.js | 98 +++--- packages/core/src/util/stackTrace.js | 111 +++++++ .../core/test/tracing/tracingUtil_test.js | 79 ++++- packages/core/test/util/stackTrace_test.js | 299 ++++++++++++++++++ 7 files changed, 542 insertions(+), 75 deletions(-) diff --git a/packages/collector/test/tracing/misc/stack_trace/test.js b/packages/collector/test/tracing/misc/stack_trace/test.js index b3a4f0cfdf..fb6c9578d7 100644 --- a/packages/collector/test/tracing/misc/stack_trace/test.js +++ b/packages/collector/test/tracing/misc/stack_trace/test.js @@ -157,12 +157,11 @@ const mochaSuiteFn = supportedVersion(process.versions.node) ? describe : descri span => expect(span.ec).to.equal(1), span => expect(span.data.http.error).to.exist, span => { - // When setErrorDetails is called with an error that has a stack, - // the error.stack should be captured in span.stack as a string (first 500 chars) - expect(span.stack).to.be.a('string'); + expect(span.stack).to.be.an('array'); expect(span.stack.length).to.be.greaterThan(0); - expect(span.stack.length).to.be.at.most(500); - expect(span.stack).to.match(/Error|ECONNREFUSED/); + expect(span.stack[0]).to.have.property('m'); + expect(span.stack[0]).to.have.property('c'); + expect(span.stack[0]).to.have.property('n'); } ]); }) diff --git a/packages/collector/test/tracing/protocols/http/client/test.js b/packages/collector/test/tracing/protocols/http/client/test.js index 1ba90a4a47..d2cbf15747 100644 --- a/packages/collector/test/tracing/protocols/http/client/test.js +++ b/packages/collector/test/tracing/protocols/http/client/test.js @@ -634,7 +634,7 @@ function registerTests(appUsesHttps) { }, span => expect(span.t).to.equal(entrySpan.t), span => expect(span.p).to.equal(entrySpan.s), - span => expect(span.stack).to.be.a('string') + span => expect(span.stack).to.be.an('array') ]); expectExactlyOneMatching(spans, span => { expect(span.n).to.equal('node.http.client'); diff --git a/packages/collector/test/tracing/sdk/test.js b/packages/collector/test/tracing/sdk/test.js index 57b022f0e8..18a92b77a3 100644 --- a/packages/collector/test/tracing/sdk/test.js +++ b/packages/collector/test/tracing/sdk/test.js @@ -721,14 +721,13 @@ mochaSuiteFn('tracing/sdk', function () { span => expect(span.data.sdk.name).to.equal('custom-entry'), span => expect(span.data.sdk.type).to.equal(constants.SDK.ENTRY), span => { - // TODO: Remove this !error condition when the span.stack to array conversion is implemented + expect(span.stack[0].c).to.match(/test\/tracing\/sdk\/app.js$/); if (functionName && !error) { - // When there's an error, span.stack is overwritten with error stack string, not stack frames array - expect(span.stack[0].c).to.match(/test\/tracing\/sdk\/app.js$/); expect(span.stack[0].m).to.match(functionName); } else if (error) { - // For errors, span.stack is a string containing the error stack trace - expect(span.stack).to.be.a('string'); + // For this error scenario, the V8 stack frame does not contain the function name + // In such cases, defaults to `` as the method (m) field. + expect(span.stack[0].m).to.match(//); } } ]); @@ -836,15 +835,9 @@ mochaSuiteFn('tracing/sdk', function () { : expect(span.data.sdk.name).to.equal(kind === 'INTERMEDIATE' ? 'intermediate-file-access' : 'file-access'), span => expect(span.data.sdk.type).to.equal(constants.SDK[kind]), span => { - // TODO: Remove this !error condition when the span.stack to array conversion is implemented - if (functionName && !error) { - // When there's an error, span.stack is overwritten with error stack string, not stack frames array - expect(span.stack[0].c).to.match(/test\/tracing\/sdk\/app.js$/); + expect(span.stack[0].c).to.match(/test\/tracing\/sdk\/app.js$/); + if (functionName) { expect(span.stack[0].m).to.match(functionName); - } else if (error) { - // For errors, span.stack is a string containing the error stack trace - expect(span.stack).to.be.a('string'); - expect(span.stack).to.contain('Error: ENOENT'); } }, span => expect(span.data.sdk.custom).to.be.an('object'), diff --git a/packages/core/src/tracing/tracingUtil.js b/packages/core/src/tracing/tracingUtil.js index 734209b688..1e4bca3980 100644 --- a/packages/core/src/tracing/tracingUtil.js +++ b/packages/core/src/tracing/tracingUtil.js @@ -281,63 +281,69 @@ exports.findCallback = (/** @type {string | any[]} */ originalArgs) => { * @param {string | Array} technology - The technology name or nested path */ exports.setErrorDetails = function setErrorDetails(span, error, technology) { - if (!error) { - return; - } - - // Normalize error to object format at the beginning - /** @type {{ message?: string, stack?: object | string | null, name?: string, code?: string, details?: string }} */ - let normalizedError; + try { + if (!error) { + return; + } - if (typeof error === 'string') { - normalizedError = { message: error, stack: null }; - } else { - normalizedError = error; - } + // Normalize error to object format at the beginning + /** @type {{ message?: string, stack?: string | null, name?: string, code?: string, details?: string }} */ + let normalizedError; - const extractErrorMessage = () => { - if (normalizedError?.details) { - return `${normalizedError.name || 'Error'}: ${normalizedError.details}`; - } else if (normalizedError?.message) { - return `${normalizedError.name || 'Error'}: ${normalizedError.message}`; + if (typeof error === 'string') { + normalizedError = { message: error, stack: null }; } else { - return normalizedError?.code || 'No error message found.'; + normalizedError = error; } - }; - let errorPath = null; - if (Array.isArray(technology)) { - errorPath = technology; - } else if (typeof technology === 'string' && technology.includes('.')) { - errorPath = technology.split('.'); - } + const extractErrorMessage = () => { + if (normalizedError?.details) { + return `${normalizedError.name || 'Error'}: ${normalizedError.details}`; + } else if (normalizedError?.message) { + return `${normalizedError.name || 'Error'}: ${normalizedError.message}`; + } else { + return normalizedError?.code || 'No error message found.'; + } + }; - if (errorPath) { - let target = span.data; + let errorPath = null; + if (Array.isArray(technology)) { + errorPath = technology; + } else if (typeof technology === 'string' && technology.includes('.')) { + errorPath = technology.split('.'); + } - // Traverse the object path and create missing nested objects along the way - // Without this, deeper properties would fail to assign if their parent objects don't exist - for (let i = 0; i < errorPath.length - 1; i++) { - const key = errorPath[i]; - if (!target[key]) { - target[key] = {}; + if (errorPath) { + let target = span.data; + + // Traverse the object path and create missing nested objects along the way + // Without this, deeper properties would fail to assign if their parent objects don't exist + for (let i = 0; i < errorPath.length - 1; i++) { + const key = errorPath[i]; + if (!target[key]) { + target[key] = {}; + } + target = target[key]; } - target = target[key]; - } - const errorKey = errorPath[errorPath.length - 1]; + const errorKey = errorPath[errorPath.length - 1]; - if (!target[errorKey]) { - target[errorKey] = extractErrorMessage().substring(0, 200); - } - } else if (typeof technology === 'string' && technology && span.data?.[technology]) { - if (!span.data[technology].error) { - span.data[technology].error = extractErrorMessage().substring(0, 200); + if (!target[errorKey]) { + target[errorKey] = extractErrorMessage().substring(0, 200); + } + } else if (typeof technology === 'string' && technology && span.data?.[technology]) { + if (!span.data[technology].error) { + span.data[technology].error = extractErrorMessage().substring(0, 200); + } } - } - // TODO: Change substring usage to frame capturing - see util/stackTrace.js - if (normalizedError.stack) { - span.stack = String(normalizedError.stack).substring(0, 500); + if (normalizedError.stack) { + const stackArray = stackTrace.parseStackTraceFromString(normalizedError.stack); + span.stack = stackArray.length > 0 ? stackArray : span.stack || []; + } else { + span.stack = span.stack || []; + } + } catch (err) { + logger.error('Failed to set error details on span:', err); } }; diff --git a/packages/core/src/util/stackTrace.js b/packages/core/src/util/stackTrace.js index 218735856f..7428715735 100644 --- a/packages/core/src/util/stackTrace.js +++ b/packages/core/src/util/stackTrace.js @@ -167,3 +167,114 @@ function defaultPrepareStackTrace(error, frames) { frames.push(error); return frames.reverse().join('\n at '); } + +/** + * Parses a string stack trace into the structured format used by Instana. + * Extracts function name, file path, and line number from each frame. + * + * @param {string} stackString - The string stack trace to parse + * @returns {Array.} - Array of structured call site objects + */ +exports.parseStackTraceFromString = function parseStackTraceFromString(stackString) { + try { + if (typeof stackString !== 'string') { + return []; + } + + const stackLines = stackString.split('\n'); + /** @type {Array.} */ + const callSites = []; + + stackLines.forEach(rawStackLine => { + const normalizedLine = rawStackLine.trim(); + + // stack traces start with at, otherwise ignore + if (!normalizedLine.startsWith('at ')) return; + + const frameText = normalizedLine.slice(3).trim(); + + /** @type {InstanaCallSite} */ + const callSite = { + m: '', + c: undefined, + n: undefined + }; + + let functionName; + let locationText = frameText; + + // Case: "function (something)" + const openParenIndex = frameText.lastIndexOf('('); + + if (openParenIndex !== -1 && frameText.endsWith(')')) { + const candidateLocation = frameText.slice(openParenIndex + 1, -1); + const parsed = parseLocation(candidateLocation); + if (parsed) { + functionName = frameText.slice(0, openParenIndex).trim(); + locationText = candidateLocation; + callSite.c = parsed.file; + callSite.n = parsed.line; + } + } + + // Case: no parentheses or "(something)" was not a location + if (callSite.c === undefined) { + const parsed = parseLocation(locationText); + if (parsed) { + callSite.c = parsed.file; + callSite.n = parsed.line; + } + } + + // Resolve method name + if (functionName) { + callSite.m = functionName; + } else if (callSite.c === undefined) { + callSite.m = frameText; + } + + callSites.push(callSite); + }); + + return callSites; + } catch { + return []; + } +}; + +/** + * Parses "file:line[:column]" safely without regex + * @param {string} locationText + */ +function parseLocation(locationText) { + const lastColon = locationText.lastIndexOf(':'); + + // file only (no line info) + if (lastColon === -1) { + return null; + } + + const lastPart = parseInt(locationText.slice(lastColon + 1), 10); + if (isNaN(lastPart)) { + return { file: locationText, line: null }; + } + + const beforeLast = locationText.slice(0, lastColon); + const secondLastColon = beforeLast.lastIndexOf(':'); + + // file:line:column + if (secondLastColon !== -1) { + const line = parseInt(beforeLast.slice(secondLastColon + 1), 10); + if (!isNaN(line)) { + return { + file: beforeLast.slice(0, secondLastColon), + line + }; + } + } + + return { + file: beforeLast, + line: lastPart + }; +} diff --git a/packages/core/test/tracing/tracingUtil_test.js b/packages/core/test/tracing/tracingUtil_test.js index 825117f9a2..bd0df371b2 100644 --- a/packages/core/test/tracing/tracingUtil_test.js +++ b/packages/core/test/tracing/tracingUtil_test.js @@ -437,8 +437,11 @@ describe('tracing/tracingUtil', () => { setErrorDetails(span, error, 'nats'); expect(span.data.nats.error).to.match(/Error: Test error message/); - expect(span.stack).to.be.a('string'); - expect(span.stack).to.match(/Test error message/); + expect(span.stack).to.be.an('array'); + expect(span.stack.length).to.be.greaterThan(0); + expect(span.stack[0]).to.have.property('m'); + expect(span.stack[0]).to.have.property('c'); + expect(span.stack[0]).to.have.property('n'); }); it('should handle error objects with only a message property', () => { @@ -451,7 +454,7 @@ describe('tracing/tracingUtil', () => { setErrorDetails(span, error, 'mysql'); expect(span.data.mysql.error).to.equal('Error: Database connection failed'); - expect(span.stack).to.contain('some test'); + expect(span.stack).to.be.an('array'); }); it('should handle error objects with code property', () => { @@ -464,7 +467,7 @@ describe('tracing/tracingUtil', () => { setErrorDetails(span, error, 'http'); expect(span.data.http.error).to.equal('ECONNREFUSED'); - expect(span.stack).to.contain('test stack'); + expect(span.stack).to.be.an('array'); }); it('should not overwrite existing error property', () => { @@ -508,17 +511,21 @@ describe('tracing/tracingUtil', () => { expect(span.data.nats.error).to.equal(`Error: ${'a'.repeat(193)}`); }); - it('should truncate stack traces to 500 characters', () => { + it('should parse stack traces into structured format', () => { const span = { data: { nats: {} } }; const error = new Error('Test'); - error.stack = `Error: Test\n${'at someFunction\n'.repeat(100)}`; + error.stack = `Error: Test\n${'at someFunction (file.js:10:5)\n'.repeat(10)}`; setErrorDetails(span, error, 'nats'); - expect(span.stack).to.have.lengthOf(500); + expect(span.stack).to.be.an('array'); + expect(span.stack.length).to.equal(10); + expect(span.stack[0]).to.have.property('m', 'someFunction'); + expect(span.stack[0]).to.have.property('c', 'file.js'); + expect(span.stack[0]).to.have.property('n', 10); }); it('should handle error objects with details property (gRPC errors)', () => { @@ -609,7 +616,8 @@ describe('tracing/tracingUtil', () => { setErrorDetails(span, error, ['sdk', 'custom', 'tags', 'message']); expect(span.data.sdk.custom.tags.message).to.match(/TypeError: Type mismatch/); - expect(span.stack).to.match(/Type mismatch/); + expect(span.stack).to.be.an('array'); + expect(span.stack.length).to.be.greaterThan(0); }); it('should handle SDK error with dot-separated string path', () => { @@ -623,8 +631,8 @@ describe('tracing/tracingUtil', () => { expect(span.data.sdk.custom).to.exist; expect(span.data.sdk.custom.tags).to.exist; expect(span.data.sdk.custom.tags.message).to.match(/Error: SDK error via string path/); - expect(span.stack).to.be.a('string'); - expect(span.stack).to.match(/SDK error via string path/); + expect(span.stack).to.be.an('array'); + expect(span.stack.length).to.be.greaterThan(0); }); it('should handle both array and string paths equivalently', () => { @@ -639,5 +647,56 @@ describe('tracing/tracingUtil', () => { expect(span1.data.sdk.custom.tags.message).to.match(/Error: Test error/); }); }); + + it('should handle errors when logger fails', () => { + const span = { + data: { + test: {} + } + }; + setErrorDetails(span, { message: 'test' }, 'test'); + expect(span.data.test.error).to.match(/Error: test/); + }); + + it('should preserve existing stack if error has no stack', () => { + const existingStack = [{ m: 'existing', c: 'file.js', n: 1 }]; + const span = { + data: { test: {} }, + stack: existingStack + }; + const error = { message: 'No stack error' }; + setErrorDetails(span, error, 'test'); + + expect(span.stack).to.equal(existingStack); + }); + + it('should handle empty stack string', () => { + const span = { + data: { test: {} } + }; + const error = { message: 'test', stack: '' }; + setErrorDetails(span, error, 'test'); + + expect(span.stack).to.deep.equal([]); + }); + }); + + describe('generateRandomLongTraceId', () => { + const { generateRandomLongTraceId } = require('../../src/tracing/tracingUtil'); + + it('should generate 128-bit (32 character) trace IDs', () => { + const id = generateRandomLongTraceId(); + expect(id).to.be.a('string'); + expect(id.length).to.equal(32); + expect(id).to.match(/^[a-f0-9]{32}$/); + }); + + it('should generate unique IDs', () => { + const ids = new Set(); + for (let i = 0; i < 1000; i++) { + ids.add(generateRandomLongTraceId()); + } + expect(ids.size).to.equal(1000); + }); }); }); diff --git a/packages/core/test/util/stackTrace_test.js b/packages/core/test/util/stackTrace_test.js index 68cc0727c1..34a0cb43d8 100644 --- a/packages/core/test/util/stackTrace_test.js +++ b/packages/core/test/util/stackTrace_test.js @@ -79,4 +79,303 @@ describe('util/stackTrace', () => { Error.captureStackTrace = orig; expect(stack.length).to.equal(5); }); + + describe('parseStackTraceFromString', () => { + it('must parse stack trace with function names and file paths', () => { + const stackString = `Error: test error + at ClientRequest. (/artifacts/node_modules/node-fetch-v2/lib/index.js:1501:11) + at ClientRequest.emit (node:events:519:28) + at Socket.socketErrorListener (node:_http_client:574:5)`; + + const result = stackTrace.parseStackTraceFromString(stackString); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(3); + + expect(result[0].m).to.equal('ClientRequest.'); + expect(result[0].c).to.equal('/artifacts/node_modules/node-fetch-v2/lib/index.js'); + expect(result[0].n).to.equal(1501); + + expect(result[1].m).to.equal('ClientRequest.emit'); + expect(result[1].c).to.equal('node:events'); + expect(result[1].n).to.equal(519); + + expect(result[2].m).to.equal('Socket.socketErrorListener'); + expect(result[2].c).to.equal('node:_http_client'); + expect(result[2].n).to.equal(574); + }); + + it('must parse stack trace with anonymous functions', () => { + const stackString = `Error: test error + at /path/to/file.js:123:45 + at node:internal/process/task_queues:90:21`; + + const result = stackTrace.parseStackTraceFromString(stackString); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(2); + + expect(result[0].m).to.equal(''); + expect(result[0].c).to.equal('/path/to/file.js'); + expect(result[0].n).to.equal(123); + + expect(result[1].m).to.equal(''); + expect(result[1].c).to.equal('node:internal/process/task_queues'); + expect(result[1].n).to.equal(90); + }); + + it('must handle empty or invalid input', () => { + expect(stackTrace.parseStackTraceFromString('')).to.deep.equal([]); + expect(stackTrace.parseStackTraceFromString(null)).to.deep.equal([]); + expect(stackTrace.parseStackTraceFromString(undefined)).to.deep.equal([]); + expect(stackTrace.parseStackTraceFromString('no stack trace here')).to.deep.equal([]); + }); + + it('must parse stack trace with mixed formats', () => { + const stackString = `Error: test error + at Object.method (/path/to/file.js:100:20) + at /another/file.js:50:10 + at process.processTicksAndRejections (node:internal/process/task_queues:90:21)`; + + const result = stackTrace.parseStackTraceFromString(stackString); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(3); + + expect(result[0].m).to.equal('Object.method'); + expect(result[0].c).to.equal('/path/to/file.js'); + expect(result[0].n).to.equal(100); + + expect(result[1].m).to.equal(''); + expect(result[1].c).to.equal('/another/file.js'); + expect(result[1].n).to.equal(50); + + expect(result[2].m).to.equal('process.processTicksAndRejections'); + expect(result[2].c).to.equal('node:internal/process/task_queues'); + expect(result[2].n).to.equal(90); + }); + + it('must handle stack traces with only function names', () => { + const stackString = `Error: test error + at someFunction + at anotherFunction`; + + const result = stackTrace.parseStackTraceFromString(stackString); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(2); + + expect(result[0].m).to.equal('someFunction'); + expect(result[0].c).to.be.undefined; + expect(result[0].n).to.be.undefined; + + expect(result[1].m).to.equal('anotherFunction'); + expect(result[1].c).to.be.undefined; + expect(result[1].n).to.be.undefined; + }); + + it('must skip error message line', () => { + const stackString = `FetchError: request to http://localhost:3564/ failed, reason: connect ECONNREFUSED + at ClientRequest. (/path/to/file.js:100:20)`; + + const result = stackTrace.parseStackTraceFromString(stackString); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(1); + expect(result[0].m).to.equal('ClientRequest.'); + }); + + it('must handle Windows-style paths with drive letters', () => { + const stackString = `Error: test error + at myFunction (C:\\Program Files\\app\\index.js:42:10) + at anotherFunc (D:\\Users\\test\\file.js:100:5)`; + + const result = stackTrace.parseStackTraceFromString(stackString); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(2); + + expect(result[0].m).to.equal('myFunction'); + expect(result[0].c).to.equal('C:\\Program Files\\app\\index.js'); + expect(result[0].n).to.equal(42); + + expect(result[1].m).to.equal('anotherFunc'); + expect(result[1].c).to.equal('D:\\Users\\test\\file.js'); + expect(result[1].n).to.equal(100); + }); + + it('must handle stack traces with column numbers', () => { + const stackString = `Error: test error + at func (/path/to/file.js:10:25) + at another (/path/to/other.js:50:100)`; + + const result = stackTrace.parseStackTraceFromString(stackString); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(2); + + expect(result[0].m).to.equal('func'); + expect(result[0].c).to.equal('/path/to/file.js'); + expect(result[0].n).to.equal(10); + + expect(result[1].m).to.equal('another'); + expect(result[1].c).to.equal('/path/to/other.js'); + expect(result[1].n).to.equal(50); + }); + + it('must handle stack traces without column numbers', () => { + const stackString = `Error: test error + at func (/path/to/file.js:10) + at another (/path/to/other.js:50)`; + + const result = stackTrace.parseStackTraceFromString(stackString); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(2); + + expect(result[0].m).to.equal('func'); + expect(result[0].c).to.equal('/path/to/file.js'); + expect(result[0].n).to.equal(10); + + expect(result[1].m).to.equal('another'); + expect(result[1].c).to.equal('/path/to/other.js'); + expect(result[1].n).to.equal(50); + }); + + it('must handle deeply nested function names', () => { + const stackString = `Error: test error + at Object.Module._extensions..js (node:internal/modules/cjs/loader:1310:10) + at Module.load (node:internal/modules/cjs/loader:1119:32)`; + + const result = stackTrace.parseStackTraceFromString(stackString); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(2); + + expect(result[0].m).to.equal('Object.Module._extensions..js'); + expect(result[0].c).to.equal('node:internal/modules/cjs/loader'); + expect(result[0].n).to.equal(1310); + + expect(result[1].m).to.equal('Module.load'); + expect(result[1].c).to.equal('node:internal/modules/cjs/loader'); + expect(result[1].n).to.equal(1119); + }); + + it('must handle async stack traces', () => { + const stackString = `Error: test error + at async myAsyncFunction (/path/to/file.js:100:20) + at async Promise.all (index 0)`; + + const result = stackTrace.parseStackTraceFromString(stackString); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(2); + + expect(result[0].m).to.equal('async myAsyncFunction'); + expect(result[0].c).to.equal('/path/to/file.js'); + expect(result[0].n).to.equal(100); + }); + + it('must handle native code references', () => { + const stackString = `Error: test error + at Array.map + at myFunction (/path/to/file.js:10:5)`; + + const result = stackTrace.parseStackTraceFromString(stackString); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(2); + + expect(result[0].m).to.equal('Array.map'); + expect(result[0].c).to.be.undefined; + expect(result[0].n).to.be.undefined; + + expect(result[1].m).to.equal('myFunction'); + expect(result[1].c).to.equal('/path/to/file.js'); + expect(result[1].n).to.equal(10); + }); + + it('must handle URLs in file paths', () => { + const stackString = `Error: test error + at func (http://localhost:3000/app.js:10:5) + at another (https://example.com:8080/lib/index.js:50:10)`; + + const result = stackTrace.parseStackTraceFromString(stackString); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(2); + + expect(result[0].m).to.equal('func'); + expect(result[0].c).to.equal('http://localhost:3000/app.js'); + expect(result[0].n).to.equal(10); + + expect(result[1].m).to.equal('another'); + expect(result[1].c).to.equal('https://example.com:8080/lib/index.js'); + expect(result[1].n).to.equal(50); + }); + + it('must handle file:// protocol paths', () => { + const stackString = `Error: test error + at func (file:///path/to/file.js:10:5)`; + + const result = stackTrace.parseStackTraceFromString(stackString); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(1); + + expect(result[0].m).to.equal('func'); + expect(result[0].c).to.equal('file:///path/to/file.js'); + expect(result[0].n).to.equal(10); + }); + + it('must handle constructor calls', () => { + const stackString = `Error: test error + at new MyClass (/path/to/file.js:10:5) + at new AnotherClass (/path/to/other.js:20:10)`; + + const result = stackTrace.parseStackTraceFromString(stackString); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(2); + + expect(result[0].m).to.equal('new MyClass'); + expect(result[0].c).to.equal('/path/to/file.js'); + expect(result[0].n).to.equal(10); + + expect(result[1].m).to.equal('new AnotherClass'); + expect(result[1].c).to.equal('/path/to/other.js'); + expect(result[1].n).to.equal(20); + }); + + it('must handle eval and anonymous eval contexts', () => { + const stackString = `Error: test error + at eval (eval at (/path/to/file.js:10:5), :1:1) + at Object. (/path/to/file.js:10:5)`; + + const result = stackTrace.parseStackTraceFromString(stackString); + + expect(result).to.be.an('array'); + // The eval line is complex and may not parse perfectly, but should not crash + expect(result.length).to.be.greaterThan(0); + }); + + it('must handle stack traces with special characters in function names', () => { + const stackString = `Error: test error + at Object. (/path/to/file.js:10:5) + at Module._compile (/path/to/module.js:20:10)`; + + const result = stackTrace.parseStackTraceFromString(stackString); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(2); + + expect(result[0].m).to.equal('Object.'); + expect(result[0].c).to.equal('/path/to/file.js'); + expect(result[0].n).to.equal(10); + + expect(result[1].m).to.equal('Module._compile'); + expect(result[1].c).to.equal('/path/to/module.js'); + expect(result[1].n).to.equal(20); + }); + }); }); From e6065c807c266a83bcce441f02af830edbdf275c Mon Sep 17 00:00:00 2001 From: Abhilash <70062455+abhilash-sivan@users.noreply.github.com> Date: Wed, 31 Dec 2025 11:27:09 +0530 Subject: [PATCH 09/11] refactor: unified stack trace configuration from agent, in-code and env vars (#2235) --- .../src/announceCycle/unannounced.js | 54 +- packages/collector/src/types/collector.d.ts | 4 + .../test/announceCycle/unannounced_test.js | 253 +++++++ packages/collector/test/apps/agentStub.js | 14 +- .../collector/test/apps/agentStubControls.js | 4 + .../test/tracing/misc/stack_trace/test.js | 239 +++++++ .../protocols/http/proxy/expressProxy.js | 8 +- .../http/proxy/expressProxyControls.js | 10 +- .../src/config/configNormalizers/index.js | 2 + .../config/configNormalizers/stackTrace.js | 126 ++++ .../core/src/config/configValidators/index.js | 9 + .../configValidators/stackTraceValidation.js | 78 +++ packages/core/src/config/index.js | 133 ++-- packages/core/src/tracing/index.js | 1 + packages/core/src/tracing/tracingUtil.js | 40 +- packages/core/src/util/constants.js | 14 + packages/core/src/util/index.js | 2 + .../configNormalizers/stackTrace_test.js | 636 ++++++++++++++++++ .../core/test/config/normalizeConfig_test.js | 253 ++++++- .../core/test/tracing/tracingUtil_test.js | 151 ++++- 20 files changed, 1968 insertions(+), 63 deletions(-) create mode 100644 packages/core/src/config/configNormalizers/stackTrace.js create mode 100644 packages/core/src/config/configValidators/index.js create mode 100644 packages/core/src/config/configValidators/stackTraceValidation.js create mode 100644 packages/core/src/util/constants.js create mode 100644 packages/core/test/config/configNormalizers/stackTrace_test.js diff --git a/packages/collector/src/announceCycle/unannounced.js b/packages/collector/src/announceCycle/unannounced.js index a7d83c0fc9..554575de7b 100644 --- a/packages/collector/src/announceCycle/unannounced.js +++ b/packages/collector/src/announceCycle/unannounced.js @@ -9,8 +9,11 @@ const { secrets, tracing, util: { ensureNestedObjectExists }, - coreConfig: { configNormalizers } + coreConfig: { configNormalizers, configValidators } } = require('@instana/core'); + +const { validateStackTraceMode, validateStackTraceLength } = configValidators.stackTraceValidation; + const { constants: tracingConstants } = tracing; const agentConnection = require('../agentConnection'); @@ -24,7 +27,6 @@ let pidStore; const initialRetryDelay = 10 * 1000; // 10 seconds const backoffFactor = 1.5; const maxRetryDelay = 60 * 1000; // one minute - /** * @typedef {Object} AgentAnnounceResponse * @property {SecretsConfig} [secrets] @@ -46,6 +48,13 @@ const maxRetryDelay = 60 * 1000; // one minute * @property {import('@instana/core/src/config/types').IgnoreEndpoints} [ignore-endpoints] * @property {boolean} [span-batching-enabled] * @property {import('@instana/core/src/config/types').Disable} [disable] + * @property {StackTraceConfig} [global] + */ + +/** + * @typedef {Object} StackTraceConfig + * @property {string} [stack-trace] - Stack trace mode ('error'|'all'|'none') + * @property {number} [stack-trace-length] - Maximum number of stack trace frames to capture */ /** @@ -121,6 +130,7 @@ function applyAgentConfiguration(agentResponse) { applyKafkaTracingConfiguration(agentResponse); applySpanBatchingConfiguration(agentResponse); applyIgnoreEndpointsConfiguration(agentResponse); + applyStackTraceConfiguration(agentResponse); applyDisableConfiguration(agentResponse); } @@ -240,6 +250,46 @@ function applyIgnoreEndpointsConfiguration(agentResponse) { agentOpts.config.tracing.ignoreEndpoints = configNormalizers.ignoreEndpoints.normalizeConfig(ignoreEndpointsConfig); } +/** + * Apply global stack trace configuration from the agent response. + * + * @param {AgentAnnounceResponse} agentResponse + */ +function applyStackTraceConfiguration(agentResponse) { + const globalConfig = agentResponse?.tracing?.global; + if (!globalConfig) return; + + ensureNestedObjectExists(agentOpts.config, ['tracing', 'global']); + + if (globalConfig['stack-trace'] !== undefined) { + const stackTraceModeValidation = validateStackTraceMode(globalConfig['stack-trace']); + if (stackTraceModeValidation.isValid) { + const normalizedStackTrace = configNormalizers.stackTrace.normalizeStackTraceModeFromAgent( + globalConfig['stack-trace'] + ); + if (normalizedStackTrace != null) { + agentOpts.config.tracing.stackTrace = normalizedStackTrace; + } + } else { + logger.warn(`Invalid stack-trace value from agent: ${stackTraceModeValidation.error}`); + } + } + + if (globalConfig['stack-trace-length'] !== undefined) { + const stackTraceLengthValidation = validateStackTraceLength(globalConfig['stack-trace-length']); + if (stackTraceLengthValidation.isValid) { + const normalizedStackTraceLength = configNormalizers.stackTrace.normalizeStackTraceLengthFromAgent( + globalConfig['stack-trace-length'] + ); + if (normalizedStackTraceLength != null) { + agentOpts.config.tracing.stackTraceLength = normalizedStackTraceLength; + } + } else { + logger.warn(`Invalid stack-trace-length value from agent: ${stackTraceLengthValidation.error}`); + } + } +} + /** * The incoming agent configuration include `disable` object that include * which instrumentation/categories should be disabled. For example: { logging: true, console: false } diff --git a/packages/collector/src/types/collector.d.ts b/packages/collector/src/types/collector.d.ts index 950dd2307b..753327e124 100644 --- a/packages/collector/src/types/collector.d.ts +++ b/packages/collector/src/types/collector.d.ts @@ -13,6 +13,10 @@ export interface AgentConfig { ignoreEndpoints?: IgnoreEndpoints; disable?: Disable; [key: string]: any; + global?: { + stackTrace?: string; + stackTraceLength?: number; + }; }; [key: string]: any; } diff --git a/packages/collector/test/announceCycle/unannounced_test.js b/packages/collector/test/announceCycle/unannounced_test.js index 797991b7ce..9c81f08d6f 100644 --- a/packages/collector/test/announceCycle/unannounced_test.js +++ b/packages/collector/test/announceCycle/unannounced_test.js @@ -757,6 +757,259 @@ describe('unannounced state', () => { }); }); + describe('applyStackTraceConfiguration', () => { + it('should apply stack trace mode from agent response', done => { + prepareAnnounceResponse({ + tracing: { + global: { + 'stack-trace': 'all' + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + stackTrace: 'all', + global: {} + } + }); + done(); + } + }); + }); + + it('should apply stack trace length from agent response', done => { + prepareAnnounceResponse({ + tracing: { + global: { + 'stack-trace-length': 15 + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + stackTraceLength: 15, + global: {} + } + }); + done(); + } + }); + }); + + it('should apply both stack trace mode and length from agent response (mixed case)', done => { + prepareAnnounceResponse({ + tracing: { + global: { + 'stack-trace': 'eRrOr', + 'stack-trace-length': 25 + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + stackTrace: 'error', + stackTraceLength: 25, + global: {} + } + }); + done(); + } + }); + }); + + it('should normalize stack trace mode to lowercase', done => { + prepareAnnounceResponse({ + tracing: { + global: { + 'stack-trace': 'ERROR' + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + stackTrace: 'error', + global: {} + } + }); + done(); + } + }); + }); + + it('should handle none stack trace mode', done => { + prepareAnnounceResponse({ + tracing: { + global: { + 'stack-trace': 'none' + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + stackTrace: 'none', + global: {} + } + }); + done(); + } + }); + }); + + it('should not apply stack trace config when tracing.global is missing', done => { + prepareAnnounceResponse({ + tracing: {} + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({}); + done(); + } + }); + }); + + it('should not apply stack trace config when tracing is missing', done => { + prepareAnnounceResponse({}); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({}); + done(); + } + }); + }); + + it('should handle invalid stack trace mode gracefully', done => { + prepareAnnounceResponse({ + tracing: { + global: { + 'stack-trace': 'invalid-mode' + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + global: {} + } + }); + done(); + } + }); + }); + + it('should handle invalid stack trace length gracefully', done => { + prepareAnnounceResponse({ + tracing: { + global: { + 'stack-trace-length': 'not-a-number' + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + global: {} + } + }); + done(); + } + }); + }); + + it('should handle negative stack trace length gracefully', done => { + prepareAnnounceResponse({ + tracing: { + global: { + 'stack-trace-length': -5 + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + global: {}, + stackTraceLength: 5 + } + }); + done(); + } + }); + }); + + it('should handle zero stack trace length', done => { + prepareAnnounceResponse({ + tracing: { + global: { + 'stack-trace-length': 0 + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + stackTraceLength: 0, + global: {} + } + }); + done(); + } + }); + }); + + it('should apply valid config and ignore invalid config', done => { + prepareAnnounceResponse({ + tracing: { + global: { + 'stack-trace': 'all', + 'stack-trace-length': 'invalid' + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + stackTrace: 'all', + global: {} + } + }); + done(); + } + }); + }); + + it('should handle empty global object', done => { + prepareAnnounceResponse({ + tracing: { + global: {} + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + global: {} + } + }); + done(); + } + }); + }); + }); + function prepareAnnounceResponse(announceResponse) { agentConnectionStub.announceNodeCollector.callsArgWithAsync(0, null, JSON.stringify(announceResponse)); } diff --git a/packages/collector/test/apps/agentStub.js b/packages/collector/test/apps/agentStub.js index 8923789ac3..7bc48d71bb 100644 --- a/packages/collector/test/apps/agentStub.js +++ b/packages/collector/test/apps/agentStub.js @@ -46,6 +46,7 @@ const kafkaTraceCorrelation = process.env.KAFKA_TRACE_CORRELATION : null; const ignoreEndpoints = process.env.IGNORE_ENDPOINTS && JSON.parse(process.env.IGNORE_ENDPOINTS); 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 uuids = {}; const agentLogs = []; @@ -118,7 +119,14 @@ app.put('/com.instana.plugin.nodejs.discovery', (req, res) => { } }; - if (kafkaTraceCorrelation != null || extraHeaders.length > 0 || enableSpanBatching || ignoreEndpoints || disable) { + if ( + kafkaTraceCorrelation != null || + extraHeaders.length > 0 || + enableSpanBatching || + ignoreEndpoints || + disable || + stackTraceConfig + ) { response.tracing = {}; if (extraHeaders.length > 0) { @@ -141,6 +149,10 @@ app.put('/com.instana.plugin.nodejs.discovery', (req, res) => { if (disable) { response.tracing.disable = disable; } + if (stackTraceConfig) { + response.tracing.global = response.tracing.global || {}; + deepMerge(response.tracing.global, stackTraceConfig); + } } res.send(response); diff --git a/packages/collector/test/apps/agentStubControls.js b/packages/collector/test/apps/agentStubControls.js index 8f5d16ae97..3fa888ed4e 100644 --- a/packages/collector/test/apps/agentStubControls.js +++ b/packages/collector/test/apps/agentStubControls.js @@ -64,6 +64,10 @@ class AgentStubControls { env.AGENT_DISABLE_TRACING = JSON.stringify(opts.disable); } + if (opts.stackTraceConfig) { + env.STACK_TRACE_CONFIG = JSON.stringify(opts.stackTraceConfig); + } + this.agentStub = spawn('node', [path.join(__dirname, 'agentStub.js')], { stdio: config.getAppStdio(), env diff --git a/packages/collector/test/tracing/misc/stack_trace/test.js b/packages/collector/test/tracing/misc/stack_trace/test.js index fb6c9578d7..d099ba363b 100644 --- a/packages/collector/test/tracing/misc/stack_trace/test.js +++ b/packages/collector/test/tracing/misc/stack_trace/test.js @@ -168,5 +168,244 @@ const mochaSuiteFn = supportedVersion(process.versions.node) ? describe : descri ) )); }); + + describe('verify precedence order env > in-code > agent', () => { + const { AgentStubControls } = require('../../../apps/agentStubControls'); + const agentStubControls = new AgentStubControls(); + + describe('when only agent config is provided', () => { + before(async () => { + await agentStubControls.startAgent({ + stackTraceConfig: { + 'stack-trace': 'all', + 'stack-trace-length': 3 + } + }); + }); + + after(async () => { + await agentStubControls.stopAgent(); + }); + + before(async () => { + await expressControls.start({ + agentControls: agentStubControls, + EXPRESS_VERSION: version + }); + await expressProxyControls.start({ + agentControls: agentStubControls, + expressControls, + EXPRESS_VERSION: version + // Don't set stackTraceLength - let it use default so agent config can override + }); + }); + + after(async () => { + await expressControls.stop(); + await expressProxyControls.stop(); + }); + + beforeEach(async () => { + await agentStubControls.clearReceivedTraceData(); + }); + + beforeEach(() => agentStubControls.waitUntilAppIsCompletelyInitialized(expressControls.getPid())); + beforeEach(() => agentStubControls.waitUntilAppIsCompletelyInitialized(expressProxyControls.getPid())); + + it('should apply agent config stackTraceLength to exit spans', () => + expressProxyControls + .sendRequest({ + method: 'POST', + path: '/checkout', + responseStatus: 201 + }) + .then(() => + testUtils.retry(() => + agentStubControls.getSpans().then(spans => { + expect(spans.length).to.be.at.least(2); + + const httpClientSpan = testUtils.expectAtLeastOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.client'), + span => expect(span.k).to.equal(constants.EXIT) + ]); + + expect(httpClientSpan.stack).to.be.an('array'); + expect(httpClientSpan.stack.length).to.equal(3); + }) + ) + )); + }); + + describe('when both agent and in-code config is provided', () => { + before(async () => { + await agentStubControls.startAgent({ + stackTraceConfig: { + 'stack-trace-length': 2 + } + }); + await expressControls.start({ + agentControls: agentStubControls, + EXPRESS_VERSION: version + }); + await expressProxyControls.start({ + agentControls: agentStubControls, + expressControls, + stackTraceLength: 4, + EXPRESS_VERSION: version + }); + }); + + after(async () => { + await expressControls.stop(); + await expressProxyControls.stop(); + await agentStubControls.stopAgent(); + }); + + beforeEach(async () => { + await agentStubControls.clearReceivedTraceData(); + }); + + beforeEach(() => agentStubControls.waitUntilAppIsCompletelyInitialized(expressControls.getPid())); + beforeEach(() => agentStubControls.waitUntilAppIsCompletelyInitialized(expressProxyControls.getPid())); + + it('should use in-code config over agent config', () => + expressProxyControls + .sendRequest({ + method: 'POST', + path: '/checkout', + responseStatus: 201 + }) + .then(() => + testUtils.retry(() => + agentStubControls.getSpans().then(spans => { + expect(spans.length).to.be.at.least(2); + + const httpClientSpan = testUtils.expectAtLeastOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.client'), + span => expect(span.k).to.equal(constants.EXIT) + ]); + + expect(httpClientSpan.stack).to.be.an('array'); + expect(httpClientSpan.stack.length).to.equal(4); + }) + ) + )); + }); + + describe('when env var, in-code config, and agent config are all provided', () => { + before(async () => { + await agentStubControls.startAgent({ + stackTraceConfig: { + 'stack-trace-length': 2 + } + }); + await expressControls.start({ + agentControls: agentStubControls, + EXPRESS_VERSION: version + }); + await expressProxyControls.start({ + agentControls: agentStubControls, + expressControls, + stackTraceLength: 4, + EXPRESS_VERSION: version, + env: { + INSTANA_STACK_TRACE_LENGTH: '6' + } + }); + }); + + after(async () => { + await expressControls.stop(); + await expressProxyControls.stop(); + await agentStubControls.stopAgent(); + }); + + beforeEach(async () => { + await agentStubControls.clearReceivedTraceData(); + }); + + beforeEach(() => agentStubControls.waitUntilAppIsCompletelyInitialized(expressControls.getPid())); + beforeEach(() => agentStubControls.waitUntilAppIsCompletelyInitialized(expressProxyControls.getPid())); + + it('should use env var over in-code config and agent config (env has highest priority)', () => + expressProxyControls + .sendRequest({ + method: 'POST', + path: '/checkout', + responseStatus: 201 + }) + .then(() => + testUtils.retry(() => + agentStubControls.getSpans().then(spans => { + expect(spans.length).to.be.at.least(2); + + const httpClientSpan = testUtils.expectAtLeastOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.client'), + span => expect(span.k).to.equal(constants.EXIT) + ]); + + expect(httpClientSpan.stack).to.be.an('array'); + expect(httpClientSpan.stack.length).to.equal(6); + }) + ) + )); + }); + + describe('when only env var is provided', () => { + before(async () => { + await agentStubControls.startAgent({ + stackTraceConfig: {} + }); + await expressControls.start({ + agentControls: agentStubControls, + EXPRESS_VERSION: version + }); + await expressProxyControls.start({ + agentControls: agentStubControls, + expressControls, + EXPRESS_VERSION: version, + env: { + INSTANA_STACK_TRACE_LENGTH: '5' + } + }); + }); + + after(async () => { + await expressControls.stop(); + await expressProxyControls.stop(); + await agentStubControls.stopAgent(); + }); + + beforeEach(async () => { + await agentStubControls.clearReceivedTraceData(); + }); + + beforeEach(() => agentStubControls.waitUntilAppIsCompletelyInitialized(expressControls.getPid())); + beforeEach(() => agentStubControls.waitUntilAppIsCompletelyInitialized(expressProxyControls.getPid())); + + it('should apply env var stackTraceLength to exit spans', () => + expressProxyControls + .sendRequest({ + method: 'POST', + path: '/checkout', + responseStatus: 201 + }) + .then(() => + testUtils.retry(() => + agentStubControls.getSpans().then(spans => { + expect(spans.length).to.be.at.least(2); + + const httpClientSpan = testUtils.expectAtLeastOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.client'), + span => expect(span.k).to.equal(constants.EXIT) + ]); + + expect(httpClientSpan.stack).to.be.an('array'); + expect(httpClientSpan.stack.length).to.equal(5); + }) + ) + )); + }); + }); }); }); diff --git a/packages/collector/test/tracing/protocols/http/proxy/expressProxy.js b/packages/collector/test/tracing/protocols/http/proxy/expressProxy.js index f47aafefcb..93c0349fd4 100644 --- a/packages/collector/test/tracing/protocols/http/proxy/expressProxy.js +++ b/packages/collector/test/tracing/protocols/http/proxy/expressProxy.js @@ -17,15 +17,17 @@ require('./mockVersion'); // latency and response codes. This can be used a baselines for many tests, e.g. // to test distributed tracing. -require('../../../../..')({ +const instanaConfig = { agentPort: process.env.AGENT_PORT, level: 'warn', tracing: { enabled: true, forceTransmissionStartingAt: 1, - stackTraceLength: process.env.STACK_TRACE_LENGTH != null ? process.env.STACK_TRACE_LENGTH : 10 + stackTraceLength: process.env.STACK_TRACE_LENGTH_TEST != null ? process.env.STACK_TRACE_LENGTH_TEST : 10 } -}); +}; + +require('../../../../..')(instanaConfig); const express = require('express'); const fetch = require('node-fetch-v2'); const port = require('../../../../test_util/app-port')(); diff --git a/packages/collector/test/tracing/protocols/http/proxy/expressProxyControls.js b/packages/collector/test/tracing/protocols/http/proxy/expressProxyControls.js index 350b798196..634fd3f294 100644 --- a/packages/collector/test/tracing/protocols/http/proxy/expressProxyControls.js +++ b/packages/collector/test/tracing/protocols/http/proxy/expressProxyControls.js @@ -54,7 +54,15 @@ exports.start = async (opts = {}) => { appPort = env.APP_PORT; env.UPSTREAM_PORT = opts.expressControls ? opts.expressControls.getPort() : null; - env.STACK_TRACE_LENGTH = opts.stackTraceLength || 0; + + if (opts.stackTraceLength != null) { + env.STACK_TRACE_LENGTH_TEST = opts.stackTraceLength; + } + + if (opts.env) { + Object.assign(env, opts.env); + } + env.INSTANA_RETRY_AGENT_CONNECTION_IN_MS = 100; env.EXPRESS_VERSION = opts.EXPRESS_VERSION; diff --git a/packages/core/src/config/configNormalizers/index.js b/packages/core/src/config/configNormalizers/index.js index 417a1c9d44..8c95431904 100644 --- a/packages/core/src/config/configNormalizers/index.js +++ b/packages/core/src/config/configNormalizers/index.js @@ -6,6 +6,7 @@ const disable = require('./disable'); const ignoreEndpoints = require('./ignoreEndpoints'); +const stackTrace = require('./stackTrace'); /** * @param {import('../../config').InstanaConfig} config @@ -17,3 +18,4 @@ exports.init = function init(config) { exports.disable = disable; exports.ignoreEndpoints = ignoreEndpoints; +exports.stackTrace = stackTrace; diff --git a/packages/core/src/config/configNormalizers/stackTrace.js b/packages/core/src/config/configNormalizers/stackTrace.js new file mode 100644 index 0000000000..2a0bb89ef2 --- /dev/null +++ b/packages/core/src/config/configNormalizers/stackTrace.js @@ -0,0 +1,126 @@ +/* + * (c) Copyright IBM Corp. 2025 + */ + +'use strict'; + +const { MAX_STACK_TRACE_LENGTH, validStackTraceModes } = require('../../util/constants'); + +/** + * Normalizes stackTrace mode from config + * @param {import('../../config').InstanaConfig} config + * @returns {string} - Normalized value + */ +exports.normalizeStackTraceMode = function (config) { + const value = config?.tracing?.global?.stackTrace; + if (typeof value === 'string') { + const normalized = value.toLowerCase(); + if (validStackTraceModes.includes(normalized)) { + return normalized; + } + } + + return null; +}; + +/** + * Normalizes stack trace length configuration based on precedence. + * Precedence: global config > config > env var > default + * @param {import('../../config').InstanaConfig} config + * @returns {number} - Normalized value + */ +exports.normalizeStackTraceLength = function (config) { + const tracingObj = config?.tracing; + + // Note: For in-code configuration, global takes precedence, + // because we are migrating away from legacy config tracing.stackTraceLength + // tracing.global.stackTraceLength > tracing.stackTraceLength + if (tracingObj?.global?.stackTraceLength != null) { + const parsed = + typeof tracingObj.global.stackTraceLength === 'number' + ? tracingObj.global.stackTraceLength + : parseInt(tracingObj.global.stackTraceLength, 10); + if (!isNaN(parsed) && Number.isFinite(parsed)) { + return normalizeNumericalStackTraceLength(parsed); + } + } + + if (tracingObj?.stackTraceLength != null) { + const parsed = + typeof tracingObj.stackTraceLength === 'number' + ? tracingObj.stackTraceLength + : parseInt(tracingObj.stackTraceLength, 10); + if (!isNaN(parsed) && Number.isFinite(parsed)) { + return normalizeNumericalStackTraceLength(parsed); + } + } + + return null; +}; + +/** + * Normalizes stackTrace mode from agent config + * @param {*} stackTraceValue - tracing.global[stack-trace] config from agent + * @returns {string | null} + */ +exports.normalizeStackTraceModeFromAgent = function (stackTraceValue) { + return stackTraceValue?.toLowerCase(); +}; + +/** + * Normalizes stackTraceLength from agent config + * @param {*} stackTraceLength - tracing.global[stack-trace-length] from agent + * @returns {number | null} + */ +exports.normalizeStackTraceLengthFromAgent = function (stackTraceLength) { + const parsed = typeof stackTraceLength === 'number' ? stackTraceLength : parseInt(stackTraceLength, 10); + if (!isNaN(parsed) && Number.isFinite(parsed)) { + return normalizeNumericalStackTraceLength(parsed); + } + + return null; +}; + +/** + * Normalizes a numerical stack trace length value. + * Ensures it's a positive integer within the maximum limit. + * + * @param {number} numericalLength + * @returns {number} + */ +function normalizeNumericalStackTraceLength(numericalLength) { + const normalized = Math.abs(Math.round(numericalLength)); + return Math.min(normalized, MAX_STACK_TRACE_LENGTH); +} + +/** + * Normalizes stackTrace mode from environment variable value + * @param {string} envValue - The environment variable value to normalize + * @returns {string | null} + */ +exports.normalizeStackTraceModeFromEnv = function (envValue) { + if (envValue) { + const normalized = String(envValue).toLowerCase(); + if (validStackTraceModes.includes(normalized)) { + return normalized; + } + } + + return null; +}; + +/** + * Normalizes stackTraceLength from environment variable value + * @param {string} envValue - The environment variable value to normalize + * @returns {number | null} + */ +exports.normalizeStackTraceLengthFromEnv = function (envValue) { + if (envValue) { + const parsed = parseInt(envValue, 10); + if (!isNaN(parsed) && Number.isFinite(parsed)) { + return normalizeNumericalStackTraceLength(parsed); + } + } + + return null; +}; diff --git a/packages/core/src/config/configValidators/index.js b/packages/core/src/config/configValidators/index.js new file mode 100644 index 0000000000..3b7bcdf816 --- /dev/null +++ b/packages/core/src/config/configValidators/index.js @@ -0,0 +1,9 @@ +/* + * (c) Copyright IBM Corp. 2025 + */ + +'use strict'; + +const stackTraceValidation = require('./stackTraceValidation'); + +module.exports.stackTraceValidation = stackTraceValidation; diff --git a/packages/core/src/config/configValidators/stackTraceValidation.js b/packages/core/src/config/configValidators/stackTraceValidation.js new file mode 100644 index 0000000000..94b3aaa4ee --- /dev/null +++ b/packages/core/src/config/configValidators/stackTraceValidation.js @@ -0,0 +1,78 @@ +/* + * (c) Copyright IBM Corp. 2025 + */ + +'use strict'; + +const { validStackTraceModes } = require('../../util/constants'); + +/** + * Validates the stack trace mode value. + * + * @param {*} value - The value to validate + * @returns {{ isValid: boolean, error: string | null }} - Validation result + */ +exports.validateStackTraceMode = function validateStackTraceMode(value) { + if (value === null) { + return { isValid: false, error: `The value cannot be null. Valid values are: ${validStackTraceModes.join(', ')}.` }; + } + + if (typeof value !== 'string') { + return { + isValid: false, + error: `The value has the non-supported type ${typeof value}. Valid values are: ${validStackTraceModes.join( + ', ' + )}.` + }; + } + + const normalizedValue = value.toLowerCase(); + if (validStackTraceModes.includes(normalizedValue)) { + return { isValid: true, error: null }; + } + + return { + isValid: false, + error: `Invalid value: "${value}". Valid values are: ${validStackTraceModes.join(', ')}.` + }; +}; + +/** + * Validates the stack trace length value. + * + * @param {*} value - The value to validate + * @returns {{ isValid: boolean, error: string | null }} - Validation result + */ +exports.validateStackTraceLength = function validateStackTraceLength(value) { + if (value == null) { + return { isValid: false, error: 'The value cannot be null' }; + } + + let parsedValue; + + if (typeof value === 'number') { + parsedValue = value; + } else if (typeof value === 'string') { + parsedValue = parseInt(value, 10); + if (isNaN(parsedValue)) { + return { + isValid: false, + error: `The value ("${value}") cannot be parsed to a numerical value.` + }; + } + } else { + return { + isValid: false, + error: `The value has the non-supported type ${typeof value}.` + }; + } + + if (!Number.isFinite(parsedValue)) { + return { + isValid: false, + error: `Invalid value: ${value}. Expected a number or numeric string.` + }; + } + + return { isValid: true, error: null }; +}; diff --git a/packages/core/src/config/index.js b/packages/core/src/config/index.js index a436d2d1b7..bbc2a7fcd4 100644 --- a/packages/core/src/config/index.js +++ b/packages/core/src/config/index.js @@ -9,7 +9,10 @@ const supportedTracingVersion = require('../tracing/supportedVersion'); const configNormalizers = require('./configNormalizers'); +const configValidators = require('./configValidators'); const deepMerge = require('../util/deepMerge'); +const { DEFAULT_STACK_TRACE_LENGTH, DEFAULT_STACK_TRACE_MODE } = require('../util/constants'); +const { validateStackTraceMode, validateStackTraceLength } = require('./configValidators/stackTraceValidation'); /** * @typedef {Object} InstanaTracingOption @@ -21,6 +24,7 @@ const deepMerge = require('../util/deepMerge'); * @property {number} [initialTransmissionDelay] * @property {number} [maxBufferedSpans] * @property {number} [transmissionDelay] + * @property {string} [stackTrace] * @property {number} [stackTraceLength] * @property {HTTPTracingOptions} [http] * @property {import('../config/types').Disable} [disable] @@ -31,6 +35,7 @@ const deepMerge = require('../util/deepMerge'); * @property {import('../config/types').IgnoreEndpoints} [ignoreEndpoints] * @property {boolean} [ignoreEndpointsDisableSuppression] * @property {boolean} [disableEOLEvents] + * @property {globalStackTraceConfig} [global] */ /** @@ -43,6 +48,12 @@ const deepMerge = require('../util/deepMerge'); * @property {boolean} [traceCorrelation] */ +/** + * @typedef {Object} globalStackTraceConfig + * @property {string} [stackTrace] + * @property {number} [stackTraceLength] + */ + /** * @typedef {Object} InstanaMetricsOption * @property {number} [transmissionDelay] @@ -99,7 +110,8 @@ let defaults = { http: { extraHttpHeadersToCapture: [] }, - stackTraceLength: 10, + stackTrace: DEFAULT_STACK_TRACE_MODE, + stackTraceLength: DEFAULT_STACK_TRACE_LENGTH, disable: {}, spanBatchingEnabled: false, disableW3cTraceCorrelation: false, @@ -119,6 +131,7 @@ let defaults = { const validSecretsMatcherModes = ['equals-ignore-case', 'equals', 'contains-ignore-case', 'contains', 'regex', 'none']; module.exports.configNormalizers = configNormalizers; +module.exports.configValidators = configValidators; /** * @param {import('../core').GenericLogger} [_logger] @@ -226,7 +239,7 @@ function normalizeTracingConfig(config) { normalizeActivateImmediately(config); normalizeTracingTransmission(config); normalizeTracingHttp(config); - normalizeTracingStackTraceLength(config); + normalizeTracingStackTrace(config); normalizeSpanBatchingEnabled(config); normalizeDisableW3cTraceCorrelation(config); normalizeTracingKafka(config); @@ -442,60 +455,88 @@ function parseHeadersEnvVar(envVarValue) { } /** + * Handles both stackTrace and stackTraceLength configuration * @param {InstanaConfig} config */ -function normalizeTracingStackTraceLength(config) { - if (config.tracing.stackTraceLength == null && process.env['INSTANA_STACK_TRACE_LENGTH']) { - parseStringStackTraceLength(config, process.env['INSTANA_STACK_TRACE_LENGTH']); +function normalizeTracingStackTrace(config) { + const tracing = config.tracing; + + const envStackTrace = process.env['INSTANA_STACK_TRACE']; + const envStackTraceLength = process.env['INSTANA_STACK_TRACE_LENGTH']; + + if (envStackTrace !== undefined) { + const result = validateStackTraceMode(envStackTrace); + + if (result.isValid) { + const normalized = configNormalizers.stackTrace.normalizeStackTraceModeFromEnv(envStackTrace); + if (normalized !== null) { + tracing.stackTrace = normalized; + } else { + tracing.stackTrace = defaults.tracing.stackTrace; + } + } else { + logger.warn(`Invalid env INSTANA_STACK_TRACE: ${result.error}`); + tracing.stackTrace = defaults.tracing.stackTrace; + } + } else if (tracing.global?.stackTrace !== undefined) { + const result = validateStackTraceMode(tracing.global.stackTrace); + + if (result.isValid) { + const normalized = configNormalizers.stackTrace.normalizeStackTraceMode(config); + if (normalized !== null) { + tracing.stackTrace = normalized; + } else { + tracing.stackTrace = defaults.tracing.stackTrace; + } + } else { + logger.warn(`Invalid config.tracing.global.stackTrace: ${result.error}`); + tracing.stackTrace = defaults.tracing.stackTrace; + } + } else { + tracing.stackTrace = defaults.tracing.stackTrace; } - if (config.tracing.stackTraceLength != null) { - if (typeof config.tracing.stackTraceLength === 'number') { - config.tracing.stackTraceLength = normalizeNumericalStackTraceLength(config.tracing.stackTraceLength); - } else if (typeof config.tracing.stackTraceLength === 'string') { - parseStringStackTraceLength(config, config.tracing.stackTraceLength); + + const isLegacyLengthDefined = tracing.stackTraceLength !== undefined; + const stackTraceConfigValue = tracing.global?.stackTraceLength || tracing.stackTraceLength; + + if (envStackTraceLength !== undefined) { + const result = validateStackTraceLength(envStackTraceLength); + + if (result.isValid) { + const normalized = configNormalizers.stackTrace.normalizeStackTraceLengthFromEnv(envStackTraceLength); + if (normalized !== null) { + tracing.stackTraceLength = normalized; + } else { + tracing.stackTraceLength = defaults.tracing.stackTraceLength; + } } else { + logger.warn(`Invalid env INSTANA_STACK_TRACE_LENGTH: ${result.error}`); + tracing.stackTraceLength = defaults.tracing.stackTraceLength; + } + } else if (stackTraceConfigValue !== undefined) { + if (isLegacyLengthDefined) { logger.warn( - `The value of config.tracing.stackTraceLength has the non-supported type ${typeof config.tracing - .stackTraceLength} (the value is "${config.tracing.stackTraceLength}").` + - `Assuming the default stack trace length ${defaults.tracing.stackTraceLength}.` + '[Deprecation Warning] The configuration option config.tracing.stackTraceLength is deprecated and will be removed in a future release. ' + + 'Please use config.tracing.global.stackTraceLength instead.' ); - config.tracing.stackTraceLength = defaults.tracing.stackTraceLength; } - } else { - config.tracing.stackTraceLength = defaults.tracing.stackTraceLength; - } -} -/** - * @param {InstanaConfig} config - * @param {string} value - */ -function parseStringStackTraceLength(config, value) { - config.tracing.stackTraceLength = parseInt(value, 10); - if (!isNaN(config.tracing.stackTraceLength)) { - config.tracing.stackTraceLength = normalizeNumericalStackTraceLength(config.tracing.stackTraceLength); - } else { - logger.warn( - `The value of config.tracing.stackTraceLength ("${value}") has type string and cannot be parsed to a numerical ` + - `value. Assuming the default stack trace length ${defaults.tracing.stackTraceLength}.` - ); - config.tracing.stackTraceLength = defaults.tracing.stackTraceLength; - } -} + const result = validateStackTraceLength(stackTraceConfigValue); -/** - * @param {number} numericalLength - * @returns {number} - */ -function normalizeNumericalStackTraceLength(numericalLength) { - // just in case folks provide non-integral numbers or negative numbers - const normalized = Math.abs(Math.round(numericalLength)); - if (normalized !== numericalLength) { - logger.warn( - `Normalized the provided value of config.tracing.stackTraceLength (${numericalLength}) to ${normalized}.` - ); + if (result.isValid) { + const normalized = configNormalizers.stackTrace.normalizeStackTraceLength(config); + if (normalized !== null) { + tracing.stackTraceLength = normalized; + } else { + tracing.stackTraceLength = defaults.tracing.stackTraceLength; + } + } else { + logger.warn(`Invalid stackTraceLength value: ${result.error}`); + tracing.stackTraceLength = defaults.tracing.stackTraceLength; + } + } else { + tracing.stackTraceLength = defaults.tracing.stackTraceLength; } - return normalized; } /** diff --git a/packages/core/src/tracing/index.js b/packages/core/src/tracing/index.js index f8afdb00fa..72abd2a7d0 100644 --- a/packages/core/src/tracing/index.js +++ b/packages/core/src/tracing/index.js @@ -263,6 +263,7 @@ exports.activate = function activate(extraConfig = {}) { if (tracingEnabled && !tracingActivated) { tracingActivated = true; coreUtil.activate(extraConfig); + tracingUtil.activate(extraConfig); spanBuffer.activate(extraConfig); opentracing.activate(); sdk.activate(); diff --git a/packages/core/src/tracing/tracingUtil.js b/packages/core/src/tracing/tracingUtil.js index 1e4bca3980..ffc25cdebd 100644 --- a/packages/core/src/tracing/tracingUtil.js +++ b/packages/core/src/tracing/tracingUtil.js @@ -10,18 +10,50 @@ const path = require('path'); const StringDecoder = require('string_decoder').StringDecoder; const stackTrace = require('../util/stackTrace'); +const { DEFAULT_STACK_TRACE_LENGTH, DEFAULT_STACK_TRACE_MODE } = require('../util/constants'); /** @type {import('../core').GenericLogger} */ let logger; const hexDecoder = new StringDecoder('hex'); -let stackTraceLength = 10; - +/** + * @type {number} + */ +let stackTraceLength; +/** + * @type {string} + */ +// eslint-disable-next-line no-unused-vars +let stackTraceMode; /** * @param {import('../config').InstanaConfig} config */ exports.init = function (config) { logger = config.logger; - stackTraceLength = config.tracing.stackTraceLength; + stackTraceLength = config?.tracing?.stackTraceLength; + stackTraceMode = config?.tracing?.stackTrace; +}; + +/** + * @param {import('@instana/collector/src/types/collector').AgentConfig} extraConfig + */ +exports.activate = function activate(extraConfig) { + const agentTraceConfig = extraConfig?.tracing; + + // Note: We check whether the already-initialized stackTraceLength equals the default value. + // If it does, we can safely override it, since the user did not explicitly configure it. + + // Note: If the user configured a value via env or code and also configured a different value in the agent, + // but the env/code value happens to equal the default, the agent value would overwrite it. + // This is a rare edge case and acceptable for now. + + if (agentTraceConfig?.stackTrace && stackTraceMode === DEFAULT_STACK_TRACE_MODE) { + stackTraceMode = agentTraceConfig.stackTrace; + } + + // stackTraceLength is valid when set to any number, including 0 + if (agentTraceConfig?.stackTraceLength != null && stackTraceLength === DEFAULT_STACK_TRACE_LENGTH) { + stackTraceLength = agentTraceConfig.stackTraceLength; + } }; /** @@ -339,7 +371,7 @@ exports.setErrorDetails = function setErrorDetails(span, error, technology) { if (normalizedError.stack) { const stackArray = stackTrace.parseStackTraceFromString(normalizedError.stack); - span.stack = stackArray.length > 0 ? stackArray : span.stack || []; + span.stack = stackArray.length > 0 ? stackArray.slice(0, stackTraceLength) : span.stack || []; } else { span.stack = span.stack || []; } diff --git a/packages/core/src/util/constants.js b/packages/core/src/util/constants.js new file mode 100644 index 0000000000..b07976d933 --- /dev/null +++ b/packages/core/src/util/constants.js @@ -0,0 +1,14 @@ +/* + * (c) Copyright IBM Corp. 2025 + */ + +'use strict'; + +/** + * Valid modes for stack trace configuration + * @type {string[]} + */ +exports.validStackTraceModes = ['error', 'all', 'none']; +exports.MAX_STACK_TRACE_LENGTH = 500; +exports.DEFAULT_STACK_TRACE_LENGTH = 10; +exports.DEFAULT_STACK_TRACE_MODE = 'all'; diff --git a/packages/core/src/util/index.js b/packages/core/src/util/index.js index b47cf8ab62..c675887b66 100644 --- a/packages/core/src/util/index.js +++ b/packages/core/src/util/index.js @@ -8,6 +8,7 @@ const applicationUnderMonitoring = require('./applicationUnderMonitoring'); const clone = require('./clone'); const compression = require('./compression'); +const constants = require('./constants'); const ensureNestedObjectExists = require('./ensureNestedObjectExists'); const excludedFromInstrumentation = require('./excludedFromInstrumentation'); const hasThePackageBeenInitializedTooLate = require('./initializedTooLateHeuristic'); @@ -85,6 +86,7 @@ exports.atMostOnce = function atMostOnce(name, cb) { exports.preloadFlags = preloadFlags; exports.clone = clone; exports.compression = compression; +exports.constants = constants; exports.ensureNestedObjectExists = ensureNestedObjectExists; exports.excludedFromInstrumentation = excludedFromInstrumentation; exports.hasThePackageBeenInitializedTooLate = hasThePackageBeenInitializedTooLate; diff --git a/packages/core/test/config/configNormalizers/stackTrace_test.js b/packages/core/test/config/configNormalizers/stackTrace_test.js new file mode 100644 index 0000000000..b3e0ffc6e0 --- /dev/null +++ b/packages/core/test/config/configNormalizers/stackTrace_test.js @@ -0,0 +1,636 @@ +/* + * (c) Copyright IBM Corp. 2025 + */ + +'use strict'; + +const { describe, it, beforeEach } = require('mocha'); +const { expect } = require('chai'); + +const stackTraceNormalizer = require('../../../src/config/configNormalizers/stackTrace'); +const { MAX_STACK_TRACE_LENGTH } = require('../../../src/util/constants'); + +function resetEnv() { + delete process.env.INSTANA_STACK_TRACE; + delete process.env.INSTANA_STACK_TRACE_LENGTH; +} + +describe('config.configNormalizers.stackTrace', () => { + beforeEach(() => { + resetEnv(); + }); + + describe('normalizeStackTraceMode()', () => { + it('should return null when no config is provided', () => { + const config = {}; + const result = stackTraceNormalizer.normalizeStackTraceMode(config); + + expect(result).to.be.null; + }); + + it('should return null when config is null', () => { + const result = stackTraceNormalizer.normalizeStackTraceMode(null); + + expect(result).to.be.null; + }); + + it('should return null when config is undefined', () => { + const result = stackTraceNormalizer.normalizeStackTraceMode(undefined); + + expect(result).to.be.null; + }); + + it('should return null when tracing is not present', () => { + const config = { someOtherConfig: true }; + const result = stackTraceNormalizer.normalizeStackTraceMode(config); + + expect(result).to.be.null; + }); + + it('should accept valid stack trace mode from config.tracing.global.stackTrace (all)', () => { + const config = { + tracing: { + global: { + stackTrace: 'all' + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceMode(config); + + expect(result).to.equal('all'); + }); + + it('should accept valid stack trace mode from config.tracing.global.stackTrace (error)', () => { + const config = { + tracing: { + global: { + stackTrace: 'error' + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceMode(config); + + expect(result).to.equal('error'); + }); + + it('should accept valid stack trace mode from config.tracing.global.stackTrace (none)', () => { + const config = { + tracing: { + global: { + stackTrace: 'none' + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceMode(config); + + expect(result).to.equal('none'); + }); + + it('should normalize stack trace mode to lowercase', () => { + const config = { + tracing: { + global: { + stackTrace: 'ALL' + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceMode(config); + + expect(result).to.equal('all'); + }); + + it('should normalize mixed case stack trace mode', () => { + const config = { + tracing: { + global: { + stackTrace: 'ErRoR' + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceMode(config); + + expect(result).to.equal('error'); + }); + + it('should return null for invalid stack trace mode', () => { + const config = { + tracing: { + global: { + stackTrace: 'invalid' + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceMode(config); + + expect(result).to.be.null; + }); + + it('should return null when config.tracing.global is empty', () => { + const config = { + tracing: { + global: {} + } + }; + const result = stackTraceNormalizer.normalizeStackTraceMode(config); + + expect(result).to.be.null; + }); + + it('should return null for non-string values', () => { + const config = { + tracing: { + global: { + stackTrace: 123 + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceMode(config); + + expect(result).to.be.null; + }); + }); + + describe('normalizeStackTraceLength()', () => { + it('should return null when no config is provided', () => { + const config = {}; + const result = stackTraceNormalizer.normalizeStackTraceLength(config); + + expect(result).to.be.null; + }); + + it('should return null when config is null', () => { + const result = stackTraceNormalizer.normalizeStackTraceLength(null); + + expect(result).to.be.null; + }); + + it('should return null when config is undefined', () => { + const result = stackTraceNormalizer.normalizeStackTraceLength(undefined); + + expect(result).to.be.null; + }); + + it('should accept valid numeric stack trace length from config.tracing.global.stackTraceLength', () => { + const config = { + tracing: { + global: { + stackTraceLength: 20 + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceLength(config); + + expect(result).to.equal(20); + }); + + it('should accept string numeric stack trace length from config', () => { + const config = { + tracing: { + global: { + stackTraceLength: '25' + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceLength(config); + + expect(result).to.equal(25); + }); + + it('should round decimal values', () => { + const config = { + tracing: { + global: { + stackTraceLength: 15.7 + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceLength(config); + + expect(result).to.equal(16); + }); + + it('should convert negative values to positive', () => { + const config = { + tracing: { + global: { + stackTraceLength: -10 + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceLength(config); + + expect(result).to.equal(10); + }); + + it('should cap at MAX_STACK_TRACE_LENGTH', () => { + const config = { + tracing: { + global: { + stackTraceLength: 1000 + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceLength(config); + + expect(result).to.equal(MAX_STACK_TRACE_LENGTH); + }); + + it('should handle zero value', () => { + const config = { + tracing: { + global: { + stackTraceLength: 0 + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceLength(config); + + expect(result).to.equal(0); + }); + + it('should prioritize config.tracing.global.stackTraceLength over tracing.stackTraceLength', () => { + const config = { + tracing: { + global: { + stackTraceLength: 30 + }, + stackTraceLength: 20 + } + }; + const result = stackTraceNormalizer.normalizeStackTraceLength(config); + + expect(result).to.equal(30); + }); + + it('should fall back to tracing.stackTraceLength when global is not set', () => { + const config = { + tracing: { + stackTraceLength: 25 + } + }; + const result = stackTraceNormalizer.normalizeStackTraceLength(config); + + expect(result).to.equal(25); + }); + + it('should return null for non-numeric string', () => { + const config = { + tracing: { + global: { + stackTraceLength: 'invalid' + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceLength(config); + + expect(result).to.be.null; + }); + + it('should return null for Infinity', () => { + const config = { + tracing: { + global: { + stackTraceLength: Infinity + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceLength(config); + + expect(result).to.be.null; + }); + + it('should return null for NaN', () => { + const config = { + tracing: { + global: { + stackTraceLength: NaN + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceLength(config); + + expect(result).to.be.null; + }); + + it('should handle stackTraceLength exactly at MAX_STACK_TRACE_LENGTH', () => { + const config = { + tracing: { + global: { + stackTraceLength: MAX_STACK_TRACE_LENGTH + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceLength(config); + + expect(result).to.equal(MAX_STACK_TRACE_LENGTH); + }); + + it('should handle stackTraceLength just below MAX_STACK_TRACE_LENGTH', () => { + const config = { + tracing: { + global: { + stackTraceLength: MAX_STACK_TRACE_LENGTH - 1 + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceLength(config); + + expect(result).to.equal(MAX_STACK_TRACE_LENGTH - 1); + }); + + it('should handle very large negative stackTraceLength', () => { + const config = { + tracing: { + global: { + stackTraceLength: -1000 + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceLength(config); + + expect(result).to.equal(MAX_STACK_TRACE_LENGTH); + }); + + it('should handle decimal string for stackTraceLength', () => { + const config = { + tracing: { + global: { + stackTraceLength: '15.8' + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceLength(config); + + expect(result).to.equal(15); + }); + + it('should handle string with leading/trailing spaces for stackTraceLength', () => { + const config = { + tracing: { + global: { + stackTraceLength: ' 20 ' + } + } + }; + const result = stackTraceNormalizer.normalizeStackTraceLength(config); + + expect(result).to.equal(20); + }); + }); + + describe('normalizeStackTraceModeFromAgent()', () => { + it('should normalize agent stack trace mode to lowercase', () => { + const result = stackTraceNormalizer.normalizeStackTraceModeFromAgent('ALL'); + + expect(result).to.equal('all'); + }); + + it('should handle lowercase input', () => { + const result = stackTraceNormalizer.normalizeStackTraceModeFromAgent('error'); + + expect(result).to.equal('error'); + }); + + it('should handle mixed case input', () => { + const result = stackTraceNormalizer.normalizeStackTraceModeFromAgent('NoNe'); + + expect(result).to.equal('none'); + }); + }); + + describe('normalizeStackTraceLengthFromAgent()', () => { + it('should accept valid numeric stack trace length from agent', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromAgent(20); + + expect(result).to.equal(20); + }); + + it('should accept string numeric stack trace length from agent', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromAgent('25'); + + expect(result).to.equal(25); + }); + + it('should round decimal values from agent', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromAgent(15.7); + + expect(result).to.equal(16); + }); + + it('should convert negative values to positive from agent', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromAgent(-10); + + expect(result).to.equal(10); + }); + + it('should cap at MAX_STACK_TRACE_LENGTH from agent', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromAgent(1000); + + expect(result).to.equal(MAX_STACK_TRACE_LENGTH); + }); + + it('should return null for invalid agent stack trace length', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromAgent('invalid'); + + expect(result).to.be.null; + }); + + it('should return null for null', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromAgent(null); + + expect(result).to.be.null; + }); + + it('should return null for undefined', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromAgent(undefined); + + expect(result).to.be.null; + }); + + it('should handle zero value from agent', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromAgent(0); + + expect(result).to.equal(0); + }); + + it('should return null for Infinity', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromAgent(Infinity); + + expect(result).to.be.null; + }); + + it('should return null for NaN', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromAgent(NaN); + + expect(result).to.be.null; + }); + + it('should return null for object', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromAgent({}); + + expect(result).to.be.null; + }); + + it('should parse from array', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromAgent([23]); + + expect(result).to.eq(23); + }); + }); + + describe('normalizeStackTraceModeFromEnv()', () => { + it('should normalize valid env value to lowercase', () => { + const result = stackTraceNormalizer.normalizeStackTraceModeFromEnv('ALL'); + + expect(result).to.equal('all'); + }); + + it('should handle lowercase input', () => { + const result = stackTraceNormalizer.normalizeStackTraceModeFromEnv('error'); + + expect(result).to.equal('error'); + }); + + it('should handle mixed case input', () => { + const result = stackTraceNormalizer.normalizeStackTraceModeFromEnv('NoNe'); + + expect(result).to.equal('none'); + }); + + it('should return null for invalid mode', () => { + const result = stackTraceNormalizer.normalizeStackTraceModeFromEnv('invalid'); + + expect(result).to.be.null; + }); + + it('should return null for empty string', () => { + const result = stackTraceNormalizer.normalizeStackTraceModeFromEnv(''); + + expect(result).to.be.null; + }); + + it('should return null for null', () => { + const result = stackTraceNormalizer.normalizeStackTraceModeFromEnv(null); + + expect(result).to.be.null; + }); + + it('should return null for undefined', () => { + const result = stackTraceNormalizer.normalizeStackTraceModeFromEnv(undefined); + + expect(result).to.be.null; + }); + + it('should handle numeric string by converting to string', () => { + const result = stackTraceNormalizer.normalizeStackTraceModeFromEnv(123); + + expect(result).to.be.null; + }); + + describe('normalizeStackTraceLengthFromEnv()', () => { + it('should accept valid numeric string from env', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromEnv('20'); + + expect(result).to.equal(20); + }); + + it('should round decimal string values', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromEnv('15.2'); + + expect(result).to.equal(15); + }); + + it('should convert negative string values to positive', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromEnv('-10'); + + expect(result).to.equal(10); + }); + + it('should cap at MAX_STACK_TRACE_LENGTH', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromEnv('1000'); + + expect(result).to.equal(MAX_STACK_TRACE_LENGTH); + }); + + it('should handle zero value', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromEnv('0'); + + expect(result).to.equal(0); + }); + + it('should return null for invalid string', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromEnv('invalid'); + + expect(result).to.be.null; + }); + + it('should return null for empty string', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromEnv(''); + + expect(result).to.be.null; + }); + + it('should return null for null', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromEnv(null); + + expect(result).to.be.null; + }); + + it('should return null for undefined', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromEnv(undefined); + + expect(result).to.be.null; + }); + + it('should handle string with leading/trailing spaces', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromEnv(' 25 '); + + expect(result).to.equal(25); + }); + + it('should return null for Infinity string', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromEnv('Infinity'); + + expect(result).to.be.null; + }); + + it('should return null for NaN string', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromEnv('NaN'); + + expect(result).to.be.null; + }); + + it('should handle very large negative string value', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromEnv('-1000'); + + expect(result).to.equal(MAX_STACK_TRACE_LENGTH); + }); + + it('should handle stackTraceLength exactly at MAX_STACK_TRACE_LENGTH', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromEnv(String(MAX_STACK_TRACE_LENGTH)); + + expect(result).to.equal(MAX_STACK_TRACE_LENGTH); + }); + + it('should handle stackTraceLength just below MAX_STACK_TRACE_LENGTH', () => { + const result = stackTraceNormalizer.normalizeStackTraceLengthFromEnv(String(MAX_STACK_TRACE_LENGTH - 1)); + + expect(result).to.equal(MAX_STACK_TRACE_LENGTH - 1); + }); + + it('should normalize INSTANA_STACK_TRACE_LENGTH=0 from environment variable to 0', () => { + process.env.INSTANA_STACK_TRACE_LENGTH = '0'; + const result = stackTraceNormalizer.normalizeStackTraceLengthFromEnv(process.env.INSTANA_STACK_TRACE_LENGTH); + + expect(result).to.equal(0); + }); + + it('should normalize INSTANA_STACK_TRACE_LENGTH=0 from environment variable to 0', () => { + process.env.INSTANA_STACK_TRACE_LENGTH = 0; + const result = stackTraceNormalizer.normalizeStackTraceLengthFromEnv(process.env.INSTANA_STACK_TRACE_LENGTH); + + expect(result).to.equal(0); + }); + }); + }); +}); diff --git a/packages/core/test/config/normalizeConfig_test.js b/packages/core/test/config/normalizeConfig_test.js index bf51b40cd0..7c35400caf 100644 --- a/packages/core/test/config/normalizeConfig_test.js +++ b/packages/core/test/config/normalizeConfig_test.js @@ -30,6 +30,7 @@ describe('config.normalizeConfig', () => { delete process.env.INSTANA_METRICS_TRANSMISSION_DELAY; delete process.env.INSTANA_SECRETS; delete process.env.INSTANA_SERVICE_NAME; + delete process.env.INSTANA_STACK_TRACE; delete process.env.INSTANA_STACK_TRACE_LENGTH; delete process.env.INSTANA_TRACING_TRANSMISSION_DELAY; delete process.env.INSTANA_SPANBATCHING_ENABLED; @@ -220,7 +221,7 @@ describe('config.normalizeConfig', () => { it('should accept numerical custom stack trace length', () => { const config = coreConfig.normalize({ tracing: { stackTraceLength: 666 } }); - expect(config.tracing.stackTraceLength).to.equal(666); + expect(config.tracing.stackTraceLength).to.equal(500); }); it('should normalize numbers for custom stack trace length', () => { @@ -232,7 +233,7 @@ describe('config.normalizeConfig', () => { it('should accept number-like strings for custom stack trace length', () => { const config = coreConfig.normalize({ tracing: { stackTraceLength: '1302' } }); expect(config.tracing.stackTraceLength).to.be.a('number'); - expect(config.tracing.stackTraceLength).to.equal(1302); + expect(config.tracing.stackTraceLength).to.equal(500); }); it('should normalize number-like strings for custom stack trace length', () => { @@ -259,6 +260,253 @@ describe('config.normalizeConfig', () => { expect(config.tracing.stackTraceLength).to.equal(3); }); + it('should give precedence to INSTANA_STACK_TRACE_LENGTH over config', () => { + process.env.INSTANA_STACK_TRACE_LENGTH = '5'; + const normalizedConfig = coreConfig.normalize({ tracing: { stackTraceLength: 20 } }); + expect(normalizedConfig.tracing.stackTraceLength).to.equal(5); + delete process.env.INSTANA_STACK_TRACE_LENGTH; + }); + + it('should use default stack trace mode', () => { + const config = coreConfig.normalize(); + expect(config.tracing.stackTrace).to.equal('all'); + }); + + it('should accept valid stack trace mode from config', () => { + const config = coreConfig.normalize({ tracing: { global: { stackTrace: 'error' } } }); + expect(config.tracing.stackTrace).to.equal('error'); + }); + + it('should accept "none" stack trace mode from config', () => { + const config = coreConfig.normalize({ tracing: { global: { stackTrace: 'none' } } }); + expect(config.tracing.stackTrace).to.equal('none'); + }); + + it('should normalize stack trace mode to lowercase from config', () => { + const config = coreConfig.normalize({ tracing: { global: { stackTrace: 'ERROR' } } }); + expect(config.tracing.stackTrace).to.equal('error'); + }); + + it('should read stack trace mode from INSTANA_STACK_TRACE', () => { + process.env.INSTANA_STACK_TRACE = 'error'; + const config = coreConfig.normalize(); + expect(config.tracing.stackTrace).to.equal('error'); + }); + + it('should normalize stack trace mode to lowercase from INSTANA_STACK_TRACE', () => { + process.env.INSTANA_STACK_TRACE = 'NONE'; + const config = coreConfig.normalize(); + expect(config.tracing.stackTrace).to.equal('none'); + }); + + it('should give precedence to env INSTANA_STACK_TRACE over config', () => { + process.env.INSTANA_STACK_TRACE = 'none'; + const config = coreConfig.normalize({ tracing: { global: { stackTrace: 'all' } } }); + expect(config.tracing.stackTrace).to.equal('none'); + }); + + it('should reject invalid stack trace mode from config and fallback to default', () => { + const config = coreConfig.normalize({ tracing: { global: { stackTrace: 'invalid' } } }); + expect(config.tracing.stackTrace).to.equal('all'); + }); + + it('should reject invalid stack trace mode from INSTANA_STACK_TRACE and use default', () => { + process.env.INSTANA_STACK_TRACE = 'invalid'; + const config = coreConfig.normalize(); + expect(config.tracing.stackTrace).to.equal('all'); + }); + + it('should reject non-string stack trace mode from config', () => { + const config = coreConfig.normalize({ tracing: { global: { stackTrace: 123 } } }); + expect(config.tracing.stackTrace).to.equal('all'); + }); + + it('should handle null stack trace mode from config', () => { + const config = coreConfig.normalize({ tracing: { global: { stackTrace: null } } }); + expect(config.tracing.stackTrace).to.equal('all'); + }); + + it('should handle undefined stack trace mode from config', () => { + const config = coreConfig.normalize({ tracing: { global: { stackTrace: undefined } } }); + expect(config.tracing.stackTrace).to.equal('all'); + }); + + it('should handle empty string stack trace mode from config', () => { + const config = coreConfig.normalize({ tracing: { global: { stackTrace: '' } } }); + expect(config.tracing.stackTrace).to.equal('all'); + }); + + it('should handle boolean stack trace mode from config', () => { + const config = coreConfig.normalize({ tracing: { global: { stackTrace: true } } }); + expect(config.tracing.stackTrace).to.equal('all'); + }); + + it('should handle object stack trace mode from config', () => { + const config = coreConfig.normalize({ tracing: { global: { stackTrace: {} } } }); + expect(config.tracing.stackTrace).to.equal('all'); + }); + + it('should handle array stack trace mode from config', () => { + const config = coreConfig.normalize({ tracing: { global: { stackTrace: ['error'] } } }); + expect(config.tracing.stackTrace).to.equal('all'); + }); + + it('should accept zero as valid stack trace length', () => { + const config = coreConfig.normalize({ tracing: { stackTraceLength: 0 } }); + expect(config.tracing.stackTraceLength).to.equal(0); + }); + + it('should handle negative stack trace length', () => { + const config = coreConfig.normalize({ tracing: { stackTraceLength: -10 } }); + expect(config.tracing.stackTraceLength).to.equal(10); + }); + + it('should handle very large negative stack trace length', () => { + const config = coreConfig.normalize({ tracing: { stackTraceLength: -100 } }); + expect(config.tracing.stackTraceLength).to.equal(100); + }); + + it('should handle stack trace length as positive float', () => { + const config = coreConfig.normalize({ tracing: { stackTraceLength: 15.9 } }); + expect(config.tracing.stackTraceLength).to.equal(16); + }); + + it('should handle stack trace length as negative float', () => { + const config = coreConfig.normalize({ tracing: { stackTraceLength: -15.9 } }); + expect(config.tracing.stackTraceLength).to.equal(16); + }); + + it('should handle stack trace length as string with leading zeros', () => { + const config = coreConfig.normalize({ tracing: { stackTraceLength: '007' } }); + expect(config.tracing.stackTraceLength).to.equal(7); + }); + + it('should handle stack trace length as string with whitespace', () => { + const config = coreConfig.normalize({ tracing: { stackTraceLength: ' 25 ' } }); + expect(config.tracing.stackTraceLength).to.equal(25); + }); + + it('should handle stack trace length as string with plus sign', () => { + const config = coreConfig.normalize({ tracing: { stackTraceLength: '+30' } }); + expect(config.tracing.stackTraceLength).to.equal(30); + }); + + it('should reject stack trace length as null', () => { + const config = coreConfig.normalize({ tracing: { stackTraceLength: null } }); + expect(config.tracing.stackTraceLength).to.equal(10); + }); + + it('should reject stack trace length as undefined', () => { + const config = coreConfig.normalize({ tracing: { stackTraceLength: undefined } }); + expect(config.tracing.stackTraceLength).to.equal(10); + }); + + it('should reject stack trace length as empty string', () => { + const config = coreConfig.normalize({ tracing: { stackTraceLength: '' } }); + expect(config.tracing.stackTraceLength).to.equal(10); + }); + + it('should reject stack trace length as object', () => { + const config = coreConfig.normalize({ tracing: { stackTraceLength: {} } }); + expect(config.tracing.stackTraceLength).to.equal(10); + }); + + it('should reject stack trace length as array', () => { + const config = coreConfig.normalize({ tracing: { stackTraceLength: [10] } }); + expect(config.tracing.stackTraceLength).to.equal(10); + }); + + it('should handle stack trace length from INSTANA_STACK_TRACE_LENGTH as zero', () => { + process.env.INSTANA_STACK_TRACE_LENGTH = '0'; + const config = coreConfig.normalize(); + expect(config.tracing.stackTraceLength).to.equal(0); + }); + + it('should handle stack trace length from INSTANA_STACK_TRACE_LENGTH with negative value', () => { + process.env.INSTANA_STACK_TRACE_LENGTH = '-20'; + const config = coreConfig.normalize(); + expect(config.tracing.stackTraceLength).to.equal(20); + }); + + it('should handle stack trace length from INSTANA_STACK_TRACE_LENGTH exceeding max', () => { + process.env.INSTANA_STACK_TRACE_LENGTH = '1000'; + const config = coreConfig.normalize(); + expect(config.tracing.stackTraceLength).to.equal(500); + }); + + it('should handle stack trace length from INSTANA_STACK_TRACE_LENGTH as float', () => { + process.env.INSTANA_STACK_TRACE_LENGTH = '12.3'; + const config = coreConfig.normalize(); + expect(config.tracing.stackTraceLength).to.equal(12); + }); + + it('should reject invalid INSTANA_STACK_TRACE_LENGTH', () => { + process.env.INSTANA_STACK_TRACE_LENGTH = 'not-a-number'; + const config = coreConfig.normalize(); + expect(config.tracing.stackTraceLength).to.equal(10); + }); + + it('should reject empty INSTANA_STACK_TRACE_LENGTH', () => { + process.env.INSTANA_STACK_TRACE_LENGTH = ''; + const config = coreConfig.normalize(); + expect(config.tracing.stackTraceLength).to.equal(10); + }); + + it('should reject INSTANA_STACK_TRACE_LENGTH with only whitespace', () => { + process.env.INSTANA_STACK_TRACE_LENGTH = ' '; + const config = coreConfig.normalize(); + expect(config.tracing.stackTraceLength).to.equal(10); + }); + + it('should handle INSTANA_STACK_TRACE_LENGTH with mixed valid and invalid characters', () => { + process.env.INSTANA_STACK_TRACE_LENGTH = '15abc'; + const config = coreConfig.normalize(); + expect(config.tracing.stackTraceLength).to.equal(15); + }); + + it('should handle both INSTANA_STACK_TRACE and INSTANA_STACK_TRACE_LENGTH together', () => { + process.env.INSTANA_STACK_TRACE = 'error'; + process.env.INSTANA_STACK_TRACE_LENGTH = '25'; + const config = coreConfig.normalize(); + expect(config.tracing.stackTrace).to.equal('error'); + expect(config.tracing.stackTraceLength).to.equal(25); + }); + + it('should handle config with both stackTrace and stackTraceLength', () => { + const config = coreConfig.normalize({ + tracing: { + global: { + stackTrace: 'none', + stackTraceLength: 30 + } + } + }); + expect(config.tracing.stackTrace).to.equal('none'); + expect(config.tracing.stackTraceLength).to.equal(30); + }); + + it('should give precedence to env vars for both stack trace settings over config', () => { + process.env.INSTANA_STACK_TRACE = 'error'; + process.env.INSTANA_STACK_TRACE_LENGTH = '15'; + const config = coreConfig.normalize({ + tracing: { + global: { + stackTrace: 'all', + stackTraceLength: 40 + } + } + }); + expect(config.tracing.stackTrace).to.equal('error'); + expect(config.tracing.stackTraceLength).to.equal(15); + }); + + it('should use INSTANA_STACK_TRACE_LENGTH when STACK_TRACE_LENGTH is not set', () => { + process.env.INSTANA_STACK_TRACE_LENGTH = '18'; + const config = coreConfig.normalize(); + expect(config.tracing.stackTraceLength).to.equal(18); + delete process.env.INSTANA_STACK_TRACE_LENGTH; + }); + it('should not disable individual instrumentations by default', () => { const config = coreConfig.normalize(); expect(config.tracing.disable).to.deep.equal({}); @@ -792,6 +1040,7 @@ 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.stackTrace).to.equal('all'); expect(config.tracing.stackTraceLength).to.equal(10); expect(config.tracing.spanBatchingEnabled).to.be.false; expect(config.tracing.disableW3cTraceCorrelation).to.be.false; diff --git a/packages/core/test/tracing/tracingUtil_test.js b/packages/core/test/tracing/tracingUtil_test.js index bd0df371b2..0edbaf6ade 100644 --- a/packages/core/test/tracing/tracingUtil_test.js +++ b/packages/core/test/tracing/tracingUtil_test.js @@ -12,20 +12,23 @@ const fail = require('assert').fail; const util = require('util'); const config = require('../config'); -const { isCI } = require('../test_util'); +const { isCI, createFakeLogger } = require('../test_util'); const { + findCallback, generateRandomId, generateRandomSpanId, generateRandomTraceId, + getStackTrace, readTraceContextFromBuffer, sanitizeConnectionStr, + setErrorDetails, unsignedHexStringToBuffer, - unsignedHexStringsToBuffer, - findCallback, - setErrorDetails + unsignedHexStringsToBuffer } = require('../../src/tracing/tracingUtil'); +const tracingUtil = require('../../src/tracing/tracingUtil'); + describe('tracing/tracingUtil', () => { describe('generate random IDs', function () { this.timeout(config.getTestTimeout() * 10); @@ -427,6 +430,15 @@ describe('tracing/tracingUtil', () => { }); describe('setErrorDetails', () => { + before(() => { + tracingUtil.init({ + logger: createFakeLogger(), + tracing: { + stackTraceLength: 10 + } + }); + }); + it('should handle Error objects with message and stack', () => { const span = { data: { @@ -528,6 +540,25 @@ describe('tracing/tracingUtil', () => { expect(span.stack[0]).to.have.property('n', 10); }); + it('should respect default stackTraceLength limit', () => { + const testSpan = { + data: { + nats: {} + } + }; + const testError = new Error('Test'); + testError.stack = `Error: Test\n${'at someFunction (file.js:10:5)\n'.repeat(20)}`; + setErrorDetails(testSpan, testError, 'nats'); + + expect(testSpan.stack).to.be.an('array'); + + // Should be limited to the configured stackTraceLength (default 10) + expect(testSpan.stack.length).to.equal(10); + expect(testSpan.stack[0]).to.have.property('m', 'someFunction'); + expect(testSpan.stack[0]).to.have.property('c', 'file.js'); + expect(testSpan.stack[0]).to.have.property('n', 10); + }); + it('should handle error objects with details property (gRPC errors)', () => { const span = { data: { @@ -681,6 +712,118 @@ describe('tracing/tracingUtil', () => { }); }); + describe('getStackTrace', () => { + before(() => { + tracingUtil.init({ + logger: createFakeLogger(), + tracing: { + stackTraceLength: 10 + } + }); + }); + + // Helper functions to create a deep call stack for testing + function deepFunction11() { + return getStackTrace(deepFunction11); + } + function deepFunction10() { + return deepFunction11(); + } + function deepFunction9() { + return deepFunction10(); + } + function deepFunction8() { + return deepFunction9(); + } + function deepFunction7() { + return deepFunction8(); + } + function deepFunction6() { + return deepFunction7(); + } + function deepFunction5() { + return deepFunction6(); + } + function deepFunction4() { + return deepFunction5(); + } + function deepFunction3() { + return deepFunction4(); + } + function deepFunction2() { + return deepFunction3(); + } + function deepFunction1() { + return deepFunction2(); + } + + it('should use configured stackTraceLength by default', () => { + const stack = deepFunction1(); + + expect(stack).to.be.an('array'); + expect(stack.length).to.be.at.most(10); + expect(stack.length).to.be.greaterThan(0); + }); + + it('should use configured stackTraceLength (cannot override with parameter)', () => { + function testFunc() { + return getStackTrace(testFunc, 0); + } + function caller1() { + return testFunc(); + } + function caller2() { + return caller1(); + } + function caller3() { + return caller2(); + } + const stack = caller3(); + + expect(stack).to.be.an('array'); + expect(stack.length).to.be.at.most(10); + expect(stack.length).to.be.greaterThan(0); + }); + + it('should handle drop parameter correctly', () => { + function testFunc(drop) { + return getStackTrace(testFunc, drop); + } + function caller1(drop) { + return testFunc(drop); + } + function caller2(drop) { + return caller1(drop); + } + function caller3(drop) { + return caller2(drop); + } + + const stackWithoutDrop = caller3(0); + const stackWithDrop = caller3(5); + + expect(stackWithoutDrop).to.be.an('array'); + expect(stackWithDrop).to.be.an('array'); + expect(stackWithDrop.length).to.be.lessThan(stackWithoutDrop.length); + }); + + it('should use drop parameter with configured length', () => { + function testFunc() { + return getStackTrace(testFunc, 1); + } + function caller1() { + return testFunc(); + } + function caller2() { + return caller1(); + } + const stack = caller2(); + + expect(stack).to.be.an('array'); + expect(stack.length).to.be.at.most(10); + }); + }); + describe('generateRandomLongTraceId', () => { const { generateRandomLongTraceId } = require('../../src/tracing/tracingUtil'); From 488724131565cc02902c5bce9ea45da94c6d6d04 Mon Sep 17 00:00:00 2001 From: Abhilash <70062455+abhilash-sivan@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:37:40 +0530 Subject: [PATCH 10/11] feat: added stack trace mode filtering (#2237) --- packages/collector/test/apps/express.js | 4 + .../test/tracing/misc/stack_trace/test.js | 272 +++++++++++ .../protocols/http/proxy/expressProxy.js | 29 ++ packages/core/src/tracing/tracingUtil.js | 11 +- packages/core/src/util/constants.js | 5 + .../core/test/tracing/tracingUtil_test.js | 430 +++++++++++++++--- 6 files changed, 687 insertions(+), 64 deletions(-) diff --git a/packages/collector/test/apps/express.js b/packages/collector/test/apps/express.js index aa4ccd7916..6a061c796a 100644 --- a/packages/collector/test/apps/express.js +++ b/packages/collector/test/apps/express.js @@ -112,6 +112,10 @@ router.get('/subPath', (req, res) => { }); app.use('/routed', router); +app.get('/return-error', (req, res) => { + res.status(500).send({ error: 'Internal Server Error' }); +}); + app.use((req, res) => { log(req.method, req.url); const delay = parseInt(req.query.delay || 0, 10); diff --git a/packages/collector/test/tracing/misc/stack_trace/test.js b/packages/collector/test/tracing/misc/stack_trace/test.js index d099ba363b..8074535122 100644 --- a/packages/collector/test/tracing/misc/stack_trace/test.js +++ b/packages/collector/test/tracing/misc/stack_trace/test.js @@ -407,5 +407,277 @@ const mochaSuiteFn = supportedVersion(process.versions.node) ? describe : descri )); }); }); + + describe('stackTraceMode configuration', () => { + const { AgentStubControls } = require('../../../apps/agentStubControls'); + const agentStubControls = new AgentStubControls(); + + describe('with stackTraceMode = "none"', () => { + before(async () => { + await agentStubControls.startAgent({ + stackTraceConfig: { + 'stack-trace': 'none', + 'stack-trace-length': 10 + } + }); + await expressControls.start({ + agentControls: agentStubControls, + EXPRESS_VERSION: version + }); + await expressProxyControls.start({ + agentControls: agentStubControls, + expressControls, + EXPRESS_VERSION: version + }); + }); + + after(async () => { + await expressControls.stop(); + await expressProxyControls.stop(); + await agentStubControls.stopAgent(); + }); + + beforeEach(async () => { + await agentStubControls.clearReceivedTraceData(); + }); + + beforeEach(() => agentStubControls.waitUntilAppIsCompletelyInitialized(expressControls.getPid())); + beforeEach(() => agentStubControls.waitUntilAppIsCompletelyInitialized(expressProxyControls.getPid())); + + it('should not generate stack traces for exit spans when mode is "none"', () => + expressProxyControls + .sendRequest({ + method: 'POST', + path: '/checkout', + responseStatus: 201 + }) + .then(() => + testUtils.retry(() => + agentStubControls.getSpans().then(spans => { + expect(spans.length).to.be.at.least(2); + + const httpClientSpan = testUtils.expectAtLeastOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.client'), + span => expect(span.k).to.equal(constants.EXIT) + ]); + + expect(httpClientSpan.stack).to.be.an('array'); + expect(httpClientSpan.stack.length).to.equal(0); + }) + ) + )); + + it('should not generate stack traces even when error occurs with mode "none"', () => + expressProxyControls + .sendRequest({ + method: 'GET', + path: '/trigger-error' + }) + .then(() => + testUtils.retry(() => + agentStubControls.getSpans().then(spans => { + const httpClientSpan = testUtils.expectAtLeastOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.client'), + span => expect(span.k).to.equal(constants.EXIT), + span => expect(span.ec).to.equal(1) + ]); + + expect(httpClientSpan.data.http.error).to.exist; + expect(httpClientSpan.stack).to.be.an('array'); + expect(httpClientSpan.stack.length).to.equal(0); + }) + ) + )); + }); + + describe('with stackTraceMode = "error"', () => { + before(async () => { + await agentStubControls.startAgent({ + stackTraceConfig: { + 'stack-trace': 'error', + 'stack-trace-length': 10 + } + }); + await expressControls.start({ + agentControls: agentStubControls, + EXPRESS_VERSION: version + }); + await expressProxyControls.start({ + agentControls: agentStubControls, + expressControls, + EXPRESS_VERSION: version + }); + }); + + after(async () => { + await expressControls.stop(); + await expressProxyControls.stop(); + await agentStubControls.stopAgent(); + }); + + beforeEach(async () => { + await agentStubControls.clearReceivedTraceData(); + }); + + beforeEach(() => agentStubControls.waitUntilAppIsCompletelyInitialized(expressControls.getPid())); + beforeEach(() => agentStubControls.waitUntilAppIsCompletelyInitialized(expressProxyControls.getPid())); + + it('should not generate stack traces for successful exit spans when mode is "error"', () => + expressProxyControls + .sendRequest({ + method: 'POST', + path: '/checkout', + responseStatus: 201 + }) + .then(() => + testUtils.retry(() => + agentStubControls.getSpans().then(spans => { + expect(spans.length).to.be.at.least(2); + + const httpClientSpan = testUtils.expectAtLeastOneMatching(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(201) + ]); + + expect(httpClientSpan.stack).to.be.an('array'); + expect(httpClientSpan.stack.length).to.equal(0); + }) + ) + )); + + it('should generate stack traces only when error occurs with mode "error"', () => + expressProxyControls + .sendRequest({ + method: 'GET', + path: '/trigger-error' + }) + .then(() => + testUtils.retry(() => + agentStubControls.getSpans().then(spans => { + const httpClientSpan = testUtils.expectAtLeastOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.client'), + span => expect(span.k).to.equal(constants.EXIT), + span => expect(span.ec).to.equal(1) + ]); + + expect(httpClientSpan.data.http.error).to.exist; + expect(httpClientSpan.stack).to.be.an('array'); + expect(httpClientSpan.stack.length).to.be.greaterThan(0); + expect(httpClientSpan.stack[0]).to.have.property('m'); + expect(httpClientSpan.stack[0]).to.have.property('c'); + expect(httpClientSpan.stack[0]).to.have.property('n'); + }) + ) + )); + // This test ensures that when stackTraceMode = 'error', we neither generate nor overwrite stack traces. + // It assumes the error object already contains a stack (common case). + // Some edge cases (e.g., gRPC or NATS) may not provide one and can be handled later if needed. + it('should have empty stack when error occurs without error.stack property with mode "error"', () => + expressProxyControls + .sendRequest({ + method: 'GET', + path: '/trigger-error-no-stack' + }) + .then(() => + testUtils.retry(() => + agentStubControls.getSpans().then(spans => { + const httpClientSpan = testUtils.expectAtLeastOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.client'), + span => expect(span.k).to.equal(constants.EXIT) + ]); + + expect(httpClientSpan.stack).to.be.an('array'); + expect(httpClientSpan.stack.length).to.equal(0); + }) + ) + )); + }); + + describe('with stackTraceMode = "all"', () => { + before(async () => { + await agentStubControls.startAgent({ + stackTraceConfig: { + 'stack-trace': 'all', + 'stack-trace-length': 10 + } + }); + await expressControls.start({ + agentControls: agentStubControls, + EXPRESS_VERSION: version + }); + await expressProxyControls.start({ + agentControls: agentStubControls, + expressControls, + EXPRESS_VERSION: version + }); + }); + + after(async () => { + await expressControls.stop(); + await expressProxyControls.stop(); + await agentStubControls.stopAgent(); + }); + + beforeEach(async () => { + await agentStubControls.clearReceivedTraceData(); + }); + + beforeEach(() => agentStubControls.waitUntilAppIsCompletelyInitialized(expressControls.getPid())); + beforeEach(() => agentStubControls.waitUntilAppIsCompletelyInitialized(expressProxyControls.getPid())); + + it('should generate stack traces for all exit spans when mode is "all"', () => + expressProxyControls + .sendRequest({ + method: 'POST', + path: '/checkout', + responseStatus: 201 + }) + .then(() => + testUtils.retry(() => + agentStubControls.getSpans().then(spans => { + expect(spans.length).to.be.at.least(2); + + const httpClientSpan = testUtils.expectAtLeastOneMatching(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(201) + ]); + + expect(httpClientSpan.stack).to.be.an('array'); + expect(httpClientSpan.stack.length).to.be.greaterThan(0); + expect(httpClientSpan.stack[0]).to.have.property('m'); + expect(httpClientSpan.stack[0]).to.have.property('c'); + expect(httpClientSpan.stack[0]).to.have.property('n'); + }) + ) + )); + + it('should generate stack traces when error occurs with mode "all"', () => + expressProxyControls + .sendRequest({ + method: 'GET', + path: '/trigger-error' + }) + .then(() => + testUtils.retry(() => + agentStubControls.getSpans().then(spans => { + const httpClientSpan = testUtils.expectAtLeastOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.client'), + span => expect(span.k).to.equal(constants.EXIT), + span => expect(span.ec).to.equal(1) + ]); + + expect(httpClientSpan.data.http.error).to.exist; + expect(httpClientSpan.stack).to.be.an('array'); + expect(httpClientSpan.stack.length).to.be.greaterThan(0); + expect(httpClientSpan.stack[0]).to.have.property('m'); + expect(httpClientSpan.stack[0]).to.have.property('c'); + expect(httpClientSpan.stack[0]).to.have.property('n'); + }) + ) + )); + }); + }); }); }); diff --git a/packages/collector/test/tracing/protocols/http/proxy/expressProxy.js b/packages/collector/test/tracing/protocols/http/proxy/expressProxy.js index 93c0349fd4..a93b8ac4a8 100644 --- a/packages/collector/test/tracing/protocols/http/proxy/expressProxy.js +++ b/packages/collector/test/tracing/protocols/http/proxy/expressProxy.js @@ -30,6 +30,7 @@ const instanaConfig = { require('../../../../..')(instanaConfig); const express = require('express'); const fetch = require('node-fetch-v2'); +const http = require('http'); const port = require('../../../../test_util/app-port')(); const app = express(); @@ -53,6 +54,34 @@ app.get('/trigger-error', (req, res) => { }); }); +// eslint-disable-next-line no-unused-vars +app.get('/trigger-error-no-stack', (req, res) => { + const options = { + hostname: 'localhost', + port: process.env.UPSTREAM_PORT, + path: '/return-error', + method: 'GET' + }; + + const clientRequest = http.request(options, response => { + response.on('data', () => {}); + response.on('end', () => {}); + }); + + setImmediate(() => { + const errorWithoutStack = { + message: 'Error without stack', + code: 'NO_STACK_ERROR', + name: 'CustomError' + }; + + clientRequest.emit('error', errorWithoutStack); + res.status(500).send(errorWithoutStack); + }); + + clientRequest.end(); +}); + app.use((req, res) => { log(req.method, req.url); const delay = parseInt(req.query.delay || 0, 10); diff --git a/packages/core/src/tracing/tracingUtil.js b/packages/core/src/tracing/tracingUtil.js index ffc25cdebd..17d1bf22f6 100644 --- a/packages/core/src/tracing/tracingUtil.js +++ b/packages/core/src/tracing/tracingUtil.js @@ -10,7 +10,7 @@ const path = require('path'); const StringDecoder = require('string_decoder').StringDecoder; const stackTrace = require('../util/stackTrace'); -const { DEFAULT_STACK_TRACE_LENGTH, DEFAULT_STACK_TRACE_MODE } = require('../util/constants'); +const { DEFAULT_STACK_TRACE_LENGTH, DEFAULT_STACK_TRACE_MODE, STACK_TRACE_MODES } = require('../util/constants'); /** @type {import('../core').GenericLogger} */ let logger; @@ -62,6 +62,9 @@ exports.activate = function activate(extraConfig) { * @returns {Array.<*>} */ exports.getStackTrace = function getStackTrace(referenceFunction, drop) { + if (stackTraceMode === STACK_TRACE_MODES.NONE || stackTraceMode === STACK_TRACE_MODES.ERROR) { + return []; + } return stackTrace.captureStackTrace(stackTraceLength, referenceFunction, drop); }; @@ -369,6 +372,12 @@ exports.setErrorDetails = function setErrorDetails(span, error, technology) { } } + // If the mode is none, we set span.data[technology].error (as done above) and return immediately + if (stackTraceMode === STACK_TRACE_MODES.NONE) { + return; + } + + // If the mode is error or all and an error occurred, generate and overwrite the stack trace with the error if (normalizedError.stack) { const stackArray = stackTrace.parseStackTraceFromString(normalizedError.stack); span.stack = stackArray.length > 0 ? stackArray.slice(0, stackTraceLength) : span.stack || []; diff --git a/packages/core/src/util/constants.js b/packages/core/src/util/constants.js index b07976d933..471d5340f2 100644 --- a/packages/core/src/util/constants.js +++ b/packages/core/src/util/constants.js @@ -12,3 +12,8 @@ exports.validStackTraceModes = ['error', 'all', 'none']; exports.MAX_STACK_TRACE_LENGTH = 500; exports.DEFAULT_STACK_TRACE_LENGTH = 10; exports.DEFAULT_STACK_TRACE_MODE = 'all'; +exports.STACK_TRACE_MODES = { + ERROR: 'error', + ALL: 'all', + NONE: 'none' +}; diff --git a/packages/core/test/tracing/tracingUtil_test.js b/packages/core/test/tracing/tracingUtil_test.js index 0edbaf6ade..f2256e23a8 100644 --- a/packages/core/test/tracing/tracingUtil_test.js +++ b/packages/core/test/tracing/tracingUtil_test.js @@ -710,19 +710,273 @@ describe('tracing/tracingUtil', () => { expect(span.stack).to.deep.equal([]); }); - }); - describe('getStackTrace', () => { - before(() => { - tracingUtil.init({ - logger: createFakeLogger(), - tracing: { - stackTraceLength: 10 - } + describe('setErrorDetails with stackTraceMode filtering', () => { + describe('with stackTraceMode = "none"', () => { + before(() => { + tracingUtil.init({ + logger: createFakeLogger(), + tracing: { + stackTraceLength: 10, + stackTrace: 'none' + } + }); + }); + + it('should set error message but not generate stack trace when mode is "none"', () => { + const span = { + data: { + http: {} + }, + stack: [] + }; + const error = new Error('Test error'); + setErrorDetails(span, error, 'http'); + + expect(span.data.http.error).to.match(/Error: Test error/); + expect(span.stack).to.be.an('array'); + expect(span.stack.length).to.equal(0); + }); + + it('should not overwrite existing stack when mode is "none"', () => { + const existingStack = [{ m: 'existing', c: 'file.js', n: 1 }]; + const span = { + data: { test: {} }, + stack: existingStack + }; + const error = new Error('New error'); + setErrorDetails(span, error, 'test'); + + expect(span.data.test.error).to.match(/Error: New error/); + expect(span.stack).to.equal(existingStack); + }); + }); + + describe('with stackTraceMode = "error"', () => { + before(() => { + tracingUtil.init({ + logger: createFakeLogger(), + tracing: { + stackTraceLength: 10, + stackTrace: 'error' + } + }); + }); + + it('should generate stack trace from error when mode is "error"', () => { + const span = { + data: { + mysql: {} + } + }; + const error = new Error('Database error'); + setErrorDetails(span, error, 'mysql'); + + expect(span.data.mysql.error).to.match(/Error: Database error/); + expect(span.stack).to.be.an('array'); + expect(span.stack.length).to.be.greaterThan(0); + expect(span.stack[0]).to.have.property('m'); + expect(span.stack[0]).to.have.property('c'); + expect(span.stack[0]).to.have.property('n'); + }); + + it('should respect stackTraceLength when mode is "error"', () => { + const span = { + data: { + test: {} + } + }; + const error = new Error('Test'); + error.stack = `Error: Test\n${'at someFunction (file.js:10:5)\n'.repeat(20)}`; + setErrorDetails(span, error, 'test'); + + expect(span.stack).to.be.an('array'); + expect(span.stack.length).to.equal(10); + }); + + it('should set empty stack array when error has no stack property', () => { + const span = { + data: { + test: {} + } + }; + const error = { message: 'No stack error' }; + setErrorDetails(span, error, 'test'); + + expect(span.data.test.error).to.match(/Error: No stack error/); + expect(span.stack).to.be.an('array'); + expect(span.stack.length).to.equal(0); + }); + }); + + describe('with stackTraceMode = "all"', () => { + before(() => { + tracingUtil.init({ + logger: createFakeLogger(), + tracing: { + stackTraceLength: 10, + stackTrace: 'all' + } + }); + }); + + it('should generate stack trace from error when mode is "all"', () => { + const span = { + data: { + nats: {} + } + }; + const error = new Error('NATS error'); + setErrorDetails(span, error, 'nats'); + + expect(span.data.nats.error).to.match(/Error: NATS error/); + expect(span.stack).to.be.an('array'); + expect(span.stack.length).to.be.greaterThan(0); + }); + + it('should handle SDK spans with nested paths when mode is "all"', () => { + const span = { + data: {} + }; + const error = new Error('SDK error'); + setErrorDetails(span, error, 'sdk.custom.tags.message'); + + expect(span.data.sdk.custom.tags.message).to.match(/Error: SDK error/); + expect(span.stack).to.be.an('array'); + expect(span.stack.length).to.be.greaterThan(0); + }); + }); + + it('should handle error with name property but no message', () => { + const span = { + data: { + test: {} + } + }; + const error = { name: 'CustomError', stack: 'test stack' }; + setErrorDetails(span, error, 'test'); + + expect(span.data.test.error).to.equal('No error message found.'); + }); + + it('should handle error with both name and details properties', () => { + const span = { + data: { + grpc: {} + } + }; + const error = { name: 'GrpcError', details: 'Connection timeout', stack: 'test' }; + setErrorDetails(span, error, 'grpc'); + + expect(span.data.grpc.error).to.equal('GrpcError: Connection timeout'); + }); + + it('should handle nested path with single element array', () => { + const span = { + data: {} + }; + const error = 'Single level error'; + setErrorDetails(span, error, ['error']); + + expect(span.data.error).to.equal('Error: Single level error'); + }); + + it('should handle flat technology key without dot notation', () => { + const span = { + data: { + redis: {} + } + }; + const error = { message: 'Redis connection failed', stack: 'test' }; + setErrorDetails(span, error, 'redis'); + + expect(span.data.redis.error).to.equal('Error: Redis connection failed'); + }); + + it('should handle error object with only code property and no stack', () => { + const span = { + data: { + net: {} + } + }; + const error = { code: 'ETIMEDOUT' }; + setErrorDetails(span, error, 'net'); + + expect(span.data.net.error).to.equal('ETIMEDOUT'); + expect(span.stack).to.deep.equal([]); + }); + + it('should handle deeply nested path with more than 4 levels', () => { + const span = { + data: {} + }; + const error = 'Deep nested error'; + setErrorDetails(span, error, ['level1', 'level2', 'level3', 'level4', 'level5', 'error']); + + expect(span.data.level1.level2.level3.level4.level5.error).to.equal('Error: Deep nested error'); + }); + + it('should handle error with empty string message', () => { + const span = { + data: { + test: {} + } + }; + const error = { message: '', stack: 'test' }; + setErrorDetails(span, error, 'test'); + + expect(span.data.test.error).to.equal('No error message found.'); + }); + + it('should not create error property when technology path does not exist in span.data', () => { + const span = { + data: {} + }; + const error = 'Test error'; + setErrorDetails(span, error, 'nonexistent'); + + expect(span.data.nonexistent).to.be.undefined; + }); + + it('should handle mixed dot notation with existing partial structure', () => { + const span = { + data: { + sdk: { + custom: {} + } + } + }; + const error = 'Partial structure error'; + setErrorDetails(span, error, 'sdk.custom.tags.message'); + + expect(span.data.sdk.custom.tags.message).to.equal('Error: Partial structure error'); + }); + + it('should handle error with stack property that parses to empty array', () => { + const span = { + data: { + test: {} + } + }; + const error = { message: 'test', stack: 'Invalid stack format without proper lines' }; + setErrorDetails(span, error, 'test'); + + expect(span.data.test.error).to.equal('Error: test'); + expect(span.stack).to.be.an('array'); + }); + + it('should handle getStackTrace without reference function', () => { + const stack = getStackTrace(); + + expect(stack).to.be.an('array'); + expect(stack.length).to.be.greaterThan(0); + expect(stack.length).to.be.at.most(10); }); }); + }); - // Helper functions to create a deep call stack for testing + describe('getStackTrace', () => { + // helper function for deep stackTrace function deepFunction11() { return getStackTrace(deepFunction11); } @@ -757,70 +1011,120 @@ describe('tracing/tracingUtil', () => { return deepFunction2(); } - it('should use configured stackTraceLength by default', () => { - const stack = deepFunction1(); + describe('with stackTraceMode = "all"', () => { + before(() => { + tracingUtil.init({ + logger: createFakeLogger(), + tracing: { + stackTraceLength: 10, + stackTrace: 'all' + } + }); + }); - expect(stack).to.be.an('array'); - expect(stack.length).to.be.at.most(10); - expect(stack.length).to.be.greaterThan(0); - }); + it('should generate stack traces when mode is "all"', () => { + const stack = deepFunction1(); - it('should use configured stackTraceLength (cannot override with parameter)', () => { - function testFunc() { - return getStackTrace(testFunc, 0); - } - function caller1() { - return testFunc(); - } - function caller2() { - return caller1(); - } - function caller3() { - return caller2(); - } - const stack = caller3(); + expect(stack).to.be.an('array'); + expect(stack.length).to.be.greaterThan(0); + expect(stack.length).to.be.at.most(10); + }); + + it('should use configured stackTraceLength', () => { + const stack = deepFunction1(); - expect(stack).to.be.an('array'); - expect(stack.length).to.be.at.most(10); - expect(stack.length).to.be.greaterThan(0); + expect(stack).to.be.an('array'); + expect(stack.length).to.be.at.most(10); + expect(stack.length).to.be.greaterThan(0); + }); + + it('should handle drop parameter correctly', () => { + function testFunc(drop) { + return getStackTrace(testFunc, drop); + } + function caller1(drop) { + return testFunc(drop); + } + function caller2(drop) { + return caller1(drop); + } + function caller3(drop) { + return caller2(drop); + } + + const stackWithoutDrop = caller3(0); + const stackWithDrop = caller3(5); + + expect(stackWithoutDrop).to.be.an('array'); + expect(stackWithDrop).to.be.an('array'); + expect(stackWithDrop.length).to.be.lessThan(stackWithoutDrop.length); + }); }); - it('should handle drop parameter correctly', () => { - function testFunc(drop) { - return getStackTrace(testFunc, drop); - } - function caller1(drop) { - return testFunc(drop); - } - function caller2(drop) { - return caller1(drop); - } - function caller3(drop) { - return caller2(drop); - } + describe('with stackTraceMode = "none"', () => { + before(() => { + tracingUtil.init({ + logger: createFakeLogger(), + tracing: { + stackTraceLength: 10, + stackTrace: 'none' + } + }); + }); - const stackWithoutDrop = caller3(0); - const stackWithDrop = caller3(5); + it('should not generate stack traces when mode is "none"', () => { + function testFunc() { + return getStackTrace(testFunc); + } + function caller1() { + return testFunc(); + } + function caller2() { + return caller1(); + } - expect(stackWithoutDrop).to.be.an('array'); - expect(stackWithDrop).to.be.an('array'); - expect(stackWithDrop.length).to.be.lessThan(stackWithoutDrop.length); + const stack = caller2(); + + expect(stack).to.be.an('array'); + expect(stack.length).to.equal(0); + }); + + it('should return empty array regardless of drop parameter', () => { + function testFunc(drop) { + return getStackTrace(testFunc, drop); + } + + const stack = testFunc(0); + + expect(stack).to.be.an('array'); + expect(stack.length).to.equal(0); + }); }); - it('should use drop parameter with configured length', () => { - function testFunc() { - return getStackTrace(testFunc, 1); - } - function caller1() { - return testFunc(); - } - function caller2() { - return caller1(); - } - const stack = caller2(); + describe('with stackTraceMode = "error"', () => { + before(() => { + tracingUtil.init({ + logger: createFakeLogger(), + tracing: { + stackTraceLength: 10, + stackTrace: 'error' + } + }); + }); + + it('should not generate stack traces when mode is "error"', () => { + function testFunc() { + return getStackTrace(testFunc); + } + function caller1() { + return testFunc(); + } - expect(stack).to.be.an('array'); - expect(stack.length).to.be.at.most(10); + const stack = caller1(); + + expect(stack).to.be.an('array'); + expect(stack.length).to.equal(0); + }); }); }); From f17879c9da3597aef1993731cd3551b20c60bd31 Mon Sep 17 00:00:00 2001 From: Abhilash Date: Mon, 5 Jan 2026 11:35:43 +0530 Subject: [PATCH 11/11] test: added test for stacktrace validator --- .../stackTraceValidation_test.js | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 packages/core/test/config/configValidators/stackTraceValidation_test.js diff --git a/packages/core/test/config/configValidators/stackTraceValidation_test.js b/packages/core/test/config/configValidators/stackTraceValidation_test.js new file mode 100644 index 0000000000..a10141d7c3 --- /dev/null +++ b/packages/core/test/config/configValidators/stackTraceValidation_test.js @@ -0,0 +1,255 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const { expect } = require('chai'); + +const stackTraceValidation = require('../../../src/config/configValidators/stackTraceValidation'); + +describe('config.configValidators.stackTraceValidation', () => { + describe('validateStackTraceMode', () => { + it('should validate "all" as valid', () => { + const result = stackTraceValidation.validateStackTraceMode('all'); + expect(result.isValid).to.be.true; + expect(result.error).to.be.null; + }); + + it('should validate "error" as valid', () => { + const result = stackTraceValidation.validateStackTraceMode('error'); + expect(result.isValid).to.be.true; + expect(result.error).to.be.null; + }); + + it('should validate "none" as valid', () => { + const result = stackTraceValidation.validateStackTraceMode('none'); + expect(result.isValid).to.be.true; + expect(result.error).to.be.null; + }); + + it('should validate uppercase "ALL" as valid', () => { + const result = stackTraceValidation.validateStackTraceMode('ALL'); + expect(result.isValid).to.be.true; + expect(result.error).to.be.null; + }); + + it('should validate mixed case "ErRoR" as valid', () => { + const result = stackTraceValidation.validateStackTraceMode('ErRoR'); + expect(result.isValid).to.be.true; + expect(result.error).to.be.null; + }); + + it('should validate mixed case "NoNe" as valid', () => { + const result = stackTraceValidation.validateStackTraceMode('NoNe'); + expect(result.isValid).to.be.true; + expect(result.error).to.be.null; + }); + + it('should reject null value', () => { + const result = stackTraceValidation.validateStackTraceMode(null); + expect(result.isValid).to.be.false; + expect(result.error).to.include('cannot be null'); + }); + + it('should reject invalid string value', () => { + const result = stackTraceValidation.validateStackTraceMode('invalid'); + expect(result.isValid).to.be.false; + expect(result.error).to.include('Invalid value: "invalid"'); + }); + + it('should reject number type', () => { + const result = stackTraceValidation.validateStackTraceMode(123); + expect(result.isValid).to.be.false; + expect(result.error).to.include('non-supported type number'); + }); + + it('should reject boolean type', () => { + const result = stackTraceValidation.validateStackTraceMode(true); + expect(result.isValid).to.be.false; + expect(result.error).to.include('non-supported type boolean'); + }); + + it('should reject object type', () => { + const result = stackTraceValidation.validateStackTraceMode({}); + expect(result.isValid).to.be.false; + expect(result.error).to.include('non-supported type object'); + }); + + it('should reject array type', () => { + const result = stackTraceValidation.validateStackTraceMode(['error']); + expect(result.isValid).to.be.false; + expect(result.error).to.include('non-supported type object'); + }); + + it('should reject undefined value', () => { + const result = stackTraceValidation.validateStackTraceMode(undefined); + expect(result.isValid).to.be.false; + expect(result.error).to.include('non-supported type undefined'); + }); + + it('should reject empty string', () => { + const result = stackTraceValidation.validateStackTraceMode(''); + expect(result.isValid).to.be.false; + expect(result.error).to.include('Invalid value: ""'); + }); + + it('should reject string with only whitespace', () => { + const result = stackTraceValidation.validateStackTraceMode(' '); + expect(result.isValid).to.be.false; + expect(result.error).to.include('Invalid value'); + }); + }); + + describe('validateStackTraceLength', () => { + it('should validate positive number', () => { + const result = stackTraceValidation.validateStackTraceLength(10); + expect(result.isValid).to.be.true; + expect(result.error).to.be.null; + }); + + it('should validate zero', () => { + const result = stackTraceValidation.validateStackTraceLength(0); + expect(result.isValid).to.be.true; + expect(result.error).to.be.null; + }); + + it('should validate negative number', () => { + const result = stackTraceValidation.validateStackTraceLength(-10); + expect(result.isValid).to.be.true; + expect(result.error).to.be.null; + }); + + it('should validate large number', () => { + const result = stackTraceValidation.validateStackTraceLength(1000); + expect(result.isValid).to.be.true; + expect(result.error).to.be.null; + }); + + it('should validate decimal number', () => { + const result = stackTraceValidation.validateStackTraceLength(15.7); + expect(result.isValid).to.be.true; + expect(result.error).to.be.null; + }); + + it('should validate numeric string', () => { + const result = stackTraceValidation.validateStackTraceLength('20'); + expect(result.isValid).to.be.true; + expect(result.error).to.be.null; + }); + + it('should validate negative numeric string', () => { + const result = stackTraceValidation.validateStackTraceLength('-15'); + expect(result.isValid).to.be.true; + expect(result.error).to.be.null; + }); + + it('should validate decimal numeric string', () => { + const result = stackTraceValidation.validateStackTraceLength('12.5'); + expect(result.isValid).to.be.true; + expect(result.error).to.be.null; + }); + + it('should validate numeric string with leading zeros', () => { + const result = stackTraceValidation.validateStackTraceLength('007'); + expect(result.isValid).to.be.true; + expect(result.error).to.be.null; + }); + + it('should validate numeric string with whitespace', () => { + const result = stackTraceValidation.validateStackTraceLength(' 25 '); + expect(result.isValid).to.be.true; + expect(result.error).to.be.null; + }); + + it('should validate numeric string with plus sign', () => { + const result = stackTraceValidation.validateStackTraceLength('+30'); + expect(result.isValid).to.be.true; + expect(result.error).to.be.null; + }); + + it('should reject null value', () => { + const result = stackTraceValidation.validateStackTraceLength(null); + expect(result.isValid).to.be.false; + expect(result.error).to.equal('The value cannot be null'); + }); + + it('should reject undefined value', () => { + const result = stackTraceValidation.validateStackTraceLength(undefined); + expect(result.isValid).to.be.false; + expect(result.error).to.equal('The value cannot be null'); + }); + + it('should reject non-numeric string', () => { + const result = stackTraceValidation.validateStackTraceLength('invalid'); + expect(result.isValid).to.be.false; + expect(result.error).to.include('cannot be parsed to a numerical value'); + }); + + it('should reject empty string', () => { + const result = stackTraceValidation.validateStackTraceLength(''); + expect(result.isValid).to.be.false; + expect(result.error).to.include('cannot be parsed to a numerical value'); + }); + + it('should reject boolean type', () => { + const result = stackTraceValidation.validateStackTraceLength(true); + expect(result.isValid).to.be.false; + expect(result.error).to.include('non-supported type boolean'); + }); + + it('should reject object type', () => { + const result = stackTraceValidation.validateStackTraceLength({}); + expect(result.isValid).to.be.false; + expect(result.error).to.include('non-supported type object'); + }); + + it('should reject array type', () => { + const result = stackTraceValidation.validateStackTraceLength([10]); + expect(result.isValid).to.be.false; + expect(result.error).to.include('non-supported type object'); + }); + + it('should reject Infinity', () => { + const result = stackTraceValidation.validateStackTraceLength(Infinity); + expect(result.isValid).to.be.false; + expect(result.error).to.include('Invalid value: Infinity'); + }); + + it('should reject -Infinity', () => { + const result = stackTraceValidation.validateStackTraceLength(-Infinity); + expect(result.isValid).to.be.false; + expect(result.error).to.include('Invalid value: -Infinity'); + }); + + it('should reject NaN', () => { + const result = stackTraceValidation.validateStackTraceLength(NaN); + expect(result.isValid).to.be.false; + expect(result.error).to.include('Invalid value: NaN'); + }); + + it('should reject string "Infinity"', () => { + const result = stackTraceValidation.validateStackTraceLength('Infinity'); + expect(result.isValid).to.be.false; + expect(result.error).to.include('cannot be parsed'); + }); + + it('should reject string "NaN"', () => { + const result = stackTraceValidation.validateStackTraceLength('NaN'); + expect(result.isValid).to.be.false; + expect(result.error).to.include('cannot be parsed to a numerical value'); + }); + + it('should reject function type', () => { + const result = stackTraceValidation.validateStackTraceLength(() => 10); + expect(result.isValid).to.be.false; + expect(result.error).to.include('non-supported type function'); + }); + + it('should reject symbol type', () => { + const result = stackTraceValidation.validateStackTraceLength(Symbol('test')); + expect(result.isValid).to.be.false; + expect(result.error).to.include('non-supported type symbol'); + }); + }); +});