diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f5e2064912a..8338fadc330 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -149,10 +149,10 @@ /packages/dd-trace/test/plugins/util/test.spec.js @DataDog/ci-app-libraries /packages/dd-trace/test/plugins/util/test-environment.spec.js @DataDog/ci-app-libraries /packages/dd-trace/src/encode/agentless-ci-visibility.js @DataDog/ci-app-libraries -/packages/dd-trace/src/encode/agentless-json.js @DataDog/ci-app-libraries /packages/dd-trace/src/encode/coverage-ci-visibility.js @DataDog/ci-app-libraries -/packages/dd-trace/src/encode/tags-processors.js @DataDog/ci-app-libraries +/packages/dd-trace/src/encode/agentless-json.js @DataDog/ci-app-libraries /packages/dd-trace/src/exporters/agentless/ @DataDog/ci-app-libraries +/packages/dd-trace/src/encode/tags-processors.js @DataDog/ci-app-libraries /packages/dd-trace/src/git_metadata.js @DataDog/ci-app-libraries /packages/dd-trace/src/git_metadata_tagger.js @DataDog/ci-app-libraries /packages/dd-trace/src/plugins/util/ci.js @DataDog/ci-app-libraries @@ -398,6 +398,7 @@ /benchmark/sirun/exporting-pipeline/ @DataDog/lang-platform-js /benchmark/sirun/id/ @DataDog/lang-platform-js /benchmark/sirun/log/ @DataDog/lang-platform-js +/benchmark/sirun/native-span-drain.js @DataDog/lang-platform-js /benchmark/sirun/runtime-metrics/ @DataDog/lang-platform-js /benchmark/sirun/scope/ @DataDog/lang-platform-js /benchmark/sirun/shimmer-runtime/ @DataDog/lang-platform-js @@ -430,11 +431,16 @@ /packages/datadog-shimmer/ @DataDog/lang-platform-js /packages/dd-trace/*/crashtracking/ @DataDog/lang-platform-js /packages/dd-trace/index.js @DataDog/lang-platform-js +/packages/dd-trace/src/native/ @DataDog/lang-platform-js +/packages/dd-trace/src/exporters/native/ @DataDog/lang-platform-js +/packages/dd-trace/test/native/ @DataDog/lang-platform-js /packages/dd-trace/src/bootstrap.js @DataDog/lang-platform-js /packages/dd-trace/src/constants.js @DataDog/lang-platform-js /packages/dd-trace/src/dogstatsd.js @DataDog/lang-platform-js /packages/dd-trace/src/exporter.js @DataDog/lang-platform-js /packages/dd-trace/src/feature-registry.js @DataDog/lang-platform-js +/packages/dd-trace/src/js_span_processor.js @DataDog/lang-platform-js +/packages/dd-trace/test/js_span_processor.spec.js @DataDog/lang-platform-js /packages/dd-trace/src/exporters/common/ @DataDog/lang-platform-js /packages/dd-trace/src/exporters/common/client-library-headers.js @DataDog/lang-platform-js @DataDog/feature-flagging-and-experimentation-sdk /packages/dd-trace/src/heap_snapshots.js @DataDog/lang-platform-js diff --git a/.github/workflows/system-tests.yml b/.github/workflows/system-tests.yml index 46b0d193f4e..9f18d1d9335 100644 --- a/.github/workflows/system-tests.yml +++ b/.github/workflows/system-tests.yml @@ -39,6 +39,10 @@ jobs: packages: write with: library: nodejs + # TEMPORARY: pin system-tests to the branch that flushes native client stats + # on /trace/stats/flush (DataDog/system-tests#7293). Revert to the default + # (drop this `ref`) once #7293 merges to system-tests main. + ref: bengl/parametric-native-stats-flush binaries_artifact: system_tests_binaries desired_execution_time: 300 # 5 minutes scenarios_groups: tracer-release diff --git a/.gitignore b/.gitignore index 21fc16d02f8..f5fad6f4bfc 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ Temporary Items logs *.log node-*-junit.xml +.junit-tmp/ npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/.gitlab/benchmarks/gitlab-ci.yml b/.gitlab/benchmarks/gitlab-ci.yml index c9e30a6c068..8fe450931d9 100644 --- a/.gitlab/benchmarks/gitlab-ci.yml +++ b/.gitlab/benchmarks/gitlab-ci.yml @@ -33,6 +33,8 @@ variables: needs: [ ] tags: ["runner:apm-k8s-m7i-metal"] image: $MICROBENCHMARKS_CI_IMAGE + variables: + KUBERNETES_POD_ANNOTATIONS_1: "beta.fabric.datadoghq.com/no-proxy-additions=registry.yarnpkg.com" rules: - if: '$CI_COMMIT_REF_NAME =~ /^graphite-base\/.*$/' when: never @@ -177,4 +179,10 @@ benchmark-serverless-trigger: UPSTREAM_GITLAB_USER_EMAIL: $GITLAB_USER_EMAIL # only available on Merge Requests UPSTREAM_MERGE_TARGET_BRANCH: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME + # The downstream serverless-tools hard cap can lag current main layer sizes; + # keep PRs gated by size increase while allowing the largest measured current-main layer. + MAX_LAYER_UNCOMPRESSED_SIZE_KB: "25280" + KUBERNETES_POD_ANNOTATIONS_1: "beta.fabric.datadoghq.com/no-proxy-additions=registry.yarnpkg.com,registry.npmjs.org" + YARN_REGISTRY: "https://registry.npmjs.org" + YARN_NETWORK_TIMEOUT: "600000" DD_TAGS: "SLS_CI_BRANCH:$SLS_CI_BRANCH" diff --git a/benchmark/sirun/appsec/server.js b/benchmark/sirun/appsec/server.js index 2fd3a861551..54b7f09ba86 100644 --- a/benchmark/sirun/appsec/server.js +++ b/benchmark/sirun/appsec/server.js @@ -14,6 +14,8 @@ const tracer = require('../../..').init() // Fail loudly if the tracer did not load: a broken require would otherwise // measure a plain server and silently "pass". assert.equal(typeof tracer.startSpan, 'function', 'tracer did not initialize') +assert.strictEqual(tracer._tracer._config.appsec.enabled, Boolean(Number(process.env.DD_APPSEC_ENABLED))) +tracer._tracer._processor._exporter = { export () {} } // eslint-disable-next-line import/order -- the tracer must load before http to instrument it const http = require('http') diff --git a/benchmark/sirun/exporting-pipeline/index.js b/benchmark/sirun/exporting-pipeline/index.js index 6ccb059405a..62973097704 100644 --- a/benchmark/sirun/exporting-pipeline/index.js +++ b/benchmark/sirun/exporting-pipeline/index.js @@ -7,7 +7,14 @@ globalThis[Symbol.for('dd-trace')] ??= { beforeExitHandlers: new Set() } const hostname = require('os').hostname() const guard = require('../startup-guard') -const SpanProcessor = require('../../../packages/dd-trace/src/span_processor') +// CI runs candidate benchmark sources against the baseline tracer source. +let SpanProcessor +try { + SpanProcessor = require('../../../packages/dd-trace/src/js_span_processor') +} catch (e) { + if (e.code !== 'MODULE_NOT_FOUND' || !e.message.includes('js_span_processor')) throw e + SpanProcessor = require('../../../packages/dd-trace/src/span_processor') +} const PrioritySampler = require('../../../packages/dd-trace/src/priority_sampler') const id = require('../../../packages/dd-trace/src/id') diff --git a/benchmark/sirun/plugin-mongodb-core/index.js b/benchmark/sirun/plugin-mongodb-core/index.js index 3f0d449fb74..71963b37048 100644 --- a/benchmark/sirun/plugin-mongodb-core/index.js +++ b/benchmark/sirun/plugin-mongodb-core/index.js @@ -24,7 +24,9 @@ const OPERATIONS = Number(process.env.OPERATIONS) // `Object.create`) is required because `DatabasePlugin` uses private methods // that demand a real instance. let lastMeta -const FAKE_SPAN = { finish () {} } +// bindStart calls span.setTag('peer.service', ...) (query.js), so the stub must +// implement setTag alongside finish (matching the other plugin benchmarks). +const FAKE_SPAN = { finish () {}, setTag () {} } const SERVICE_RESULT = { name: 'mongo-prod', source: 'mongodb' } class BenchedMongoPlugin extends MongodbCorePlugin { addTraceSubs () { /* skip diagnostic-channel subscriptions */ } diff --git a/benchmark/sirun/plugin-redis-traced/index.js b/benchmark/sirun/plugin-redis-traced/index.js index cf481088ed0..84358074798 100644 --- a/benchmark/sirun/plugin-redis-traced/index.js +++ b/benchmark/sirun/plugin-redis-traced/index.js @@ -74,10 +74,6 @@ for (let i = 0; i < OPERATIONS; i++) { startCh.runStores(ctx, NOOP) finishCh.publish(ctx) } -// This is the heaviest per-iteration loop in the suite (a full span lifecycle -// through the real processor), so the instruction-counting pass on the stable -// machine scales steeply with the count: ~600k overran the one-minute budget, -// 450k keeps the variant under it while staying deterministic. At that count the -// fixed full-tracer init still settles around 15% of the run -- pushing it below -// 10% would need a count that overruns the budget -- so allow an 18% startup share. +// The full traced lifecycle cannot grow enough to meet the default startup +// share without exceeding the benchmark runtime budget. guard.done(0.18) diff --git a/benchmark/sirun/runall.sh b/benchmark/sirun/runall.sh index bd798bf22aa..6739138377e 100755 --- a/benchmark/sirun/runall.sh +++ b/benchmark/sirun/runall.sh @@ -40,10 +40,12 @@ else source /usr/local/nvm/nvm.sh fi +YARN_INSTALL_FLAGS=(--ignore-engines --network-timeout 600000) + ( cd ../../ && npm install --global yarn || (sleep 60 && npm install --global yarn) \ - && yarn install --ignore-engines || (sleep 60 && yarn install --ignore-engines) \ + && yarn install "${YARN_INSTALL_FLAGS[@]}" || (sleep 60 && yarn install "${YARN_INSTALL_FLAGS[@]}") \ && PLUGINS="graphql|express" yarn services ) diff --git a/benchmark/sirun/spans/README.md b/benchmark/sirun/spans/README.md index 7b695939b00..658d3434480 100644 --- a/benchmark/sirun/spans/README.md +++ b/benchmark/sirun/spans/README.md @@ -1,5 +1,2 @@ -This test initializes a tracer with the no-op scope manager. It then creates -many spans, and depending on the variant, either finishes all of them as they -are created, or later on once they're all created. Prior to creating any spans, -it modifies the processor instance so that no span processing (or exporting) is -done, and it simply stops storing the spans. +Measures JS span construction and finish with the no-op scope manager while +bypassing processing and export, isolating the span lifecycle from serialization and transport. diff --git a/benchmark/sirun/spans/spans.js b/benchmark/sirun/spans/spans.js index 909f79ba246..749f683f130 100644 --- a/benchmark/sirun/spans/spans.js +++ b/benchmark/sirun/spans/spans.js @@ -2,30 +2,25 @@ const assert = require('node:assert/strict') const guard = require('../startup-guard') +const eraseTrace = require('../../../packages/dd-trace/src/span-processor-state') const tracer = require('../../..').init() +/** @param {import('../../../packages/dd-trace/src/opentracing/span')} span */ tracer._tracer._processor.process = function process (span) { const trace = span.context()._trace - this._erase(trace) + eraseTrace(trace, []) } const { FINISH, SHAPE = 'plain' } = process.env -// Total spans created per process. The fixed tracer load (~75 ms) must be a small -// fraction of the run so the bench measures span construction, not startup; at -// 2M it is well under 10%. OPERATIONS keeps it tunable per variant: finish-later (the -// noisiest variant) runs a heavier 3M over more sirun iterations (meta.json) so its -// deferred-finish GC jitter averages out run-to-run, within the one-minute budget. +// Keep the operation count tunable because the span shapes cross the allocation +// cliff at different points. const OPERATIONS = Number(process.env.OPERATIONS) // finish-later defers the finish so it runs off the active-span path. Holding all -// OPERATIONS spans live at once would blow the heap (a 1M array of spans is ~1.6 GB); -// instead run in fixed-size batches so the deferred-finish path is still exercised -// while live memory stays flat. The batch size sets peak live spans, hence major-GC -// pause size: 10k drove run-to-run jitter (the major share of finish-later's noise), -// 500 added loop/reset overhead and got noisy again, 2000 sits in the valley (lower -// stddev and ~10% faster locally). Overridable to re-sweep if the span shape changes. +// operations live at once would grow the heap with the workload. Fixed-size batches +// still exercise deferred finish while keeping live memory flat. const BATCH = Number(process.env.BATCH) || 2000 const spans = [] @@ -79,6 +74,7 @@ assert.equal(sanitySpan.context().getTag('service'), 'svc') assert.equal(sanitySpan._links.length, 1) assert.equal(sanitySpan._events.length, 1) sanitySpan.finish() +LINK_TARGET.finish() // One span creation for the active shape. addEvent only applies to the otel shape. function startOne () { @@ -117,6 +113,6 @@ if (FINISH === 'now') { remaining -= size } } -// Full-tracer load is a fixed ~90 ms here and the lightest variant can't grow its -// loop past it without risking the span-allocation GC cliff, so use the relaxed ceiling. +// These allocation-heavy variants cannot grow enough to meet the default startup +// share without crossing the GC cliff. guard.done(0.15) diff --git a/integration-tests/aiguard/index.spec.js b/integration-tests/aiguard/index.spec.js index acbfa0eec69..9e39b06e5e3 100644 --- a/integration-tests/aiguard/index.spec.js +++ b/integration-tests/aiguard/index.spec.js @@ -5,8 +5,15 @@ const path = require('path') const { after, afterEach, before, beforeEach, describe, it } = require('mocha') -const { sandboxCwd, useSandbox, FakeAgent, spawnProc, stopProc } = require('../helpers') -const { assertObjectContains } = require('../helpers') +const { + assertClientComputedStats, + assertObjectContains, + FakeAgent, + sandboxCwd, + spawnProc, + stopProc, + useSandbox, +} = require('../helpers') const { USER_KEEP } = require('../../ext/priority') const { APM_TRACING_ENABLED_KEY, @@ -112,7 +119,7 @@ describe('AIGuard SDK integration tests', () => { }) function assertStandaloneAiGuardTrace (headers, payload) { - assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') + assertClientComputedStats(headers) const requestSpan = payload[0].find(span => span.name === 'express.request') const guardSpan = payload[0].find(span => span.name === 'ai_guard') diff --git a/integration-tests/appsec/standalone-asm.spec.js b/integration-tests/appsec/standalone-asm.spec.js index e1fdcf5ac11..c97a191a6a6 100644 --- a/integration-tests/appsec/standalone-asm.spec.js +++ b/integration-tests/appsec/standalone-asm.spec.js @@ -5,6 +5,7 @@ const path = require('path') const { inspect } = require('node:util') const { + assertClientComputedStats, sandboxCwd, useSandbox, FakeAgent, @@ -77,7 +78,7 @@ describe('Standalone ASM', () => { // first req initializes the waf and reports the first appsec event adding manual.keep tag it('should send correct headers and tags on first req', async () => { return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { - assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') + assertClientComputedStats(headers) assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 1) assert.ok(Array.isArray(payload[0]), `Expected array, got ${inspect(payload[0])}`) @@ -135,7 +136,7 @@ describe('Standalone ASM', () => { it('should keep fifth req because RateLimiter allows 1 req/min', async () => { const promise = curlAndAssertMessage(agent, proc, ({ headers, payload }) => { - assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') + assertClientComputedStats(headers) assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) if (payload.length === 4) { assertKeep(payload[0][0]) @@ -175,7 +176,7 @@ describe('Standalone ASM', () => { const urlAttack = proc.url + '?query=1 or 1=1' return curlAndAssertMessage(agent, urlAttack, ({ headers, payload }) => { - assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') + assertClientComputedStats(headers) assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 4) @@ -188,7 +189,7 @@ describe('Standalone ASM', () => { const url = proc.url + '/login?user=test' return curlAndAssertMessage(agent, url, ({ headers, payload }) => { - assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') + assertClientComputedStats(headers) assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 4) @@ -201,7 +202,7 @@ describe('Standalone ASM', () => { const url = proc.url + '/sdk' return curlAndAssertMessage(agent, url, ({ headers, payload }) => { - assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') + assertClientComputedStats(headers) assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 4) @@ -214,7 +215,7 @@ describe('Standalone ASM', () => { const url = proc.url + '/vulnerableHash' return curlAndAssertMessage(agent, url, ({ headers, payload }) => { - assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') + assertClientComputedStats(headers) assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 4) @@ -252,7 +253,7 @@ describe('Standalone ASM', () => { const url = `${proc.url}/propagation-after-drop-and-call-sdk?port=${port2}` return curlAndAssertMessage(agent, url, ({ headers, payload }) => { - assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') + assertClientComputedStats(headers) assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) const innerReq = payload.find(p => p[0].resource === 'GET /sdk') @@ -269,7 +270,7 @@ describe('Standalone ASM', () => { const url = `${proc.url}/propagation-with-event?port=${port2}` return curlAndAssertMessage(agent, url, ({ headers, payload }) => { - assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') + assertClientComputedStats(headers) assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) const innerReq = payload.find(p => p[0].resource === 'GET /down') @@ -284,7 +285,7 @@ describe('Standalone ASM', () => { const url = `${proc.url}/propagation-without-event?port=${port2}` return curlAndAssertMessage(agent, url, ({ headers, payload }) => { - assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') + assertClientComputedStats(headers) assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) const innerReq = payload.find(p => p[0].resource === 'GET /down') @@ -298,7 +299,7 @@ describe('Standalone ASM', () => { const url = `${proc.url}/propagation-with-event?port=${port2}` return curlAndAssertMessage(agent, url, ({ headers, payload }) => { - assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') + assertClientComputedStats(headers) assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) const innerReq = payload.find(p => p[0].resource === 'GET /down') @@ -334,7 +335,7 @@ describe('Standalone ASM', () => { it('should keep fifth req because of api security sampler', async () => { const promise = curlAndAssertMessage(agent, proc, ({ headers, payload }) => { - assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') + assertClientComputedStats(headers) assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) if (payload.length === 4) { assertKeep(payload[0][0]) diff --git a/integration-tests/ci-visibility-intake.js b/integration-tests/ci-visibility-intake.js index 170df12ca60..9df1cf2c4f4 100644 --- a/integration-tests/ci-visibility-intake.js +++ b/integration-tests/ci-visibility-intake.js @@ -191,7 +191,7 @@ class FakeCiVisIntake extends FakeAgent { const app = express() app.use(bodyParser.raw({ limit: Infinity, type: 'application/msgpack' })) - app.put('/v0.4/traces', (req, res) => { + const handleV04Traces = (req, res) => { if (req.body.length === 0) return res.status(200).send() res.status(200).send({ rate_by_service: { 'service:,env:': 1 } }) this.emit('message', { @@ -199,7 +199,9 @@ class FakeCiVisIntake extends FakeAgent { payload: msgpack.decode(req.body, { useBigInt64: true }), url: req.url, }) - }) + } + app.put('/v0.4/traces', handleV04Traces) + app.post('/v0.4/traces', handleV04Traces) app.get('/info', (req, res) => { res.status(200).send(JSON.stringify(this.#infoResponse)) diff --git a/integration-tests/ci-visibility-intake.spec.js b/integration-tests/ci-visibility-intake.spec.js index 28a08b01a80..cb52f592e68 100644 --- a/integration-tests/ci-visibility-intake.spec.js +++ b/integration-tests/ci-visibility-intake.spec.js @@ -1,11 +1,14 @@ 'use strict' const assert = require('node:assert/strict') -const { EventEmitter } = require('node:events') +const { EventEmitter, once } = require('node:events') +const http = require('node:http') +const msgpack = require('@msgpack/msgpack') const sinon = require('sinon') const { FakeCiVisIntake } = require('./ci-visibility-intake') +const { assertClientComputedStats } = require('./helpers') function fakeChildProcess () { const child = new EventEmitter() @@ -18,6 +21,62 @@ function fakeChildProcess () { return child } +/** + * @param {number} port + * @param {object[][]} payload + */ +async function postV04Trace (port, payload) { + const response = await new Promise((resolve, reject) => { + const request = http.request({ + host: '127.0.0.1', + method: 'POST', + path: '/v0.4/traces', + port, + headers: { 'content-type': 'application/msgpack' }, + }, resolve) + request.once('error', reject) + request.end(msgpack.encode(payload)) + }) + const ended = once(response, 'end') + response.resume() + await ended +} + +describe('FakeCiVisIntake v0.4 endpoint', () => { + let intake + + beforeEach(async () => { + intake = await new FakeCiVisIntake().start() + }) + + afterEach(() => intake.stop()) + + it('accepts native POST payloads', async () => { + const received = intake.payloadReceived(({ url }) => url === '/v0.4/traces') + const payload = [[{ name: 'test' }]] + + await postV04Trace(intake.port, payload) + + assert.deepStrictEqual((await received).payload, payload) + }) +}) + +describe('assertClientComputedStats', () => { + it('accepts every Agent truthy spelling', () => { + for (const value of ['yes', 'true', 't', '1']) { + assertClientComputedStats({ 'datadog-client-computed-stats': value }) + } + }) + + it('rejects false and missing values', () => { + assert.throws( + () => assertClientComputedStats({ 'datadog-client-computed-stats': 'false' }), + /should be truthy/, + ) + assert.throws(() => assertClientComputedStats({}), /should be truthy/) + }) +}) + describe('FakeCiVisIntake.gatherPayloadsUntilChildExit', () => { let clock, intake diff --git a/integration-tests/helpers/fake-agent.js b/integration-tests/helpers/fake-agent.js index 3f98984e784..34b934235fd 100644 --- a/integration-tests/helpers/fake-agent.js +++ b/integration-tests/helpers/fake-agent.js @@ -383,14 +383,19 @@ function buildExpressServer (agent) { res.json({ endpoints }) }) - app.put('/v0.4/traces', (req, res) => { + // The native (libdatadog) exporter sends `POST /v0.4/traces` while the legacy + // JS exporter uses PUT; the real agent accepts both. Register the same + // handler for each so trace payloads are received regardless of exporter. + const handleV04Traces = (req, res) => { if (req.body.length === 0) return res.status(200).send() res.status(200).send({ rate_by_service: { 'service:,env:': 1 } }) agent.emit('message', { headers: req.headers, payload: msgpack.decode(req.body, { useBigInt64: true }), }) - }) + } + app.put('/v0.4/traces', handleV04Traces) + app.post('/v0.4/traces', handleV04Traces) app.post('/v0.7/config', (req, res) => { const { diff --git a/integration-tests/helpers/index.js b/integration-tests/helpers/index.js index 23171566a89..03c030c8290 100644 --- a/integration-tests/helpers/index.js +++ b/integration-tests/helpers/index.js @@ -38,6 +38,18 @@ const ANY_NUMBER = Symbol('test.ANY_NUMBER') const ANY_VALUE = Symbol('test.ANY_VALUE') const defaultStopProcTimeoutMs = 2_000 +/** + * Assert that the agent's client-computed-stats header has a truthy value. + * @param {Record} headers + */ +function assertClientComputedStats (headers) { + const value = headers['datadog-client-computed-stats'] + assert.ok( + ['yes', 'true', 't', '1'].includes(value), + `datadog-client-computed-stats should be truthy, got '${value}'` + ) +} + /** * @param {string} filename * @param {string} cwd @@ -1348,6 +1360,8 @@ module.exports = { FakeAgent, hookFile, assertObjectContains, + assertClientComputedStats, + assertUUID, deepFreeze, stopProc, diff --git a/integration-tests/init.spec.js b/integration-tests/init.spec.js index 25d2677b273..d916e3e5074 100644 --- a/integration-tests/init.spec.js +++ b/integration-tests/init.spec.js @@ -24,6 +24,7 @@ const { } = require('./helpers') const supportedRange = engines.node const currentVersionIsSupported = semver.satisfies(NODE_VERSION, supportedRange) +const nativeInitDebugLines = '(?:Native spans interface initialized\nNative spans mode enabled\n)?' // These are on by default in release tests, so we'll turn them off for // more fine-grained control of these variables in these tests. delete process.env.DD_INJECTION_ENABLED @@ -162,7 +163,7 @@ false Found incompatible runtime Node.js ${process.versions.node}, Supported runtimes: Node.js \ >=${NODE_MAJOR + 1} <${MAX_NODE_MAJOR}. DD_INJECT_FORCE enabled, allowing unsupported runtimes and continuing. -Application instrumentation bootstrapping complete +${nativeInitDebugLines}Application instrumentation bootstrapping complete true `, telemetryForced)) }) @@ -204,7 +205,7 @@ false Found incompatible runtime Node.js ${process.versions.node}, Supported runtimes: Node.js \ ${engines.node} <${NODE_MAJOR}. DD_INJECT_FORCE enabled, allowing unsupported runtimes and continuing. -Application instrumentation bootstrapping complete +${nativeInitDebugLines}Application instrumentation bootstrapping complete true `, telemetryForced)) }) diff --git a/integration-tests/opentelemetry-traces.spec.js b/integration-tests/opentelemetry-traces.spec.js index b908159e8f5..be03c33c998 100644 --- a/integration-tests/opentelemetry-traces.spec.js +++ b/integration-tests/opentelemetry-traces.spec.js @@ -21,6 +21,12 @@ function waitForOtlpTraces (agent, timeout) { }) } +function getAttributeValue (attributes, key) { + const attribute = attributes.find(attribute => attribute.key === key) + assert.ok(attribute, `attribute ${key} should be present`) + return attribute.value +} + describe('OTLP Trace Export', () => { let agent let cwd @@ -128,15 +134,10 @@ describe('OTLP Trace Export', () => { assert.ok(span.endTimeUnixNano >= span.startTimeUnixNano, 'endTime should be >= startTime') } - assertObjectContains(webSpan.attributes, [ - { key: 'service.name', value: { stringValue: 'otlp-test-service' } }, - { key: 'operation.name', value: { stringValue: 'web.request' } }, - { key: 'resource.name', value: { stringValue: 'GET /api/test' } }, - { key: 'http.method', value: { stringValue: 'GET' } }, - { key: 'http.url', value: { stringValue: '/api/test' } }, - ]) - assertObjectContains(dbSpan.attributes, [ - { key: 'db.type', value: { stringValue: 'postgres' } }, - ]) + assert.deepStrictEqual(getAttributeValue(webSpan.attributes, 'operation.name'), { stringValue: 'web.request' }) + assert.deepStrictEqual(getAttributeValue(webSpan.attributes, 'resource.name'), { stringValue: 'GET /api/test' }) + assert.deepStrictEqual(getAttributeValue(webSpan.attributes, 'http.method'), { stringValue: 'GET' }) + assert.deepStrictEqual(getAttributeValue(webSpan.attributes, 'http.url'), { stringValue: '/api/test' }) + assert.deepStrictEqual(getAttributeValue(dbSpan.attributes, 'db.type'), { stringValue: 'postgres' }) }) }) diff --git a/package.json b/package.json index 584a7df98a6..c1a5d845240 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "test:debugger": "mocha \"packages/dd-trace/test/debugger/**/*.spec.js\"", "test:debugger:ci": "node scripts/c8-ci.js test:debugger", "test:eslint-rules": "node scripts/run-eslint-rule-tests.mjs eslint-rules/*.test.mjs", - "test:trace:core": "node scripts/mocha-parallel-files.js --expose-gc --timeout 30000 -- \"packages/dd-trace/test/*.spec.js\" \"packages/dd-trace/test/{agent,ci-visibility,config,crashtracking,datastreams,encode,exporters,msgpack,opentelemetry,opentracing,payload-tagging,plugins,remote_config,service-naming,standalone,telemetry,external-logger}/**/*.spec.js\"", + "test:trace:core": "node scripts/mocha-parallel-files.js --expose-gc --timeout 30000 -- \"packages/dd-trace/test/*.spec.js\" \"packages/dd-trace/test/{agent,ci-visibility,config,crashtracking,datastreams,encode,exporters,msgpack,native,opentelemetry,opentracing,payload-tagging,plugins,remote_config,service-naming,standalone,telemetry,external-logger}/**/*.spec.js\"", "test:trace:core:ci": "node scripts/c8-ci.js test:trace:core", "test:trace:guardrails": "mocha \"packages/dd-trace/test/guardrails/**/*.spec.js\"", "test:trace:guardrails:ci": "node scripts/c8-ci.js test:trace:guardrails", @@ -182,7 +182,7 @@ "opentracing": ">=0.14.7" }, "optionalDependencies": { - "@datadog/libdatadog": "0.12.1", + "@datadog/libdatadog": "0.18.1", "@datadog/native-appsec": "11.0.1", "@datadog/native-iast-taint-tracking": "4.2.0", "@datadog/native-metrics": "3.1.2", diff --git a/packages/datadog-esbuild/index.js b/packages/datadog-esbuild/index.js index 3aed61204e2..c9d1036d011 100644 --- a/packages/datadog-esbuild/index.js +++ b/packages/datadog-esbuild/index.js @@ -119,6 +119,21 @@ module.exports.setup = function (build) { const isSourceMapEnabled = !!build.initialOptions.sourcemap || ['internal', 'both'].includes(build.initialOptions.sourcemap) const externalModules = new Set(build.initialOptions.external || []) + + // `@datadog/libdatadog` ships platform-specific native/.wasm binaries that it + // loads at runtime by reading its own `prebuilds/` directory and dynamically + // requiring the resolved file (see its load.js). Bundling it inlines that + // loader, so `__dirname` points at the output bundle instead of the package + // and the binaries can't be found. It is a hard, always-loaded dependency of + // the native span pipeline, so externalize it automatically (webpack users + // list it in `externals`; here the plugin does it for them) — it resolves + // from node_modules at runtime. + if (!externalModules.has('@datadog/libdatadog')) { + externalModules.add('@datadog/libdatadog') + build.initialOptions.external ??= [] + build.initialOptions.external.push('@datadog/libdatadog') + } + build.initialOptions.banner ??= {} build.initialOptions.banner.js ??= '' if (DD_IAST_ENABLED) { diff --git a/packages/datadog-instrumentations/src/aerospike.js b/packages/datadog-instrumentations/src/aerospike.js index cb8f346b080..31fabcbeab1 100644 --- a/packages/datadog-instrumentations/src/aerospike.js +++ b/packages/datadog-instrumentations/src/aerospike.js @@ -8,6 +8,7 @@ const { } = require('./helpers/instrument') const ch = tracingChannel('apm:aerospike:command') +const kTracingCallbackCommand = Symbol('datadog.aerospike.tracing_callback_command') function wrapCreateCommand (createCommand) { if (typeof createCommand !== 'function') return createCommand @@ -17,27 +18,50 @@ function wrapCreateCommand (createCommand) { if (!CommandClass) return CommandClass + if (typeof CommandClass.prototype.executeWithCallback === 'function') { + shimmer.wrap(CommandClass.prototype, 'executeWithCallback', wrapExecuteWithCallback) + } shimmer.wrap(CommandClass.prototype, 'process', wrapProcess) return CommandClass } } +function wrapExecuteWithCallback (executeWithCallback) { + return function (...args) { + const cb = args[0] + if (typeof cb !== 'function') return executeWithCallback.apply(this, args) + + this[kTracingCallbackCommand] = true + try { + return ch.traceCallback(executeWithCallback, 0, getContext(this), this, ...args) + } finally { + this[kTracingCallbackCommand] = false + } + } +} + function wrapProcess (process) { return function (...args) { const cb = args[0] if (typeof cb !== 'function') return process.apply(this, args) - const ctx = { - commandName: this.constructor.name, - commandArgs: this.args, - clientConfig: this.client.config, - } + if (this[kTracingCallbackCommand]) return process.apply(this, args) + + const ctx = getContext(this) return ch.traceCallback(process, -1, ctx, this, ...args) } } +function getContext (command) { + return { + commandName: command.constructor.name, + commandArgs: command.args, + clientConfig: command.client.config, + } +} + addHook({ name: 'aerospike', file: 'lib/commands/command.js', diff --git a/packages/datadog-plugin-aerospike/test/index.spec.js b/packages/datadog-plugin-aerospike/test/index.spec.js index f5f60d091cc..453513305f0 100644 --- a/packages/datadog-plugin-aerospike/test/index.spec.js +++ b/packages/datadog-plugin-aerospike/test/index.spec.js @@ -25,7 +25,6 @@ describe('Plugin', () => { withVersions('aerospike', 'aerospike', version => { beforeEach(() => { - tracer = require('../../dd-trace') aerospike = require(`../../../versions/aerospike@${version}`).get() }) @@ -43,18 +42,15 @@ describe('Plugin', () => { keyString = `${ns}:${set}:${userKey}` }) - after(() => { - return agent.close() - }) - describe('without configuration', () => { - before(function () { + before(async function () { this.timeout(10_000) - return agent.load('aerospike') + tracer = await agent.load('aerospike') }) after(() => { aerospike?.releaseEventLoop() + return agent.close() }) describe('client', () => { @@ -85,7 +81,7 @@ describe('Plugin', () => { 'aerospike.userkey': userKey, component: 'aerospike', }, - }) + }, { spanResourceMatch: /^Put$/ }) .then(done) .catch(done) @@ -130,7 +126,7 @@ describe('Plugin', () => { 'aerospike.userkey': userKey, component: 'aerospike', }, - }) + }, { spanResourceMatch: /^Get$/ }) .then(done) .catch(done) @@ -155,7 +151,7 @@ describe('Plugin', () => { 'aerospike.userkey': userKey, component: 'aerospike', }, - }) + }, { spanResourceMatch: /^Operate$/ }) .then(done) .catch(done) @@ -187,7 +183,7 @@ describe('Plugin', () => { 'aerospike.index': 'tags_idx', component: 'aerospike', }, - }) + }, { spanResourceMatch: /^IndexCreate$/ }) .then(done) .catch(done) @@ -218,7 +214,7 @@ describe('Plugin', () => { 'aerospike.setname': set, component: 'aerospike', }, - }) + }, { spanResourceMatch: /^Query$/ }) .then(done) .catch(done) @@ -271,7 +267,7 @@ describe('Plugin', () => { component: 'aerospike', }, }) - }) + }, { spanResourceMatch: /^Operate$/ }) .then(done) .catch(done) @@ -304,13 +300,14 @@ describe('Plugin', () => { }) describe('with configuration', () => { - before(function () { + before(async function () { this.timeout(10_000) - return agent.load('aerospike', { service: 'custom' }) + tracer = await agent.load('aerospike', { service: 'custom' }) }) after(() => { aerospike?.releaseEventLoop() + return agent.close() }) it('should be configured with the correct values', done => { diff --git a/packages/datadog-plugin-aerospike/test/instrumentation.spec.js b/packages/datadog-plugin-aerospike/test/instrumentation.spec.js new file mode 100644 index 00000000000..dc44b8745e0 --- /dev/null +++ b/packages/datadog-plugin-aerospike/test/instrumentation.spec.js @@ -0,0 +1,155 @@ +'use strict' + +const assert = require('node:assert/strict') + +const dc = require('dc-polyfill') +const { afterEach, beforeEach, describe, it } = require('mocha') + +const { storage } = require('../../datadog-core') + +require('../../datadog-instrumentations/src/aerospike') + +const HOOK = globalThis[Symbol.for('_ddtrace_instrumentations')].aerospike + .find(entry => entry.file === 'lib/commands/command.js') + .hook + +const commandStorage = storage('aerospike-command-test') +const commandChannel = dc.tracingChannel('apm:aerospike:command') + +function wrapCommandFactory (commandFactory) { + return HOOK(commandFactory)() +} + +describe('packages/datadog-instrumentations/src/aerospike.js', () => { + let starts + let asyncStarts + + beforeEach(() => { + starts = 0 + asyncStarts = 0 + + commandChannel.start.bindStore(commandStorage, ctx => { + starts++ + const parentStore = commandStorage.getStore() + ctx.parentStore = parentStore + ctx.currentStore = { ...parentStore, span: { name: 'aerospike-command' } } + return ctx.currentStore + }) + + commandChannel.asyncStart.bindStore(commandStorage, ctx => { + asyncStarts++ + return ctx.parentStore + }) + }) + + afterEach(() => { + commandChannel.start.unbindStore(commandStorage) + commandChannel.asyncStart.unbindStore(commandStorage) + }) + + it('runs callbacks in the parent context after Aerospike defers a synchronous result', async () => { + const Command = wrapCommandFactory(() => class FakeCommand { + constructor () { + this.args = ['arg'] + this.client = { config: { hosts: '127.0.0.1:3000' } } + } + + process (callback) { + callback(null, 'ok') + } + + executeWithCallback (callback) { + let sync = true + this.process((error, result) => { + if (sync) { + process.nextTick(callback, error, result) + } else { + callback(error, result) + } + }) + sync = false + } + }) + + const parentSpan = { name: 'parent' } + const command = new Command() + + const resultPromise = new Promise((resolve, reject) => { + commandStorage.run({ span: parentSpan }, () => { + command.executeWithCallback((error, value) => { + try { + assert.ifError(error) + assert.equal(commandStorage.getStore()?.span, parentSpan) + resolve(value) + } catch (err) { + reject(err) + } + }) + + assert.equal(starts, 1) + assert.equal(asyncStarts, 0) + }) + }) + + const result = await resultPromise + + assert.equal(result, 'ok') + assert.equal(starts, 1) + assert.equal(asyncStarts, 1) + }) + + it('still traces commands through process when no callback helper exists', async () => { + const Command = wrapCommandFactory(() => class FakeCommand { + constructor () { + this.args = ['arg'] + this.client = { config: { hosts: '127.0.0.1:3000' } } + } + + process (callback) { + process.nextTick(callback, null, 'ok') + } + + executeAndReturnPromise () { + return new Promise((resolve, reject) => { + this.process((error, result) => { + if (error) { + reject(error) + } else { + resolve(result) + } + }) + }) + } + }) + + const parentSpan = { name: 'parent' } + const command = new Command() + + const result = await commandStorage.run({ span: parentSpan }, () => command.executeAndReturnPromise()) + + assert.equal(result, 'ok') + assert.equal(starts, 1) + assert.equal(asyncStarts, 1) + }) + + it('passes through callback helper calls without a callback', () => { + const Command = wrapCommandFactory(() => class FakeCommand { + constructor () { + this.args = ['arg'] + this.client = { config: { hosts: '127.0.0.1:3000' } } + } + + process () {} + + executeWithCallback () { + return 'result' + } + }) + + const result = new Command().executeWithCallback() + + assert.equal(result, 'result') + assert.equal(starts, 0) + assert.equal(asyncStarts, 0) + }) +}) diff --git a/packages/datadog-plugin-http2/test/client.spec.js b/packages/datadog-plugin-http2/test/client.spec.js index 4a11e84837c..47b349fd5a1 100644 --- a/packages/datadog-plugin-http2/test/client.spec.js +++ b/packages/datadog-plugin-http2/test/client.spec.js @@ -48,8 +48,16 @@ describe('Plugin', () => { return server } + // `agent.load` evicts dd-trace from require.cache and rebinds the global + // tracer, resolving with the live proxy. Bind `tracer` to that returned + // proxy so manual spans created in tests (e.g. tracer.startSpan) share the + // same tracer — and the same native span-storage interface — as the + // plugin's spans. Capturing require('../../dd-trace') separately yields a + // stale proxy, which in native mode splits a trace across two WASM span + // maps and drops it (span-not-found on flush). + const loadTracer = (...args) => agent.load(...args).then(t => { tracer = t; return t }) + beforeEach(() => { - tracer = require('../../dd-trace') appListener = null }) @@ -63,7 +71,7 @@ describe('Plugin', () => { describe('with OTel semantics enabled', () => { beforeEach(() => { process.env.DD_TRACE_OTEL_SEMANTICS_ENABLED = 'true' - return agent.load('http2', { server: false }) + return loadTracer('http2', { server: false }) .then(() => { http2 = require(loadPlugin) }) @@ -109,7 +117,7 @@ describe('Plugin', () => { describe('without configuration', () => { beforeEach(() => { - return agent.load('http2', { server: false }) + return loadTracer('http2', { server: false }) .then(() => { http2 = require(loadPlugin) }) @@ -707,7 +715,7 @@ describe('Plugin', () => { }, } - return agent.load('http2', config) + return loadTracer('http2', config) .then(() => { http2 = require(loadPlugin) }) @@ -746,11 +754,10 @@ describe('Plugin', () => { let sub beforeEach(() => { - return agent.load('http2', { server: false }) + return loadTracer('http2', { server: false }) .then(() => { ch = require('dc-polyfill').channel('apm:http2:client:request:start') sub = () => {} - tracer = require('../../dd-trace') http2 = require('http2') }) }) @@ -798,7 +805,7 @@ describe('Plugin', () => { }, } - return agent.load('http2', config) + return loadTracer('http2', config) .then(() => { http2 = require(loadPlugin) }) @@ -844,7 +851,7 @@ describe('Plugin', () => { }, } - return agent.load('http2', config) + return loadTracer('http2', config) .then(() => { http2 = require(loadPlugin) }) @@ -920,7 +927,7 @@ describe('Plugin', () => { }, } - return agent.load('http2', config) + return loadTracer('http2', config) .then(() => { http2 = require(loadPlugin) }) @@ -967,7 +974,7 @@ describe('Plugin', () => { }, } - return agent.load('http2', config) + return loadTracer('http2', config) .then(() => { http2 = require(loadPlugin) }) @@ -1009,7 +1016,7 @@ describe('Plugin', () => { }, } - return agent.load('http2', config) + return loadTracer('http2', config) .then(() => { http2 = require(loadPlugin) }) diff --git a/packages/datadog-plugin-mongodb-core/src/query.js b/packages/datadog-plugin-mongodb-core/src/query.js index 225aca285b6..6da23bd9030 100644 --- a/packages/datadog-plugin-mongodb-core/src/query.js +++ b/packages/datadog-plugin-mongodb-core/src/query.js @@ -49,6 +49,20 @@ class MongodbCoreQueryPlugin extends DatabasePlugin { 'out.port': options.port, }, }, ctx) + // When DBM propagation is enabled, master sets peer.service as a side effect + // of getPeerService mutating the live tags map while building the comment. + // In native mode that direct getTags() mutation never reaches the WASM store, + // so the exported span would lack peer.service while the comment (built from + // live _tags) still carried ddprs=. Set it through the span (WASM-synced) + // instead, gated on DBM being active so default-config spans are unchanged + // (the spanComputePeerService path already syncs via addTags). The mongo ns + // is `dbName` or `dbName.collection`, so keep the first segment — matching + // getPeerService (whose `=== undefined` guard is now false, so it won't + // re-mutate). + if (ns && this.config.dbmPropagationMode !== 'disabled') { + const dotIndex = ns.indexOf('.') + span.setTag('peer.service', dotIndex === -1 ? ns : ns.slice(0, dotIndex)) + } const comment = this.injectDbmComment(span, ops.comment, serviceResult.name) if (comment) { ops.comment = comment diff --git a/packages/datadog-plugin-mongodb-core/test/limit-depth.spec.js b/packages/datadog-plugin-mongodb-core/test/limit-depth.spec.js index 98a3d987c42..12043e8b261 100644 --- a/packages/datadog-plugin-mongodb-core/test/limit-depth.spec.js +++ b/packages/datadog-plugin-mongodb-core/test/limit-depth.spec.js @@ -8,10 +8,14 @@ const sinon = require('sinon') const MongodbCorePlugin = require('../src/query') -// The sanitisation helpers are module-private; exercise them through `bindStart`, -// which surfaces their output as `meta['mongodb.query']`. -function callBindStart (ctx, configOverride) { - const startSpan = sinon.stub().returns({ finish () {} }) +/** + * @param {object} ctx + * @param {object} [configOverride] + * @param {{ finish: () => void, setTag: (name: string, value: string) => void }} [span] + * @returns {string} + */ +function callBindStart (ctx, configOverride, span = { finish () {}, setTag () {} }) { + const startSpan = sinon.stub().returns(span) const self = { config: { heartbeatEnabled: true, @@ -56,13 +60,15 @@ describe('mongodb-core query depth limiter', () => { }) it('extracts cmd.filter when no .query is present', () => { + const span = { finish () {}, setTag: sinon.stub() } const query = callBindStart({ - ns: 'db.coll', + ns: 'db', ops: { filter: { user: 'alice' } }, name: 'find', - }) + }, { dbmPropagationMode: 'service' }, span) assert.deepStrictEqual(JSON.parse(query), { user: 'alice' }) + sinon.assert.calledOnceWithExactly(span.setTag, 'peer.service', 'db') }) it('extracts cmd.pipeline when no .query / .filter is present', () => { diff --git a/packages/datadog-webpack/index.js b/packages/datadog-webpack/index.js index 33bb840be5b..cbfde9386b7 100644 --- a/packages/datadog-webpack/index.js +++ b/packages/datadog-webpack/index.js @@ -68,6 +68,20 @@ class DatadogWebpackPlugin { * @param {object} compiler */ apply (compiler) { + // `@datadog/libdatadog` ships platform-specific native/.wasm binaries that it + // loads at runtime by reading its own `prebuilds/` directory and dynamically + // requiring the resolved file. Bundling it inlines that loader so its path + // resolution points at the output bundle instead of the package. It is a + // hard, always-loaded dependency of the native span pipeline, so externalize + // it automatically (resolved from node_modules at runtime) rather than making + // every webpack config list it in `externals`. + const ExternalsPlugin = compiler.webpack?.ExternalsPlugin + if (ExternalsPlugin) { + new ExternalsPlugin('node-commonjs', ['@datadog/libdatadog']).apply(compiler) + } else { + log.warn('compiler.webpack.ExternalsPlugin unavailable; @datadog/libdatadog must be listed in externals manually') + } + // optimization.minimize is not yet set when apply() is called in webpack 5.54.0+ // (applyWebpackOptionsDefaults runs after plugins), so we defer the check to the // environment hook which fires synchronously after defaults are applied. diff --git a/packages/datadog-webpack/test/plugin.spec.js b/packages/datadog-webpack/test/plugin.spec.js index 6b7daf4362c..286e5aeefa5 100644 --- a/packages/datadog-webpack/test/plugin.spec.js +++ b/packages/datadog-webpack/test/plugin.spec.js @@ -32,7 +32,24 @@ describe('DatadogWebpackPlugin', () => { it('does not throw when minimize is not enabled', () => { const plugin = new DatadogWebpackPlugin() const tapped = [] + let externalizedCompiler + class ExternalsPlugin { + /** + * @param {string} type + * @param {string[]} modules + */ + constructor (type, modules) { + assert.strictEqual(type, 'node-commonjs') + assert.deepStrictEqual(modules, ['@datadog/libdatadog']) + } + + /** @param {object} compiler */ + apply (compiler) { + externalizedCompiler = compiler + } + } const compiler = { + webpack: { ExternalsPlugin }, options: { optimization: { minimize: false }, }, @@ -47,6 +64,7 @@ describe('DatadogWebpackPlugin', () => { plugin.apply(compiler) assert.equal(tapped[0], 'DatadogWebpackPlugin') + assert.strictEqual(externalizedCompiler, compiler) }) }) }) diff --git a/packages/dd-trace/src/ci-visibility/exporters/ci-validation/sink.js b/packages/dd-trace/src/ci-visibility/exporters/ci-validation/sink.js index 2657eb8c442..58387da360e 100644 --- a/packages/dd-trace/src/ci-visibility/exporters/ci-validation/sink.js +++ b/packages/dd-trace/src/ci-visibility/exporters/ci-validation/sink.js @@ -303,7 +303,7 @@ function writeNewFile (filename, payload) { * * @param {string} directory directory path * @param {string} label directory label - * @returns {{dev: bigint, ino: bigint}} stable directory identity + * @returns {{dev: bigint, ino: bigint, birthtimeMs?: bigint}} stable directory identity */ function captureDirectory (directory, label) { // Windows file reference numbers exceed 2^53, where distinct directories round to one number. @@ -311,17 +311,19 @@ function captureDirectory (directory, label) { if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new Error(`Offline Test Optimization validation ${label} must be a regular directory.`) } - return { dev: stat.dev, ino: stat.ino } + const identity = { dev: stat.dev, ino: stat.ino } + if (process.platform === 'win32') identity.birthtimeMs = stat.birthtimeMs + return identity } /** * Creates or validates one child directory without accepting symbolic links. * * @param {string} parent parent directory path - * @param {{dev: bigint, ino: bigint}} parentIdentity expected parent identity + * @param {{dev: bigint, ino: bigint, birthtimeMs?: bigint}} parentIdentity expected parent identity * @param {string} directory child directory path * @param {string} label directory label - * @returns {{dev: bigint, ino: bigint}} stable child identity + * @returns {{dev: bigint, ino: bigint, birthtimeMs?: bigint}} stable child identity */ function createDirectory (parent, parentIdentity, directory, label) { assertDirectoryUnchanged(parent, parentIdentity, 'parent output') @@ -337,12 +339,14 @@ function createDirectory (parent, parentIdentity, directory, label) { * Rejects a directory that changed after sink construction. * * @param {string} directory directory path - * @param {{dev: bigint, ino: bigint}} identity expected directory identity + * @param {{dev: bigint, ino: bigint, birthtimeMs?: bigint}} identity expected directory identity * @param {string} label directory label */ function assertDirectoryUnchanged (directory, identity, label) { const current = captureDirectory(directory, label) - if (current.dev !== identity.dev || current.ino !== identity.ino) { + if (current.dev !== identity.dev || + current.ino !== identity.ino || + current.birthtimeMs !== identity.birthtimeMs) { throw new Error(`Offline Test Optimization validation ${label} changed during execution.`) } } @@ -352,7 +356,7 @@ function assertDirectoryUnchanged (directory, identity, label) { * * @param {string} filename partial payload path * @param {string} directory expected parent directory - * @param {{dev: bigint, ino: bigint}} identity expected parent identity + * @param {{dev: bigint, ino: bigint, birthtimeMs?: bigint}} identity expected parent identity */ function removePartialFile (filename, directory, identity) { try { diff --git a/packages/dd-trace/src/config/index.js b/packages/dd-trace/src/config/index.js index 7a1df2f12d6..753e7b58db8 100644 --- a/packages/dd-trace/src/config/index.js +++ b/packages/dd-trace/src/config/index.js @@ -401,11 +401,6 @@ class Config extends ConfigBase { setAndTrack(this, 'DD_METRICS_OTEL_ENABLED', false) } - if (this.OTEL_TRACES_EXPORTER === 'otlp' && trackedConfigOrigins.has('protocolVersion')) { - log.warn('DD_TRACE_AGENT_PROTOCOL_VERSION is set, disabling OTLP traces export') - setAndTrack(this, 'OTEL_TRACES_EXPORTER', 'none') - } - if (this.telemetry.DD_TELEMETRY_HEARTBEAT_INTERVAL) { setAndTrack(this, 'telemetry.DD_TELEMETRY_HEARTBEAT_INTERVAL', Math.floor(this.telemetry.DD_TELEMETRY_HEARTBEAT_INTERVAL * 1000)) @@ -622,20 +617,13 @@ class Config extends ConfigBase { } // Experimental agentless APM span intake - // When enabled, sends spans directly to Datadog intake without an agent - // TODO: Replace this with a proper configuration const agentlessEnabled = isTrue(getEnvironmentVariable('_DD_APM_TRACING_AGENTLESS_ENABLED')) if (agentlessEnabled) { setAndTrack(this, 'experimental.exporter', 'agentless') - // Disable client-side stats computation setAndTrack(this, 'stats.DD_TRACE_STATS_COMPUTATION_ENABLED', false) - // Enable hostname reporting setAndTrack(this, 'reportHostname', true) - // Disable rate limiting - server-side sampling will be used setAndTrack(this, 'sampler.rateLimit', -1) - // Clear sampling rules - server-side sampling handles this setAndTrack(this, 'sampler.rules', []) - // Agentless intake only accepts 64-bit trace IDs; disable 128-bit generation if (!trackedConfigOrigins.has('traceId128BitGenerationEnabled')) { setAndTrack(this, 'traceId128BitGenerationEnabled', false) } diff --git a/packages/dd-trace/src/encode/0.4.js b/packages/dd-trace/src/encode/0.4.js index 1916e7bc046..0601acb35e2 100644 --- a/packages/dd-trace/src/encode/0.4.js +++ b/packages/dd-trace/src/encode/0.4.js @@ -4,6 +4,7 @@ const getConfig = require('../config') const { MsgpackChunk, MAX_SIZE: MAX_CHUNK_SIZE } = require('../msgpack') const log = require('../log') const { normalizeSpan, eventTimeNano } = require('./tags-processors') +const { stringifySpanEvents } = require('./span-events') const SOFT_LIMIT = 8 * 1024 * 1024 // 8MB // Values longer than this byte threshold skip the `_stringMap` lookup and @@ -126,107 +127,6 @@ function formatSpanWithLegacyEvents (span) { return span } -/** - * Hand-written stringifier for `span.span_events`. Events arrive in their raw - * `{ name, startTime, attributes? }` shape; `time_unix_nano` is derived per - * event via `eventTimeNano` and empty attribute objects are dropped, matching - * what the formatter used to precompute. Attribute values are pre-sanitized to - * primitives or arrays of primitives, so we skip everything `JSON.stringify` - * does for the generic case (toJSON probing, prototype-chain key iteration, - * replacer hooks). - * - * @param {Array<{ name: unknown, startTime: number, attributes?: object }>} spanEvents - * @returns {string} - */ -function stringifySpanEvents (spanEvents) { - let result = '[' - for (let index = 0; index < spanEvents.length; index++) { - if (index > 0) result += ',' - const event = spanEvents[index] - // `_sanitizeEventAttributes` leaves `attributes` undefined when empty, so a - // present value always has entries — no emptiness probe here. - const attributes = event.attributes - // `addEvent` does not type-check `name`; defer the unusual cases to - // `JSON.stringify` so non-string names match the prior behaviour instead - // of throwing in `escapeJsonString`. Build the wire-shaped object so the - // emitted key stays `time_unix_nano`, not the raw `startTime`. - if (typeof event.name !== 'string') { - result += JSON.stringify({ name: event.name, time_unix_nano: eventTimeNano(event), attributes }) - continue - } - result += '{"name":' + escapeJsonString(event.name) + - ',"time_unix_nano":' + jsonNumber(eventTimeNano(event)) - if (attributes) { - result += ',"attributes":' + stringifyAttributes(attributes) - } - result += '}' - } - return result + ']' -} - -function stringifyAttributes (attributes) { - let result = '{' - let first = true - for (const key of Object.keys(attributes)) { - if (first) { - first = false - } else { - result += ',' - } - result += escapeJsonString(key) + ':' + stringifyAttributeValue(attributes[key]) - } - return result + '}' -} - -function stringifyAttributeValue (value) { - if (typeof value === 'string') return escapeJsonString(value) - if (typeof value === 'number') return jsonNumber(value) - if (typeof value === 'boolean') return value ? 'true' : 'false' - if (Array.isArray(value)) { - let result = '[' - for (let index = 0; index < value.length; index++) { - if (index > 0) result += ',' - result += stringifyAttributeValue(value[index]) - } - return result + ']' - } - // Sanitization rejects everything else, but keep the safety net. - return 'null' -} - -/** - * Match `JSON.stringify` for numbers: `NaN` and `±Infinity` collapse to the - * literal `null`, everything else uses ECMAScript's default `Number → String` - * conversion (which is what `JSON.stringify` calls internally). - * - * @param {number} value - * @returns {string} - */ -function jsonNumber (value) { - if (Number.isFinite(value)) return String(value) - return 'null' -} - -/** - * Fast path: scan once, and if no character in the string requires JSON - * escaping, emit `""` as-is. The scanned chars are `"`, `\`, and any - * control char in the U+0000–U+001F range. Anything else delegates to - * `JSON.stringify` for full spec-compliant escaping (surrogate pairs, - * lone surrogates, etc.). - * - * @param {string} value - * @returns {string} - */ -function escapeJsonString (value) { - for (let index = 0; index < value.length; index++) { - const code = value.charCodeAt(index) - if (code < 0x20 || code === 0x22 || code === 0x5C) { - return JSON.stringify(value) - } - } - return '"' + value + '"' -} - function lazyEncodedTraceBufferLogger (bytes, start, end) { const hex = bytes.buffer.subarray(start, end).toString('hex').match(/../g).join(' ') return `Adding encoded trace to buffer: ${hex}` @@ -239,7 +139,12 @@ class AgentEncoder { #debugEncoding #formatSpan - constructor (writer, limit = SOFT_LIMIT) { + /** + * @param {{ flush: () => void, onError?: (error: unknown) => void }} writer + * @param {number} [limit] + * @param {boolean} [nativeSpanEvents] + */ + constructor (writer, limit = SOFT_LIMIT, nativeSpanEvents) { this.#limit = limit this._traceBytes = new MsgpackChunk() this._stringBytes = new MsgpackChunk() @@ -250,7 +155,7 @@ class AgentEncoder { // Pick the per-span formatter once so the hot loop pays no per-span // config check. The native path keeps the raw `span_events` slot for // `#encodeSpanEvents`; the legacy path serializes it into meta.events. - this.#formatSpan = this.#config.DD_TRACE_NATIVE_SPAN_EVENTS + this.#formatSpan = (nativeSpanEvents ?? this.#config.DD_TRACE_NATIVE_SPAN_EVENTS) ? normalizeSpan : formatSpanWithLegacyEvents } @@ -268,7 +173,12 @@ class AgentEncoder { try { this._encode(bytes, trace) } catch (error) { - if (error.code !== 'ERR_MSGPACK_CHUNK_OVERFLOW') throw error + if (error?.code !== 'ERR_MSGPACK_CHUNK_OVERFLOW') { + if (this.#writer.onError === undefined) throw error + this.reset() + this.#writer.onError(error) + return + } // The trace, or the queued payload it joined, hit the chunk cap. // Rolling back just the in-flight trace is unsafe: the string cache // may already hold subarrays / indices pointing at bytes we'd @@ -813,7 +723,7 @@ class AgentEncoder { bytes.set(KEY_NAME) this._encodeString(bytes, event.name) bytes.set(KEY_EVENT_TIME) - bytes.writeFloat(eventTimeNano(event)) + bytes.writeLong(eventTimeNano(event)) const attributes = event.attributes if (attributes !== null && typeof attributes === 'object') { diff --git a/packages/dd-trace/src/encode/0.5.js b/packages/dd-trace/src/encode/0.5.js index f3828f0c944..f3bfcc3fdab 100644 --- a/packages/dd-trace/src/encode/0.5.js +++ b/packages/dd-trace/src/encode/0.5.js @@ -2,7 +2,8 @@ const { MAX_SIZE, OverflowError } = require('../msgpack') const { normalizeSpan } = require('./tags-processors') -const { AgentEncoder: BaseEncoder, stringifySpanEvents } = require('./0.4') +const { AgentEncoder: BaseEncoder } = require('./0.4') +const { stringifySpanEvents } = require('./span-events') const ARRAY_OF_TWO = 0x92 const ARRAY_OF_TWELVE = 0x9C diff --git a/packages/dd-trace/src/encode/agentless-json.js b/packages/dd-trace/src/encode/agentless-json.js index 37edabc72df..a39c4483c17 100644 --- a/packages/dd-trace/src/encode/agentless-json.js +++ b/packages/dd-trace/src/encode/agentless-json.js @@ -3,7 +3,7 @@ const log = require('../log') const { TOP_LEVEL_KEY } = require('../constants') const { normalizeSpan } = require('./tags-processors') -const { stringifySpanEvents } = require('./0.4') +const { stringifySpanEvents } = require('./span-events') // Soft limit for estimated payload size. Triggers an early flush to stay under intake request size limits. const SOFT_LIMIT = 8 * 1024 * 1024 // 8MB diff --git a/packages/dd-trace/src/encode/span-events.js b/packages/dd-trace/src/encode/span-events.js new file mode 100644 index 00000000000..77848abbe8d --- /dev/null +++ b/packages/dd-trace/src/encode/span-events.js @@ -0,0 +1,88 @@ +'use strict' + +const { eventTimeNano } = require('./tags-processors') + +/** + * @param {Array<{ name: unknown, startTime: number, attributes?: object }>} spanEvents + * @returns {string} + */ +function stringifySpanEvents (spanEvents) { + let result = '[' + for (let index = 0; index < spanEvents.length; index++) { + if (index > 0) result += ',' + const event = spanEvents[index] + const attributes = event.attributes + if (typeof event.name !== 'string') { + result += JSON.stringify({ name: event.name, time_unix_nano: eventTimeNano(event), attributes }) + continue + } + result += '{"name":' + escapeJsonString(event.name) + + ',"time_unix_nano":' + jsonNumber(eventTimeNano(event)) + if (attributes) { + result += ',"attributes":' + stringifyAttributes(attributes) + } + result += '}' + } + return result + ']' +} + +/** + * @param {object} attributes + * @returns {string} + */ +function stringifyAttributes (attributes) { + let result = '{' + let first = true + for (const key of Object.keys(attributes)) { + if (first) { + first = false + } else { + result += ',' + } + result += escapeJsonString(key) + ':' + stringifyAttributeValue(attributes[key]) + } + return result + '}' +} + +/** + * @param {unknown} value + * @returns {string} + */ +function stringifyAttributeValue (value) { + if (typeof value === 'string') return escapeJsonString(value) + if (typeof value === 'number') return jsonNumber(value) + if (typeof value === 'boolean') return value ? 'true' : 'false' + if (Array.isArray(value)) { + let result = '[' + for (let index = 0; index < value.length; index++) { + if (index > 0) result += ',' + result += stringifyAttributeValue(value[index]) + } + return result + ']' + } + return 'null' +} + +/** + * @param {number} value + * @returns {string} + */ +function jsonNumber (value) { + return Number.isFinite(value) ? String(value) : 'null' +} + +/** + * @param {string} value + * @returns {string} + */ +function escapeJsonString (value) { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index) + if (code < 0x20 || code === 0x22 || code === 0x5C || (code >= 0xD8_00 && code <= 0xDF_FF)) { + return JSON.stringify(value) + } + } + return '"' + value + '"' +} + +module.exports = { stringifySpanEvents } diff --git a/packages/dd-trace/src/exporters/common/limits.js b/packages/dd-trace/src/exporters/common/limits.js new file mode 100644 index 00000000000..23d97501603 --- /dev/null +++ b/packages/dd-trace/src/exporters/common/limits.js @@ -0,0 +1,5 @@ +'use strict' + +const MAX_ACTIVE_BUFFER_SIZE = 64 * 1024 * 1024 + +module.exports = { MAX_ACTIVE_BUFFER_SIZE } diff --git a/packages/dd-trace/src/exporters/common/request.js b/packages/dd-trace/src/exporters/common/request.js index dc050a45228..b028ca3e64c 100644 --- a/packages/dd-trace/src/exporters/common/request.js +++ b/packages/dd-trace/src/exporters/common/request.js @@ -13,6 +13,7 @@ const log = require('../../log') const { isLoopbackHost, parseUrl } = require('./url') const docker = require('./docker') const { httpAgent, httpsAgent } = require('./agents') +const { MAX_ACTIVE_BUFFER_SIZE } = require('./limits') const { getMaxAttempts, getRetryDelay, @@ -22,8 +23,6 @@ const { const legacyStorage = storage('legacy') -const maxActiveBufferSize = 1024 * 1024 * 64 - let activeBufferSize = 0 /** @@ -250,7 +249,7 @@ function byteLength (data) { Object.defineProperty(request, 'writable', { get () { - return activeBufferSize < maxActiveBufferSize + return activeBufferSize < MAX_ACTIVE_BUFFER_SIZE }, }) diff --git a/packages/dd-trace/src/exporters/native/index.js b/packages/dd-trace/src/exporters/native/index.js new file mode 100644 index 00000000000..4108f28d3ea --- /dev/null +++ b/packages/dd-trace/src/exporters/native/index.js @@ -0,0 +1,487 @@ +'use strict' + +const { URL, format } = require('url') + +const { channel } = require('dc-polyfill') + +const exporters = require('../../../../../ext/exporters') +const defaults = require('../../config/defaults') +const { AgentEncoder } = require('../../encode/0.4') +const log = require('../../log') +const runtimeMetrics = require('../../runtime_metrics') +const { fetchAgentInfo } = require('../../agent/info') +const { computeIntakeUrl, INTAKE_PATH } = require('../agentless/intake') +const { MAX_ACTIVE_BUFFER_SIZE } = require('../common/limits') + +const firstFlushChannel = channel('dd-trace:exporter:first-flush') + +// Native sends mirror legacy exporter request/response/error health metrics. +const METRIC_PREFIX = 'datadog.tracer.node.exporter.agent' + +// Lazy debug representation matching the legacy payload log. +/** + * @param {object[]} spans Finalized spans + * @returns {string} + */ +function formatSpansForDebug (spans) { + try { + const payload = JSON.stringify(spans, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)) + return `Queueing payload: ${payload}` + } catch { + // A pathological tag value (e.g. circular) must never throw out of export(). + return 'Queueing payload: [unserializable]' + } +} + +/** + * Encodes finalized spans and delegates transport to libdatadog. + */ +class NativeExporter { + #agentless = false + #nativeSpans + #bufferedBytes = 0 + #timer + #flushInFlight = false + #firstFlushSent = false + #flushCallbacks = [] + #encoder + #pendingPayloads = [] + #urlUpdateCallbacks = [] + // Fatal native exporter construction errors cannot recover. + #disabled = false + /** + * @param {object} config - Tracer configuration + * @param {object} prioritySampler - Priority sampler instance + * @param {import('../../native/native-spans')} nativeSpans - NativeSpansInterface instance + */ + constructor (config, prioritySampler, nativeSpans) { + this._config = config + this._prioritySampler = prioritySampler + this.#nativeSpans = nativeSpans + this.#agentless = config.experimental?.exporter === exporters.AGENTLESS + const nativeSpanEvents = this.#agentless || + config.DD_TRACE_NATIVE_SPAN_EVENTS || + config.OTEL_TRACES_EXPORTER === 'otlp' + this.#encoder = new AgentEncoder({ + flush: () => { + this.#stageEncodedPayload() + this.flush() + }, + onError: error => this.#handleEncodeError(error), + }, undefined, nativeSpanEvents) + this._writer = { flush: this.#flushWithStats.bind(this) } + + if (this.#agentless) { + this.#configureAgentless() + } else { + const { url, hostname = defaults.hostname, port } = config + this._url = url || new URL(format({ + protocol: 'http:', + hostname, + port, + })) + + // OTLP takes precedence over explicit, capability-gated v0.5 output. + if (config.OTEL_TRACES_EXPORTER === 'otlp') { + this.#configureOtlp() + } else if (config.protocolVersion === '0.5') { + this.#negotiateV05() + } + } + + // Use the shared registry to avoid per-tracer process listeners. Flush + // traces before stats because chunk preparation feeds the concentrator. + const finalFlush = () => { + this.flush(() => { + this.flushStats().catch((error) => { + log.warn('Failed final native stats flush on exit: %s', error) + }) + }) + } + const handlers = globalThis[Symbol.for('dd-trace')]?.beforeExitHandlers + if (handlers) { + handlers.add(finalFlush) + } else { + process.once('beforeExit', finalFlush) + } + } + + /** + * Apply agentless intake configuration before the first native send. + */ + #configureAgentless () { + const apiKey = this._config.DD_API_KEY + if (!apiKey) { + this.#disabled = true + this._url = undefined + log.error('DD_API_KEY is required for native agentless trace intake. Traces will not be sent.') + return + } + + try { + const url = new URL(computeIntakeUrl(this._config.site)) + const endpoint = new URL(INTAKE_PATH, url).toString() + this.#nativeSpans.setAgentlessEndpoint(endpoint, apiKey) + this._url = url + } catch (error) { + this.#disabled = true + this._url = undefined + log.error('Failed to configure native agentless trace intake: %s', error) + } + } + + /** + * Apply resolved OTLP configuration before the first native send. + */ + #configureOtlp () { + const config = this._config + const endpoint = config.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT + if (!endpoint) { + // No endpoint means the native exporter must remain on the agent path. + log.warn('Native exporter: OTEL_TRACES_EXPORTER=otlp but no OTLP traces endpoint resolved; skipping OTLP setup') + return + } + // Invalid endpoints fail loudly during native exporter construction. + this.#nativeSpans.setOtlpEndpoint(endpoint) + + const protocol = config.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL + if (protocol) { + try { + this.#nativeSpans.setOtlpProtocol(protocol) + } catch (error) { + // Unsupported protocols fall back to the native default. + log.warn('Native exporter: unsupported OTLP protocol %s, using default: %s', protocol, error.message) + } + } + + // Flatten parsed headers for the binding API. + const headers = config.OTEL_EXPORTER_OTLP_TRACES_HEADERS + if (headers && typeof headers === 'object') { + const flat = [] + for (const [key, value] of Object.entries(headers)) { + flat.push(key, String(value)) + } + if (flat.length > 0) { + this.#nativeSpans.setOtlpHeaders(flat) + } + } + } + + /** + * Enable v0.5 only when the agent advertises it before the first send. + */ + #negotiateV05 () { + let infoUrl + try { + infoUrl = typeof this._url === 'string' ? new URL(this._url) : this._url + } catch (error) { + log.warn('Native exporter: cannot parse agent URL for /info v0.5 check: %s', error.message) + return + } + fetchAgentInfo(infoUrl, (error, info) => { + if (error) { + log.debug('Native exporter: /info fetch failed, staying on v0.4: %s', error.message) + return + } + // `endpoints` is untrusted agent input: guard the type so a malformed + // response (non-array, or a string that substring-matches) can't throw + // in this async callback or false-positive into v0.5. + if (Array.isArray(info?.endpoints) && info.endpoints.includes('/v0.5/traces')) { + this.#nativeSpans.setUseV05(true) + } + }) + } + + #finishUrlUpdateCallbacks () { + if (this.#urlUpdateCallbacks.length === 0) return + if (this.#flushInFlight) return + if (this.#hasPendingWork()) { + this.flush() + return + } + + const callbacks = this.#urlUpdateCallbacks + this.#urlUpdateCallbacks = [] + for (const callback of callbacks) { + callback() + } + } + + /** + * Update the agent URL. + * @param {string|URL} url - New agent URL + */ + setUrl (url) { + if (this.#disabled) return + + let parsed + try { + parsed = new URL(url) + } catch (error) { + log.warn('Failed to parse new agent URL %s: %s', url, error.message) + return + } + + const applyUrl = () => { + try { + if (this.#agentless) { + const endpoint = new URL(INTAKE_PATH, parsed).toString() + this.#nativeSpans.setAgentlessEndpoint(endpoint, this._config.DD_API_KEY) + } else { + this.#nativeSpans.setAgentUrl(parsed.toString()) + } + // Only commit `_url` after native state replacement succeeds. Otherwise + // JS and WASM would report different active destinations. + this._url = parsed + } catch (error) { + log.warn('Failed to apply new native export URL %s: %s', url, error.message) + } + } + + this.#urlUpdateCallbacks.push(applyUrl) + this.#finishUrlUpdateCallbacks() + } + + /** + * Encode one finalized trace chunk. + * @param {Array} spans Finalized spans to export + */ + export (spans) { + if (this.#disabled || spans.length === 0) return + + log.debug(formatSpansForDebug, spans) + + this.#encoder.encode(spans) + + const { flushInterval } = this._config + if (flushInterval === 0) { + this.#stageEncodedPayload() + this.flush() + return + } + + if (this.#timer === undefined && this.#encoder.count() > 0) { + this.#timer = setTimeout(() => { + this.flush() + this.#timer = undefined + }, flushInterval) + this.#timer.unref?.() + } + } + + /** + * Compatibility surface for tooling that calls `_writer.flush(cb)`. Native + * stats must flush after traces so recently prepared chunks are included. + * @param {Function} [done] Callback when both flushes complete + */ + #flushWithStats (done = () => {}) { + this.flush(() => { + this.flushStats().then(() => done(), (error) => { + log.error('Error force-flushing native stats via _writer.flush: %s', error) + done() + }) + }) + } + + /** + * Force-flush native stats after an explicit trace flush. + * @returns {Promise} + */ + flushStats () { + return this.#nativeSpans.flushStats() + } + + #finishFlushCallbacks () { + const callbacks = this.#flushCallbacks + this.#flushCallbacks = [] + let firstError + let hasError = false + for (const done of callbacks) { + try { + done() + } catch (error) { + if (!hasError) { + firstError = error + hasError = true + } + } + } + if (hasError) { + setImmediate(() => { throw firstError }) + } + } + + #finishSend () { + if (!this.#hasPendingWork()) { + this.#finishFlushCallbacks() + this.#finishUrlUpdateCallbacks() + return + } + + // Explicit and elapsed flushes clear the timer. Ordinary traffic keeps its + // existing timer so a send completion does not bypass the batching window. + if (this.#timer === undefined) this.flush() + } + + /** + * @param {unknown} error Export error + */ + #recordError (error) { + const name = error?.name ?? 'Error' + const code = error?.code + runtimeMetrics.increment(`${METRIC_PREFIX}.errors`, true) + runtimeMetrics.increment(`${METRIC_PREFIX}.errors.by.name`, `name:${name}`, true) + if (code) { + runtimeMetrics.increment(`${METRIC_PREFIX}.errors.by.code`, `code:${code}`, true) + } + } + + /** + * @param {unknown} error Encoding error + */ + #handleEncodeError (error) { + this.#recordError(error) + log.error('Error encoding spans for native export: %s', error) + } + + /** + * @param {Error & { code?: string }} error Native send error + * @param {number} payloadBytes Size charged to the export buffer + */ + #handleSendError (error, payloadBytes) { + this.#bufferedBytes -= payloadBytes + this.#flushInFlight = false + this.#recordError(error) + log.error('Error sending spans via native exporter: %s', error) + // Stop after a one-shot native exporter build failure. + if (error?.name === 'NativeExporterBuildError') { + this.#disabled = true + this.#encoder.reset() + this.#pendingPayloads = [] + this.#urlUpdateCallbacks = [] + this.#bufferedBytes = 0 + clearTimeout(this.#timer) + this.#timer = undefined + log.error('Native exporter disabled after a fatal build error; no further spans will be sent') + this.#finishFlushCallbacks() + return + } + // Transient failures still drain work queued during the failed send. + this.#finishSend() + } + + /** + * Flush pending spans to the configured destination. + * + * @param {Function} [done] - Callback when flush completes + */ + flush (done) { + if (done) this.#flushCallbacks.push(done) + + if (this.#disabled) { + this.#finishFlushCallbacks() + return + } + clearTimeout(this.#timer) + this.#timer = undefined + + // Explicit flush callbacks wait until the exporter is idle. + if (this.#flushInFlight) { + return + } + + if (!this.#hasPendingWork()) { + this.#finishFlushCallbacks() + return + } + + if (this.#pendingPayloads.length === 0) { + try { + this.#stageEncodedPayload() + } catch (error) { + this.#encoder.reset() + this.#handleEncodeError(error) + this.#finishFlushCallbacks() + this.#finishUrlUpdateCallbacks() + return + } + } + + const payload = this.#pendingPayloads.shift() + + // Serialize preparation and sends because libdatadog allows only one + // prepared-send transaction at a time. + runtimeMetrics.increment(`${METRIC_PREFIX}.requests`, true) + // Publish when a send is attempted, matching the legacy AgentWriter. This + // must also fire when the agent is unreachable. + if (!this.#firstFlushSent && firstFlushChannel.hasSubscribers) { + this.#firstFlushSent = true + firstFlushChannel.publish() + } + let send + try { + send = this.#nativeSpans.sendEncodedTraces(payload) + } catch (error) { + this.#handleSendError(error, payload.length) + return + } + this.#flushInFlight = true + send + .then((response) => { + this.#bufferedBytes -= payload.length + this.#updateSamplingRates(response) + this.#flushInFlight = false + runtimeMetrics.increment(`${METRIC_PREFIX}.responses`, true) + // Flush callbacks wait until the exporter is idle so explicit flush + // endpoints only acknowledge once all queued sends have reached the destination. + this.#finishSend() + }, (error) => { + this.#handleSendError(error, payload.length) + }) + } + + /** + * Stage the encoder's current payload for an asynchronous send. + */ + #stageEncodedPayload () { + if (this.#encoder.count() > 0) { + const payload = this.#encoder.makePayload() + if (this.#bufferedBytes + payload.length > MAX_ACTIVE_BUFFER_SIZE) { + log.debug('Maximum native export buffer size reached: payload is discarded') + return + } + this.#bufferedBytes += payload.length + this.#pendingPayloads.push(payload) + } + } + + /** + * @returns {boolean} Whether encoded trace data is waiting to be sent + */ + #hasPendingWork () { + return this.#pendingPayloads.length > 0 || this.#encoder.count() > 0 + } + + /** + * Apply `rate_by_service` from a native response. `unchanged`, empty, and + * malformed responses leave the current sampler state intact. + * @param {string} response Native send response body + */ + #updateSamplingRates (response) { + // No body to parse: rates unchanged, or nothing was sent this cycle. + if (!response || response === 'unchanged' || response === 'no spans to flush') { + return + } + + try { + const { rate_by_service: rateByService } = JSON.parse(response) + if (rateByService) { + this._prioritySampler.update(rateByService) + } + } catch (error) { + log.error('Error updating priority sampler rates from native response: %s', error) + } + } +} + +module.exports = NativeExporter diff --git a/packages/dd-trace/src/msgpack/index.js b/packages/dd-trace/src/msgpack/index.js index 96c7d946a16..f5ae5fc1017 100644 --- a/packages/dd-trace/src/msgpack/index.js +++ b/packages/dd-trace/src/msgpack/index.js @@ -87,7 +87,12 @@ function writeArray (bytes, value) { * @param {Record} value */ function writeMap (bytes, value) { - const keys = Object.keys(value) + // Skip keys whose value is `undefined`: msgpack has no `undefined`, and + // encoding it as `null` would diverge from the legacy v0.4 encoder (which + // omits such keys) and from JSON semantics. This matters for meta_struct + // payloads such as AppSec's truncated request body, where a dropped child is + // left as an `undefined`-valued key. + const keys = Object.keys(value).filter(key => value[key] !== undefined) bytes.writeMapPrefix(keys.length) diff --git a/packages/dd-trace/src/native/index.js b/packages/dd-trace/src/native/index.js new file mode 100644 index 00000000000..f6d8f8f4029 --- /dev/null +++ b/packages/dd-trace/src/native/index.js @@ -0,0 +1,117 @@ +'use strict' + +/** + * Libdatadog pipeline module loader. + * + * Loading is deferred until the native exporter is selected so package managers + * can omit the optional dependency in constrained installs. Loader failures are + * surfaced to the caller, which distinguishes an omitted dependency from corruption. + */ + +const { storage } = require('../../../datadog-core') + +let NativeSpansInterfaceModule + +// Flag to track if we're currently loading a module to prevent recursion +let isLoading = false + +let pipeline + +const CONTAINER_TAGS_HASH_HEADER = 'datadog-container-tags-hash' + +/** + * Pull `Datadog-Container-Tags-Hash` out of an agent response and hand it to the + * propagation hash, mirroring `exporters/agent/writer.js`. + * + * libdatadog's transport passes Node's `res.rawHeaders`: a flat + * `[name, value, name, value, ...]` array that preserves the sender's casing and + * repeats a header as another pair. Walk the name slots and take the first + * match. The transport wraps this call in its own try/catch, but there is + * nothing here that can throw on a well-formed array. + * + * @param {unknown} rawHeaders + */ +function observeResponseHeaders (rawHeaders) { + if (!Array.isArray(rawHeaders)) return + for (let i = 0; i + 1 < rawHeaders.length; i += 2) { + if (String(rawHeaders[i]).toLowerCase() !== CONTAINER_TAGS_HASH_HEADER) continue + const hash = rawHeaders[i + 1] + if (hash) require('../propagation-hash').updateContainerTagsHash(hash) + return + } +} + +function getPipeline () { + if (pipeline) return pipeline + const libdatadog = require('@datadog/libdatadog') + const loadedPipeline = libdatadog.load('pipeline') + if (loadedPipeline?.WasmSpanState == null) { + throw new Error('@datadog/libdatadog pipeline crate is missing WasmSpanState; install may be corrupt') + } + loadedPipeline.init() + const legacyStorage = storage('legacy') + // Provide libdatadog with a `run(callback)` hook that executes the callback + // in a noop async context, so internal HTTP/IO done by the native exporter + // doesn't get re-instrumented by our http/fs plugins. + loadedPipeline.setStorage(legacyStorage.run.bind(legacyStorage, { noop: true })) + // The agent returns `Datadog-Container-Tags-Hash` whenever the request carried + // a container id. The legacy writer feeds it to the propagation hash so DBM SQL + // comments and DSM pathway hashes correlate with container tags; without this + // the libdatadog transport keeps hashing process tags alone. Registered on the module + // (not the state), so it survives the `setAgentUrl` state rebuild. + loadedPipeline.setResponseHeaderObserver(observeResponseHeaders) + pipeline = loadedPipeline + return loadedPipeline +} + +/** + * Helper to load a module while preventing fs instrumentation recursion. + * During module loading, we set noop: true to prevent fs plugin from + * triggering, which would try to create spans, which would try to load + * this module again. + * @param {() => typeof import('./native-spans')} loader Module loader + */ +function loadWithNoop (loader) { + if (isLoading) { + throw new Error('Recursive native module load detected') + } + isLoading = true + const legacy = storage('legacy') + const oldStore = legacy.getStore() + try { + legacy.enterWith({ noop: true }) + return loader() + } finally { + legacy.enterWith(oldStore) + isLoading = false + } +} + +module.exports = { + /** + * The pipeline contract exposed by the installed binding without loading its addon. + * @type {number} + */ + get pipelineApiVersion () { + return require('@datadog/libdatadog').pipelineApiVersion ?? 0 + }, + + /** + * The WasmSpanState class from the pipeline crate. + * @type {typeof import('@datadog/libdatadog').WasmSpanState} + */ + get WasmSpanState () { + return getPipeline().WasmSpanState + }, + + /** + * The NativeSpansInterface class for managing libdatadog export state. + * @type {typeof import('./native-spans')} + */ + get NativeSpansInterface () { + if (!NativeSpansInterfaceModule) { + NativeSpansInterfaceModule = loadWithNoop(() => require('./native-spans')) + } + return NativeSpansInterfaceModule + }, +} diff --git a/packages/dd-trace/src/native/native-spans.js b/packages/dd-trace/src/native/native-spans.js new file mode 100644 index 00000000000..ea88de5b574 --- /dev/null +++ b/packages/dd-trace/src/native/native-spans.js @@ -0,0 +1,307 @@ +'use strict' + +const log = require('../log') +const runtimeMetrics = require('../runtime_metrics') +const { WasmSpanState } = require('./index') + +const COLLAPSED_SPANS_HEALTH_METRIC = 'datadog.tracer.stats.collapsed_spans' +const COLLAPSED_SPANS_WHOLE_KEY_TAG = 'collapsed_spans:whole_key' + +// The encoded pipeline does not use change-buffer storage. WasmSpanState still +// requires buffers for compatibility with its original constructor. +const CHANGE_QUEUE_BUFFER_SIZE = 8 +const STRING_TABLE_INPUT_BUFFER_SIZE = 0 + +/** + * Convert the legacy `unix://./pipe/...` Windows-pipe form to libdatadog's + * `windows:` scheme. Unix sockets and HTTP URLs pass through unchanged. + * @param {string} url Agent URL + * @returns {string} URL accepted by libdatadog + */ +function normalizeAgentUrl (url) { + if (typeof url === 'string' && url.startsWith('unix://./')) { + return 'windows:' + url.slice('unix:'.length) + } + return url +} + +/** + * @param {unknown} result Native stats flush result + * @returns {boolean} Whether a stats payload was sent + */ +function normalizeStatsFlushResult (result) { + if (result == null || typeof result !== 'object') return result === true + + const collapsedSpans = result.collapsedSpans + if (typeof collapsedSpans === 'number' && collapsedSpans > 0) { + runtimeMetrics.count(COLLAPSED_SPANS_HEALTH_METRIC, collapsedSpans, COLLAPSED_SPANS_WHOLE_KEY_TAG, true) + } + + return result.sent === true +} + +/** + * Configures libdatadog and transfers finalized trace payloads to WASM. + */ +class NativeSpansInterface { + #agentUrl + #agentlessApiKey + #agentlessEndpoint + #operations = new Map() + #options + #otlpEndpoint + #otlpHeaders + #otlpProtocol + #retiredStates = new Set() + #state + #statsInterval + #useV05 = false + + /** + * @param {object} options Configuration options + * @param {string} options.agentUrl URL of the Datadog agent + * @param {string} options.tracerVersion Version of dd-trace + * @param {string} [options.lang] Language identifier + * @param {string} [options.langVersion] Language version + * @param {string} [options.langInterpreter] Language interpreter + * @param {number} [options.pid] Process ID + * @param {string} options.tracerService Default service name + * @param {boolean} [options.statsEnabled] Enable native stats collection + * @param {string} [options.hostname] Hostname for stats payloads + * @param {string} [options.env] Environment for stats payloads + * @param {string} [options.appVersion] Application version for stats payloads + * @param {string} [options.runtimeId] Runtime ID for stats payloads + * @param {boolean} [options.clientComputedStats] Advertise client-computed stats + */ + constructor (options) { + if (!WasmSpanState) { + throw new Error('Native spans module is not available') + } + + this.#options = { + tracerVersion: options.tracerVersion, + lang: options.lang || 'nodejs', + langVersion: options.langVersion || process.version, + langInterpreter: options.langInterpreter || 'v8', + pid: options.pid ?? process.pid, + tracerService: options.tracerService, + statsEnabled: options.statsEnabled || false, + hostname: options.hostname || '', + env: options.env || '', + appVersion: options.appVersion || '', + runtimeId: options.runtimeId || '', + clientComputedStats: options.clientComputedStats || false, + } + this.#agentUrl = options.agentUrl + this.#state = this.#createWasmState(this.#agentUrl) + + if (typeof this.#state.sendEncodedTraces !== 'function') { + this.#state.free() + throw new Error('@datadog/libdatadog pipeline is missing sendEncodedTraces; install may be outdated') + } + + if (this.#options.statsEnabled) { + this.#statsInterval = setInterval(() => { + this.#flushStats(false).catch((error) => { + log.error('Error flushing native stats: %s', error) + }) + }, 10_000) + this.#statsInterval.unref?.() + } + + log.debug('Native spans interface initialized') + } + + /** + * Keep a native state alive until one of its asynchronous operations settles. + * @param {object} state Native state used by the operation + * @param {Promise} operation Native operation + * @returns {Promise} The tracked operation + */ + #trackOperation (state, operation) { + this.#operations.set(state, (this.#operations.get(state) ?? 0) + 1) + const settled = () => { + const remaining = this.#operations.get(state) - 1 + if (remaining === 0) { + this.#operations.delete(state) + if (this.#retiredStates.delete(state)) state.free() + } else { + this.#operations.set(state, remaining) + } + } + operation.then(settled, settled) + return operation + } + + /** + * Free a superseded state after its asynchronous work completes. + * @param {object} state Superseded native state + */ + #releaseState (state) { + if (this.#operations.has(state)) { + this.#retiredStates.add(state) + } else { + state.free() + } + } + + /** + * Construct and configure a native state through the binding's positional API. + * @param {string} url Agent URL + * @returns {WasmSpanState} Configured native state + */ + #createWasmState (url) { + const options = this.#options + const state = new WasmSpanState( + normalizeAgentUrl(url), + options.tracerVersion, + options.lang, + options.langVersion, + options.langInterpreter, + CHANGE_QUEUE_BUFFER_SIZE, + STRING_TABLE_INPUT_BUFFER_SIZE, + options.pid, + options.tracerService, + options.statsEnabled, + options.hostname, + options.env, + options.appVersion, + options.runtimeId, + options.clientComputedStats, + ) + + try { + if (this.#useV05) state.setUseV05(true) + if (this.#agentlessEndpoint !== undefined) { + state.setAgentlessEndpoint(this.#agentlessEndpoint, this.#agentlessApiKey) + } + if (this.#otlpEndpoint !== undefined) { + state.setOtlpEndpoint(this.#otlpEndpoint) + if (this.#otlpProtocol !== undefined) state.setOtlpProtocol(this.#otlpProtocol) + if (this.#otlpHeaders !== undefined) state.setOtlpHeaders(this.#otlpHeaders) + } + return state + } catch (error) { + state.free() + throw error + } + } + + /** + * Select v0.5 before the first send after agent capability negotiation. + * @param {boolean} useV05 Whether to use v0.5 + */ + setUseV05 (useV05) { + this.#state.setUseV05(useV05) + this.#useV05 = useV05 + } + + /** + * Select agentless trace export before the first send or replace its intake endpoint. + * @param {string} endpoint Complete agentless trace intake URL + * @param {string} apiKey Datadog API key + */ + setAgentlessEndpoint (endpoint, apiKey) { + if (this.#agentlessEndpoint === undefined) { + this.#state.setAgentlessEndpoint(endpoint, apiKey) + this.#agentlessEndpoint = endpoint + this.#agentlessApiKey = apiKey + return + } + + const previousEndpoint = this.#agentlessEndpoint + const previousApiKey = this.#agentlessApiKey + this.#agentlessEndpoint = endpoint + this.#agentlessApiKey = apiKey + + let state + try { + state = this.#createWasmState(this.#agentUrl) + } catch (error) { + this.#agentlessEndpoint = previousEndpoint + this.#agentlessApiKey = previousApiKey + throw error + } + + const oldState = this.#state + this.#state = state + this.#releaseState(oldState) + } + + /** + * Select OTLP trace export before the first send. + * @param {string} url OTLP HTTP traces endpoint + */ + setOtlpEndpoint (url) { + this.#state.setOtlpEndpoint(url) + this.#otlpEndpoint = url + } + + /** + * Select the native OTLP wire protocol. + * @param {string} protocol OTLP wire protocol + */ + setOtlpProtocol (protocol) { + this.#state.setOtlpProtocol(protocol) + this.#otlpProtocol = protocol + } + + /** + * Set extra OTLP export headers. + * @param {string[]} headers Flat key/value pairs + */ + setOtlpHeaders (headers) { + this.#state.setOtlpHeaders(headers) + this.#otlpHeaders = [...headers] + } + + /** + * Rebuild native state for a new agent URL. + * @param {string} url New agent URL + */ + setAgentUrl (url) { + const state = this.#createWasmState(url) + const oldState = this.#state + this.#agentUrl = url + this.#state = state + this.#releaseState(oldState) + log.debug('Native spans interface reinitialized with new URL: %s', url) + } + + /** + * Transfer and send an encoded v0.4 trace payload. + * @param {Uint8Array} payload Encoded trace chunks + * @returns {Promise} Native response body + */ + sendEncodedTraces (payload) { + const state = this.#state + return this.#trackOperation(state, state.sendEncodedTraces(payload)) + } + + /** + * Keep the stats API asynchronous even when the WASM boundary throws. + * @param {boolean} force Whether to include partial buckets + * @returns {Promise} Whether a stats payload was sent + */ + #flushStats (force) { + const state = this.#state + let operation + try { + operation = state.flushStats(force) + } catch (error) { + return Promise.reject(error) + } + return this.#trackOperation(state, operation).then(normalizeStatsFlushResult) + } + + /** + * Force-flush native stats, including partial buckets. + * @returns {Promise} Whether a stats payload was sent + */ + flushStats () { + if (!this.#options.statsEnabled) return Promise.resolve(true) + return this.#flushStats(true) + } +} + +module.exports = NativeSpansInterface diff --git a/packages/dd-trace/src/opentelemetry/span-helpers.js b/packages/dd-trace/src/opentelemetry/span-helpers.js index 286df5e966a..153eb621a62 100644 --- a/packages/dd-trace/src/opentelemetry/span-helpers.js +++ b/packages/dd-trace/src/opentelemetry/span-helpers.js @@ -254,8 +254,8 @@ function recordException (ddSpan, exception, timeInput, otelTraceSemanticsEnable /** * Applies OTel `setStatus({ code, message })` per spec: UNSET / missing is a no-op, OK is - * final, ERROR is replaceable. Only ERROR writes tags; the returned code is the one the - * caller must store for the next call. + * final, ERROR is replaceable. ERROR writes tags; OK clears a previous ERROR and writes + * `error=0` so the native path can replace an earlier SetError(1). * * @param {import('../opentracing/span')} ddSpan * @param {number} currentCode 0 = UNSET, 1 = OK, 2 = ERROR. @@ -267,14 +267,22 @@ function applyOtelStatus (ddSpan, currentCode, status, otelTraceSemanticsEnabled if (!isWritable(ddSpan)) return currentCode const code = status?.code - if (!code || currentCode === 1) { - if (otelTraceSemanticsEnabled) { - ddSpan.context().deleteTag(ERROR_MESSAGE) - ddSpan.context().deleteTag(IGNORE_OTEL_ERROR) - } + if (!code) return currentCode + + if (currentCode === 1) { return currentCode } + if (code === 1) { + if (currentCode === 2) { + const context = ddSpan.context() + context.deleteTag(ERROR_MESSAGE) + context.deleteTag(IGNORE_OTEL_ERROR) + ddSpan.setTag('error', 0) + } + return 1 + } + if (code === 2) { ddSpan.addTags({ [ERROR_MESSAGE]: status.message, diff --git a/packages/dd-trace/src/opentelemetry/span.js b/packages/dd-trace/src/opentelemetry/span.js index f4a84d026bc..4e644c4de0a 100644 --- a/packages/dd-trace/src/opentelemetry/span.js +++ b/packages/dd-trace/src/opentelemetry/span.js @@ -150,7 +150,7 @@ class Span extends BridgeSpanBase { const hrStartTime = timeInputToHrTime(timeInput || (performance.now() + timeOrigin)) const startTime = hrTimeToMilliseconds(hrStartTime) - const ddSpan = new DatadogSpan(_tracer, _tracer._processor, _tracer._prioritySampler, { + const spanFields = { operationName: spanNameMapper(spanName, kind, attributes), context: spanContext._ddContext, startTime, @@ -167,7 +167,15 @@ class Span extends BridgeSpanBase { [SPAN_KIND]: spanKindNames[kind], }, links, - }, _tracer._debug) + } + + const ddSpan = new DatadogSpan( + _tracer, + _tracer._processor, + _tracer._prioritySampler, + spanFields, + _tracer._debug + ) super(ddSpan) diff --git a/packages/dd-trace/src/opentelemetry/tracer_provider.js b/packages/dd-trace/src/opentelemetry/tracer_provider.js index 910c464cc84..98f65cc3d0f 100644 --- a/packages/dd-trace/src/opentelemetry/tracer_provider.js +++ b/packages/dd-trace/src/opentelemetry/tracer_provider.js @@ -84,7 +84,10 @@ class TracerProvider { return Promise.reject(new Error('Not started')) } - exporter._writer?.flush() + // The Lambda stdout exporter writes synchronously and defines no `flush`, so + // an unguarded call turns `forceFlush()` into a TypeError there and the active + // span processor never gets flushed either. + exporter.flush?.() return this.#activeProcessor.forceFlush() } diff --git a/packages/dd-trace/src/opentracing/tracer.js b/packages/dd-trace/src/opentracing/tracer.js index d2e9036dedb..178cccc27ad 100644 --- a/packages/dd-trace/src/opentracing/tracer.js +++ b/packages/dd-trace/src/opentracing/tracer.js @@ -1,12 +1,18 @@ 'use strict' const os = require('os') +const { URL, format } = require('url') const SpanProcessor = require('../span_processor') +const getExporter = require('../exporter') +const exporters = require('../../../../ext/exporters') const PrioritySampler = require('../priority_sampler') const formats = require('../../../../ext/formats') const log = require('../log') const runtimeMetrics = require('../runtime_metrics') -const getExporter = require('../exporter') +const NativeExporter = require('../exporters/native') +const defaults = require('../config/defaults') +const { getIsAWSLambda } = require('../serverless') +const pkg = require('../../../../package.json') const Span = require('./span') const TextMapPropagator = require('./propagation/text_map') const DSMTextMapPropagator = require('./propagation/text_map_dsm') @@ -16,8 +22,32 @@ const LogPropagator = require('./propagation/log') const SpanContext = require('./span_context') +// Lazy-loaded so libdatadog initialization is only paid when its exporter is selected. +// A corrupt install still fails hard; an omitted optional dependency can fall back. +let nativeModule +function getNativeModule () { + if (nativeModule === undefined) { + nativeModule = require('../native') + } + return nativeModule +} + +// An omitted or outdated binding and runtimes without WebAssembly can use the +// JS exporter. Other loader failures indicate a corrupt install and still fail hard. +function isNativeUnavailable (error) { + if (typeof WebAssembly === 'undefined') return true + if (error?.code === NATIVE_PIPELINE_UNAVAILABLE) return true + if (error?.code === NATIVE_AGENTLESS_UNAVAILABLE) return true + return error?.code === 'MODULE_NOT_FOUND' && + /^Cannot find module ['"]@datadog\/libdatadog['"]/.test(String(error.message)) +} + const REFERENCE_CHILD_OF = 'child_of' const REFERENCE_FOLLOWS_FROM = 'follows_from' +const PIPELINE_API_VERSION = 1 +const NATIVE_PIPELINE_UNAVAILABLE = 'DD_NATIVE_PIPELINE_UNAVAILABLE' +const NATIVE_AGENTLESS_UNAVAILABLE = 'DD_NATIVE_AGENTLESS_UNAVAILABLE' +const JS_ONLY_EXPORTERS = new Set([exporters.ELECTRON, exporters.LOG]) class DatadogTracer { constructor (config, prioritySampler) { @@ -30,33 +60,197 @@ class DatadogTracer { this._logInjection = config.logInjection this._debug = config.debug this._prioritySampler = prioritySampler ?? new PrioritySampler(config.env, config.sampler) + this._enableGetRumData = config.experimental.enableGetRumData + this._traceId128BitGenerationEnabled = config.traceId128BitGenerationEnabled - // OTEL_TRACES_EXPORTER=otlp should not replace the Test Optimization - // exporter when the tracer is running in Test Optimization mode. Test spans - // (test_session/test_module/ test_suite/test) belong on the citestcycle - // endpoint, not on an OTLP traces endpoint — otherwise users with OTEL_* - // vars set in their environment (e.g. for a separate telemetry integration) - // silently lose all test spans. The same applies to the Electron exporter: - // spans must reach the Electron SDK's IPC bridge, not an OTLP endpoint, - // even when OTEL_* vars are set for unrelated telemetry. - if (config.OTEL_TRACES_EXPORTER === 'otlp' && !config.isCiVisibility && - config.experimental.exporter !== 'electron') { - const { createOtlpTraceExporter } = require('../opentelemetry/trace') - this._exporter = createOtlpTraceExporter(config) - } else { - const Exporter = getExporter(config.experimental.exporter) - this._exporter = new Exporter(config, this._prioritySampler) + // Exporters that consume JS-formatted spans stay on the JS exporter pipeline. Lambda + // also uses it unless native-only OTLP trace export was requested. + const configuredExporter = config.experimental?.exporter + const useAgentlessExporter = configuredExporter === exporters.AGENTLESS + const useOtlpExporter = config.OTEL_TRACES_EXPORTER === 'otlp' + const useConfiguredJsExporter = JS_ONLY_EXPORTERS.has(configuredExporter) + const useLambdaJsPipeline = getIsAWSLambda() && + !config.isCiVisibility && + !useConfiguredJsExporter && + !useAgentlessExporter && + !useOtlpExporter + // A custom DNS `lookup` cannot be honoured by the native exporter. libdatadog's + // shipped transport builds its own `http.request` options and exposes no hook + // for them (only `setStorage` and the response-header observer), so the + // callback would be silently dropped and every payload would go wherever the + // system resolver points. Anyone setting `lookup` is resolving the agent + // through custom service discovery, so ignoring it is worse than not using + // the native exporter: use the JS agent exporter, which threads `lookup` into + // every agent request (exporters/agent/writer.js). + // + // Ask config where the value came from rather than comparing it to + // `dns.lookup`: the dns plugin wraps `dns.lookup` in-place, so an identity + // check reports "custom" for every default install once that instrumentation + // is active. A config without `getOrigin` (plain object in tests) is treated + // as the default, which keeps the native exporter. + // + // Configured JS exporters do not use the native transport. + // + // OTLP is excluded for a harder reason: OTLP export lives in libdatadog, so + // the JS exporter cannot do it at all. Routing there would quietly ship every + // span to the agent instead of the configured collector, which is a worse + // failure than resolving the collector with the system resolver. OTLP keeps + // precedence exactly as it does for the Lambda pipeline above, and the + // unhonoured `lookup` is announced rather than dropped in silence. + const lookupOrigin = typeof config.getOrigin === 'function' ? config.getOrigin('lookup') : 'default' + const hasCustomLookup = typeof config.lookup === 'function' && lookupOrigin !== 'default' + if (hasCustomLookup && useOtlpExporter) { + log.warn('OTLP trace export cannot honour a custom `lookup`; resolving the collector with the system resolver') } + const useCustomLookup = hasCustomLookup && + !config.isCiVisibility && + !useConfiguredJsExporter && + !useAgentlessExporter && + !useOtlpExporter + const unsupportedApmExporter = configuredExporter && + configuredExporter !== exporters.AGENT && + !useConfiguredJsExporter && + !useAgentlessExporter && + !useLambdaJsPipeline && + !config.isCiVisibility + // Built once for every exporter pipeline. Config forces + // DD_TRACE_STATS_COMPUTATION_ENABLED when it is enabled, so a branch that + // omits it silently ships v0.6 client stats to the agent instead. let otlpStatsExporter if (config.OTEL_TRACES_SPAN_METRICS_ENABLED) { const { createOtlpSpanStatsExporter } = require('../opentelemetry/metrics') otlpStatsExporter = createOtlpSpanStatsExporter(config) } - this._processor = new SpanProcessor(this._exporter, this._prioritySampler, config, otlpStatsExporter) - this._url = this._exporter._url - this._enableGetRumData = config.experimental.enableGetRumData - this._traceId128BitGenerationEnabled = config.traceId128BitGenerationEnabled + + if (config.isCiVisibility || useConfiguredJsExporter || useLambdaJsPipeline || useCustomLookup) { + this._isCiVisibility = config.isCiVisibility === true + const Exporter = getExporter(configuredExporter) + this._exporter = new Exporter(config, this._prioritySampler) + this._processor = new SpanProcessor(this._exporter, this._prioritySampler, config, otlpStatsExporter) + this._url = this._exporter._url + + let message = 'Custom DNS lookup configured (JS span pipeline)' + if (useConfiguredJsExporter) { + message = 'Configured "%s" exporter enabled (JS span pipeline)' + } else if (useLambdaJsPipeline) { + message = 'AWS Lambda environment detected (JS span pipeline)' + } else if (config.isCiVisibility) { + message = 'CI Visibility mode enabled (JS span pipeline)' + } + log.debug(message, configuredExporter) + } else { + if (unsupportedApmExporter) { + log.warn( + 'Native exporter ignores unsupported experimental exporter "%s"; using native agent exporter', + configuredExporter + ) + } + let useNativeExporter = true + let NativeSpansInterface + try { + const native = getNativeModule() + if (native.pipelineApiVersion < PIPELINE_API_VERSION) { + throw Object.assign(new Error('Installed libdatadog predates encoded trace export'), { + code: NATIVE_PIPELINE_UNAVAILABLE, + }) + } + const statePrototype = native.WasmSpanState?.prototype + if (typeof statePrototype?.sendEncodedTraces !== 'function') { + throw Object.assign(new Error('Installed libdatadog does not support encoded trace export'), { + code: NATIVE_PIPELINE_UNAVAILABLE, + }) + } + if (useAgentlessExporter && typeof statePrototype.setAgentlessEndpoint !== 'function') { + throw Object.assign(new Error('Installed libdatadog does not support native agentless export'), { + code: NATIVE_AGENTLESS_UNAVAILABLE, + }) + } + NativeSpansInterface = native.NativeSpansInterface + } catch (error) { + if (isNativeUnavailable(error)) { + let reason = 'optional dependency @datadog/libdatadog is not installed' + if (typeof WebAssembly === 'undefined') { + reason = 'this runtime has no WebAssembly support' + } else if (error?.code === NATIVE_PIPELINE_UNAVAILABLE) { + reason = 'the installed @datadog/libdatadog does not support encoded trace export' + } else if (error?.code === NATIVE_AGENTLESS_UNAVAILABLE) { + reason = 'the installed @datadog/libdatadog does not support agentless export' + } + const useJsOtlpExporter = useOtlpExporter && !useAgentlessExporter + useNativeExporter = false + this._isCiVisibility = false + if (useJsOtlpExporter) { + const { createOtlpTraceExporter } = require('../opentelemetry/trace') + this._exporter = createOtlpTraceExporter(config) + } else { + const Exporter = getExporter(configuredExporter) + this._exporter = new Exporter(config, this._prioritySampler) + } + this._processor = new SpanProcessor( + this._exporter, + this._prioritySampler, + config, + otlpStatsExporter + ) + this._url = this._exporter._url + log.warn('Native exporter unavailable because %s; using JS exporter pipeline', reason) + } else { + throw error + } + } + + if (useNativeExporter) { + const { url, hostname = defaults.hostname, port } = config + const nativeStatsEnabled = config.stats?.DD_TRACE_STATS_COMPUTATION_ENABLED === true && + !config.OTEL_TRACES_SPAN_METRICS_ENABLED && + !useAgentlessExporter + const agentUrl = url || new URL(format({ + protocol: 'http:', + hostname, + port, + })) + + const nativeSpans = new NativeSpansInterface({ + agentUrl: agentUrl.toString(), + tracerVersion: pkg.version, + lang: 'nodejs', + langVersion: process.version, + // Bun runs on JavaScriptCore; match the legacy agent writer's + // Datadog-Meta-Lang-Interpreter (process.versions.bun ? 'JavaScriptCore' : 'v8'). + langInterpreter: process.versions.bun ? 'JavaScriptCore' : (process.jsEngine || 'v8'), + pid: process.pid, + tracerService: config.service, + // Native v0.6 client stats and OTLP trace metrics are mutually exclusive + // (system-tests FR02): when OTLP trace metrics are enabled, config forces + // DD_TRACE_STATS_COMPUTATION_ENABLED=true so the OTLP stats exporter runs, + // but the native concentrator must NOT also ship v0.6 stats. Route stats + // to OTLP only in that case by leaving the native concentrator disabled. + statsEnabled: nativeStatsEnabled, + hostname: config.hostname || os.hostname(), + env: config.env || '', + appVersion: config.version || '', + runtimeId: config.tags?.['runtime-id'] || '', + // Advertise Datadog-Client-Computed-Stats when we compute stats + // client-side or run in APM-standalone (apmTracingEnabled=false), so the + // agent skips its own APM stats/sampling for these traces. + clientComputedStats: config.stats?.DD_TRACE_STATS_COMPUTATION_ENABLED || config.apmTracingEnabled === false, + }) + + this._exporter = new NativeExporter(config, this._prioritySampler, nativeSpans) + this._processor = new SpanProcessor( + this._exporter, + this._prioritySampler, + config, + otlpStatsExporter, + nativeStatsEnabled + ) + this._url = agentUrl + + log.debug('Native exporter enabled') + } + } + this._propagators = { [formats.TEXT_MAP]: new TextMapPropagator(config), [formats.HTTP_HEADERS]: new HttpPropagator(config), @@ -74,7 +268,7 @@ class DatadogTracer { ? getContext(options.childOf) : getParent(options.references) - const span = new Span(this, this._processor, this._prioritySampler, { + const fields = { operationName: options.operationName || name, parent, startTime: options.startTime, @@ -82,7 +276,9 @@ class DatadogTracer { traceId128BitGenerationEnabled: this._traceId128BitGenerationEnabled, integrationName: options.integrationName, links: options.links, - }, this._debug) + } + + const span = new Span(this, this._processor, this._prioritySampler, fields, this._debug) // As per unified service tagging spec if a span is created with a service name different from the global // service name it will not inherit the global version value diff --git a/packages/dd-trace/src/plugins/util/http-otel-semantics.js b/packages/dd-trace/src/plugins/util/http-otel-semantics.js index d42537e247c..13d7f09204e 100644 --- a/packages/dd-trace/src/plugins/util/http-otel-semantics.js +++ b/packages/dd-trace/src/plugins/util/http-otel-semantics.js @@ -269,8 +269,33 @@ function applyHttpOtelSemantics (formattedSpan) { formattedSpan.metrics = newMetrics } +// The meta/metric keys `applyHttpOtelSemantics` can emit. The native path syncs +// only these from the remapped view (the rest of the span's tags are already in +// the WASM store), so it must know the exact output set. +const OTEL_OUTPUT_META_KEYS = [ + HTTP_REQUEST_METHOD, + HTTP_REQUEST_METHOD_ORIGINAL, + URL_FULL, + URL_PATH, + URL_SCHEME, + URL_QUERY, + SERVER_ADDRESS, + USER_AGENT_ORIGINAL, + CLIENT_ADDRESS, + ERROR_TYPE, +] +const OTEL_OUTPUT_METRIC_KEYS = [HTTP_RESPONSE_STATUS_CODE, SERVER_PORT] + module.exports = { NETWORK_PEER_ADDRESS, // imported by web.js (set from req.socket, not at serialization) decomposeServerUrl, // exercised directly by the helper spec applyHttpOtelSemantics, + // Consumed by the native span path (packages/dd-trace/src/native): the DD HTTP + // meta keys + network.destination.port are held out of the WASM store under + // OTEL semantics (they'd otherwise be un-removable), and the OTel output keys + // are synced from the remapped view at finish. + DD_HTTP_META_KEYS, + NETWORK_DESTINATION_PORT, + OTEL_OUTPUT_META_KEYS, + OTEL_OUTPUT_METRIC_KEYS, } diff --git a/packages/dd-trace/src/serverless.js b/packages/dd-trace/src/serverless.js index 23e424b4baa..087029fec35 100644 --- a/packages/dd-trace/src/serverless.js +++ b/packages/dd-trace/src/serverless.js @@ -2,6 +2,10 @@ const { getEnvironmentVariable, getValueFromEnvSources } = require('./config/helper') +function getIsAWSLambda () { + return getEnvironmentVariable('AWS_LAMBDA_FUNCTION_NAME') !== undefined +} + function getIsGCPFunction () { const isDeprecatedGCPFunction = getEnvironmentVariable('FUNCTION_NAME') !== undefined && @@ -35,11 +39,10 @@ function getIsFlexConsumptionAzureFunction () { } function isInServerlessEnvironment () { - const inAWSLambda = getEnvironmentVariable('AWS_LAMBDA_FUNCTION_NAME') !== undefined const isGCPFunction = getIsGCPFunction() const isAzureFunction = getIsAzureFunction() - return inAWSLambda || isGCPFunction || isAzureFunction + return getIsAWSLambda() || isGCPFunction || isAzureFunction } /** @@ -79,6 +82,7 @@ function getVercelPlatformTags () { } module.exports = { + getIsAWSLambda, getServerlessPlatformTags, getIsGCPFunction, getIsAzureFunction, diff --git a/packages/dd-trace/src/service-naming/extra-services.js b/packages/dd-trace/src/service-naming/extra-services.js index f57eaecbbb0..addb9841f50 100644 --- a/packages/dd-trace/src/service-naming/extra-services.js +++ b/packages/dd-trace/src/service-naming/extra-services.js @@ -4,10 +4,12 @@ const maxExtraServices = 64 /** @type {Set} */ const extraServices = new Set() -// 1-element cache of the most-recent argument. The sole production caller -// (`span_format.js`) runs per span; without the cache every redis / mysql -// burst pays a `Set.add` hash + probe even though the value is already -// registered. +// 1-element cache of the most-recent argument. Designed for a per-span hot path +// (e.g. redis / mysql bursts that repeatedly register the same service); without +// the cache each call pays a `Set.add` hash + probe even when the value is +// already registered. The sole production caller is `span_format`, which runs +// per finished span on both pipelines and only calls in when the span's service +// actually differs from the tracer's own. /** @type {string | null | undefined} */ let lastSeenService diff --git a/packages/dd-trace/src/span_processor.js b/packages/dd-trace/src/span_processor.js index 727631a1085..9eb177b0b97 100644 --- a/packages/dd-trace/src/span_processor.js +++ b/packages/dd-trace/src/span_processor.js @@ -12,31 +12,44 @@ const startedSpans = new WeakSet() const finishedSpans = new WeakSet() class SpanProcessor { - constructor (exporter, prioritySampler, config, otlpStatsExporter) { + /** + * @param {object} exporter + * @param {object} prioritySampler + * @param {object} config + * @param {object} [otlpStatsExporter] + * @param {boolean} [nativeStatsEnabled] + */ + constructor (exporter, prioritySampler, config, otlpStatsExporter, nativeStatsEnabled = false) { this._exporter = exporter this._prioritySampler = prioritySampler this._config = config this._killAll = false - if (config.stats?.DD_TRACE_STATS_COMPUTATION_ENABLED && !config.appsec?.standalone?.enabled) { + if (!config.isCiVisibility && (otlpStatsExporter || + (!nativeStatsEnabled && config.stats?.DD_TRACE_STATS_COMPUTATION_ENABLED))) { const { SpanStatsProcessor } = require('./span_stats') this._stats = new SpanStatsProcessor(config, otlpStatsExporter) } this._spanSampler = new SpanSampler(config.sampler) this._gitMetadataTagger = new GitMetadataTagger(config) - this._processTags = config.DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED ? processTags.serialized : false } + /** + * @param {import('./opentracing/span')} span + */ sample (span) { const spanContext = span.context() this._prioritySampler.sample(spanContext) this._spanSampler.sample(spanContext) } + /** + * @param {import('./opentracing/span')} span + */ process (span) { const spanContext = span.context() const active = [] @@ -47,7 +60,7 @@ class SpanProcessor { if (trace.record === false) return if (DD_TRACE_ENABLED === false) { - this._erase(trace, active) + this.#erase(trace, active) return } if (started.length === finished.length || finished.length >= flushMinSpans) { @@ -57,17 +70,15 @@ class SpanProcessor { let isFirstSpanInChunk = true const stampApmDisabled = this._config.apmTracingEnabled === false - for (const span of started) { - if (span._duration === undefined) { - active.push(span) + for (const startedSpan of started) { + if (startedSpan._duration === undefined) { + active.push(startedSpan) } else { - const formattedSpan = spanFormat(span, isFirstSpanInChunk, this._processTags) - if (stampApmDisabled) { - formattedSpan.metrics[APM_TRACING_ENABLED_KEY] = 0 + if (stampApmDisabled && isFirstSpanInChunk) { + startedSpan.context().setTag(APM_TRACING_ENABLED_KEY, 0) } + const formattedSpan = spanFormat(startedSpan, isFirstSpanInChunk, this._processTags) isFirstSpanInChunk = false - // Span stats read Datadog HTTP tag names from the formatted span, so - // record them before the OTel rename — an export-only transform. this._stats?.onSpanFinished(formattedSpan) if (this._config.DD_TRACE_OTEL_SEMANTICS_ENABLED) { applyHttpOtelSemantics(formattedSpan) @@ -80,7 +91,7 @@ class SpanProcessor { this._exporter.export(formatted) } - this._erase(trace, active) + this.#erase(trace, active) } if (this._killAll) { @@ -96,7 +107,12 @@ class SpanProcessor { this._killAll = true } - _erase (trace, active) { + /** + * Validate optional span state tracking and retain only active spans. + * @param {object} trace Trace state to clear + * @param {object[]} active Spans that remain active + */ + #erase (trace, active) { if (this._config.DD_TRACE_EXPERIMENTAL_STATE_TRACKING) { const started = new Set() const startedIds = new Set() diff --git a/packages/dd-trace/src/tracer.js b/packages/dd-trace/src/tracer.js index 28e7df78d5f..a717843cca7 100644 --- a/packages/dd-trace/src/tracer.js +++ b/packages/dd-trace/src/tracer.js @@ -146,7 +146,8 @@ class DatadogTracer extends Tracer { } setUrl (url) { - this._exporter.setUrl(url) + // The stdout exporter (Lambda with no local agent) has no URL to set. + this._exporter.setUrl?.(url) this._dataStreamsProcessor.setUrl(url) } diff --git a/packages/dd-trace/test/config/index.spec.js b/packages/dd-trace/test/config/index.spec.js index 22df5332176..abbc5a5cf58 100644 --- a/packages/dd-trace/test/config/index.spec.js +++ b/packages/dd-trace/test/config/index.spec.js @@ -790,25 +790,25 @@ describe('Config', () => { assert.strictEqual(config.OTEL_TRACES_EXPORTER, undefined) }) - it('should disable OTLP traces export when DD_TRACE_AGENT_PROTOCOL_VERSION is set', () => { + it('should keep OTEL_TRACES_EXPORTER=otlp when DD_TRACE_AGENT_PROTOCOL_VERSION is set to a non-default value', () => { process.env.OTEL_TRACES_EXPORTER = 'otlp' process.env.DD_TRACE_AGENT_PROTOCOL_VERSION = '0.5' const config = getConfig() - assert.strictEqual(config.OTEL_TRACES_EXPORTER, 'none') + assert.strictEqual(config.OTEL_TRACES_EXPORTER, 'otlp') }) - it('should not disable OTLP traces export when DD_TRACE_AGENT_PROTOCOL_VERSION is unset', () => { + it('should keep OTEL_TRACES_EXPORTER=otlp when DD_TRACE_AGENT_PROTOCOL_VERSION is unset', () => { process.env.OTEL_TRACES_EXPORTER = 'otlp' delete process.env.DD_TRACE_AGENT_PROTOCOL_VERSION const config = getConfig() assert.strictEqual(config.OTEL_TRACES_EXPORTER, 'otlp') }) - it('should disable OTLP traces export when DD_TRACE_AGENT_PROTOCOL_VERSION is set', () => { + it('should keep OTEL_TRACES_EXPORTER=otlp when DD_TRACE_AGENT_PROTOCOL_VERSION is 0.4', () => { process.env.OTEL_TRACES_EXPORTER = 'otlp' process.env.DD_TRACE_AGENT_PROTOCOL_VERSION = '0.4' const config = getConfig() - assert.strictEqual(config.OTEL_TRACES_EXPORTER, 'none') + assert.strictEqual(config.OTEL_TRACES_EXPORTER, 'otlp') }) it('should fall back to http/json when OTEL_EXPORTER_OTLP_TRACES_PROTOCOL is unsupported', () => { @@ -5065,7 +5065,6 @@ rules: process.env._DD_APM_TRACING_AGENTLESS_ENABLED = 'true' process.env.DD_TRACE_128_BIT_TRACEID_GENERATION_ENABLED = 'true' const config = getConfig() - // Env var has higher priority than calculated; encoder truncation is the safety net assert.strictEqual(config.traceId128BitGenerationEnabled, true) }) diff --git a/packages/dd-trace/test/encode/0.4.spec.js b/packages/dd-trace/test/encode/0.4.spec.js index cca0a46e0b6..e3fb13dec59 100644 --- a/packages/dd-trace/test/encode/0.4.spec.js +++ b/packages/dd-trace/test/encode/0.4.spec.js @@ -151,6 +151,19 @@ describe('encode', () => { assert.throws(() => encoder.encode(data), /something else/) }) + it('should reset and report non-overflow encoder errors when the writer handles them', () => { + const error = new Error('something else') + writer.onError = sinon.stub() + sinon.stub(encoder._traceBytes, 'reserve').throws(error) + const reset = sinon.spy(encoder, 'reset') + + encoder.encode(data) + + assert.strictEqual(encoder.count(), 0) + sinon.assert.calledOnceWithExactly(writer.onError, error) + sinon.assert.calledOnce(reset) + }) + it('should reset after making a payload', () => { encoder.encode(data) encoder.makePayload() @@ -278,13 +291,22 @@ describe('encode', () => { { name: 'I can sing!!! acbdefggnmdfsdv k 2e2ev;!|=xxx', startTime: 1633023102, - attributes: { emotion: 'happy', rating: 9.8, other: [1, 9.5, 1], idol: false }, + attributes: { + emotion: 'happy', + rating: 9.8, + other: [1, 9.5, 1], + idol: false, + success: true, + invalid: null, + notNumber: NaN, + }, }, ] const encodedLink = '[{"name":"Something went so wrong","time_unix_nano":1000000},' + '{"name":"I can sing!!! acbdefggnmdfsdv k 2e2ev;!|=xxx","time_unix_nano":1633023102000000,' + - '"attributes":{"emotion":"happy","rating":9.8,"other":[1,9.5,1],"idol":false}}]' + '"attributes":{"emotion":"happy","rating":9.8,"other":[1,9.5,1],"idol":false,"success":true,' + + '"invalid":null,"notNumber":null}}]' data[0].span_events = topLevelEvents @@ -296,6 +318,26 @@ describe('encode', () => { assert.deepStrictEqual(trace[0].meta.events, encodedLink) }) + it('should preserve lone surrogates in fallback span event JSON', () => { + const events = [{ + name: '\uD800', + startTime: 1, + attributes: { '\uD801': '\uD802' }, + }] + data[0].span_events = events + + encoder.encode(data) + + const buffer = encoder.makePayload() + const decoded = msgpack.decode(buffer, { useBigInt64: true }) + const expected = JSON.stringify([{ + name: '\uD800', + time_unix_nano: 1000000, + attributes: { '\uD801': '\uD802' }, + }]) + assert.strictEqual(decoded[0][0].meta.events, expected) + }) + it('should encode span events whose name is not a string without throwing', () => { // `addEvent` does not type-check `name`. The legacy stringifier must // tolerate the same inputs `JSON.stringify` did before the rewrite. @@ -664,10 +706,10 @@ describe('encode', () => { const trace = decoded[0] const formattedTopLevelEvent = [ - { name: 'Something went so wrong', time_unix_nano: 1000000 }, + { name: 'Something went so wrong', time_unix_nano: 1000000n }, { name: 'I can sing!!! acbdefggnmdfsdv k 2e2ev;!|=xxx', - time_unix_nano: 1633023102000000, + time_unix_nano: 1633023102000000n, attributes: { emotion: { type: 0, string_value: 'happy' }, idol: { type: 1, bool_value: false }, @@ -718,11 +760,11 @@ describe('encode', () => { const formattedTopLevelEvent = [ { name: 'I can sing!!! acbdefggnmdfsdv k 2e2ev;!|=xxx', - time_unix_nano: 1633023102000000, + time_unix_nano: 1633023102000000n, }, { name: 'I can sing!!!', - time_unix_nano: 1633023102000000, + time_unix_nano: 1633023102000000n, attributes: { array: { type: 4, array_value: { values: [{ type: 0, string_value: 'valid_value' }] } } }, }, ] @@ -820,7 +862,7 @@ describe('encode', () => { assert.deepStrictEqual(trace[0].span_events, [ { name: 'kept', - time_unix_nano: 5000000, + time_unix_nano: 5000000n, attributes: { mood: { type: 0, string_value: 'happy' } }, }, ]) diff --git a/packages/dd-trace/test/llmobs/util.js b/packages/dd-trace/test/llmobs/util.js index c14551b8753..d7b2b34ebc6 100644 --- a/packages/dd-trace/test/llmobs/util.js +++ b/packages/dd-trace/test/llmobs/util.js @@ -373,7 +373,13 @@ function expectedLLMObsTags ({ }) { const version = span.meta?.version ?? '' const env = span.meta?.env ?? '' - const service = span.meta?.service ?? '' + // `service` is a top-level span field on the v0.4 wire (not a meta entry); + // fall back to meta for any producer that puts it there. + // LLMObs reports the base tracer service, not a plugin-schematized service + // name (e.g. aws-sdk's `test-aws-bedrockruntime`). `_dd.base_service` holds it + // when the span's service was schematized; otherwise the bare `meta.service` + // (== config.service) does. + const service = span.meta?.['_dd.base_service'] ?? span.meta?.service ?? span.service ?? '' const spanTags = [ `version:${version}`, diff --git a/packages/dd-trace/test/msgpack/encode.spec.js b/packages/dd-trace/test/msgpack/encode.spec.js index 2c11dd6ef8e..5bc0ec41a97 100644 --- a/packages/dd-trace/test/msgpack/encode.spec.js +++ b/packages/dd-trace/test/msgpack/encode.spec.js @@ -107,15 +107,16 @@ describe('msgpack/encode', () => { assert.strictEqual(msgpack.decode(buffer), 'Symbol(pipeline)') }) - it('falls back to msgpack null for unsupported value types (functions, undefined)', () => { - // `typeof undefined === 'undefined'` and `typeof () => {} === 'function'` - // both hit the dispatcher's `default` arm. Encoding them as `nil` keeps - // the surrounding payload well-formed instead of letting the chunk - // emit zero bytes for the value, which would desync the map header - // count from the actual entries. - const buffer = encode({ fn: () => {}, missing: undefined }) - - assert.deepStrictEqual(msgpack.decode(buffer), { fn: null, missing: null }) + it('omits undefined-valued keys and falls back to null for other unsupported types (functions)', () => { + // msgpack has no `undefined`; encoding it as `nil` would diverge from JSON + // semantics and from the legacy v0.4 encoder (which omits such keys), and + // it corrupts meta_struct payloads such as AppSec's truncated request body + // where a dropped child is left as an `undefined`-valued key. So undefined + // keys are omitted (with the map header counting only the kept entries), + // while a function still falls back to `nil` rather than emitting nothing. + const buffer = encode({ fn: () => {}, missing: undefined, kept: 1 }) + + assert.deepStrictEqual(msgpack.decode(buffer), { fn: null, kept: 1 }) }) it('emits an array32 header for arrays with 16 or more entries', () => { diff --git a/packages/dd-trace/test/native/exporter.spec.js b/packages/dd-trace/test/native/exporter.spec.js new file mode 100644 index 00000000000..377557a7ae8 --- /dev/null +++ b/packages/dd-trace/test/native/exporter.spec.js @@ -0,0 +1,802 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { channel } = require('dc-polyfill') +const msgpack = require('@msgpack/msgpack') +const proxyquire = require('proxyquire') +const sinon = require('sinon') + +require('../setup/core') +const { AgentEncoder } = require('../../src/encode/0.4') +const id = require('../../src/id') + +const METRIC_PREFIX = 'datadog.tracer.node.exporter.agent' +const firstFlushChannel = channel('dd-trace:exporter:first-flush') + +describe('NativeExporter', () => { + let NativeExporter + let beforeExitHandlers + let handlersBefore + let clock + let config + let exporter + let fetchAgentInfo + let logDebug + let logError + let logWarn + let metricsIncrement + let nativeSpans + let prioritySampler + + beforeEach(() => { + clock = sinon.useFakeTimers() + beforeExitHandlers = globalThis[Symbol.for('dd-trace')].beforeExitHandlers + handlersBefore = new Set(beforeExitHandlers) + config = { + url: 'http://localhost:8126', + flushInterval: 1000, + } + prioritySampler = { + update: sinon.stub(), + } + nativeSpans = { + flushStats: sinon.stub().resolves(true), + sendEncodedTraces: sinon.stub().resolves('unchanged'), + setAgentUrl: sinon.stub(), + setAgentlessEndpoint: sinon.stub(), + setOtlpEndpoint: sinon.stub(), + setOtlpHeaders: sinon.stub(), + setOtlpProtocol: sinon.stub(), + setUseV05: sinon.stub(), + } + logDebug = sinon.stub() + logError = sinon.stub() + logWarn = sinon.stub() + metricsIncrement = sinon.stub() + fetchAgentInfo = sinon.stub() + NativeExporter = proxyquire('../../src/exporters/native', { + '../../agent/info': { fetchAgentInfo }, + '../../log': { + debug: logDebug, + error: logError, + warn: logWarn, + }, + '../../runtime_metrics': { increment: metricsIncrement }, + }) + }) + + afterEach(() => { + for (const handler of beforeExitHandlers) { + if (!handlersBefore.has(handler)) beforeExitHandlers.delete(handler) + } + clock.restore() + }) + + /** @param {number} [testId] */ + function createSpan (testId = 1) { + return { + testId, + trace_id: id('0000000000000001'), + span_id: id(String(testId).padStart(16, '0')), + parent_id: id('0000000000000000'), + name: 'request', + resource: 'GET /', + service: 'web', + meta: {}, + metrics: {}, + error: 0, + start: 1, + duration: 2, + } + } + + /** @returns {InstanceType} */ + function createExporter () { + exporter = new NativeExporter(config, prioritySampler, nativeSpans) + return exporter + } + + /** + * @param {object[]} [spans] + * @returns {void} + */ + function exportChunk (spans = [createSpan()]) { + exporter.export(spans) + } + + async function settle () { + for (let turn = 0; turn < 8; turn++) { + await Promise.resolve() + } + } + + describe('configuration', () => { + it('enables v0.5 only when the agent advertises it', () => { + config.protocolVersion = '0.5' + fetchAgentInfo.callsArgWith(1, undefined, { endpoints: ['/v0.5/traces'] }) + + createExporter() + + sinon.assert.calledOnceWithExactly(nativeSpans.setUseV05, true) + }) + + it('uses a pre-parsed Agent URL for v0.5 negotiation', () => { + config.protocolVersion = '0.5' + config.url = new URL('http://agent.internal:8126') + fetchAgentInfo.callsArgWith(1, undefined, { endpoints: [] }) + + createExporter() + + sinon.assert.calledOnceWithExactly(fetchAgentInfo, config.url, sinon.match.func) + }) + + it('ignores malformed v0.5 capability responses', () => { + config.protocolVersion = '0.5' + fetchAgentInfo.callsArgWith(1, undefined, { endpoints: '/v0.5/traces' }) + + createExporter() + + sinon.assert.notCalled(nativeSpans.setUseV05) + }) + + it('configures OTLP endpoint, protocol, and flattened headers without v0.5 negotiation', () => { + config.protocolVersion = '0.5' + config.OTEL_TRACES_EXPORTER = 'otlp' + config.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'http://collector:4318/v1/traces' + config.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL = 'http/protobuf' + config.OTEL_EXPORTER_OTLP_TRACES_HEADERS = { authorization: 'token', count: 2 } + + createExporter() + + sinon.assert.calledOnceWithExactly(nativeSpans.setOtlpEndpoint, 'http://collector:4318/v1/traces') + sinon.assert.calledOnceWithExactly(nativeSpans.setOtlpProtocol, 'http/protobuf') + sinon.assert.calledOnceWithExactly(nativeSpans.setOtlpHeaders, ['authorization', 'token', 'count', '2']) + sinon.assert.notCalled(fetchAgentInfo) + }) + + it('configures agentless intake before OTLP and v0.5', () => { + config.experimental = { exporter: 'agentless' } + config.site = 'datadoghq.eu' + config.DD_API_KEY = 'test-api-key' + config.OTEL_TRACES_EXPORTER = 'otlp' + config.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'http://collector:4318/v1/traces' + config.protocolVersion = '0.5' + + createExporter() + + sinon.assert.calledOnceWithExactly( + nativeSpans.setAgentlessEndpoint, + 'https://public-trace-http-intake.logs.datadoghq.eu/api/v2/spans', + 'test-api-key', + ) + sinon.assert.notCalled(nativeSpans.setOtlpEndpoint) + sinon.assert.notCalled(fetchAgentInfo) + assert.strictEqual(exporter._url.href, 'https://public-trace-http-intake.logs.datadoghq.eu/') + }) + + it('disables agentless export when the API key is missing', () => { + config.experimental = { exporter: 'agentless' } + + createExporter() + exportChunk() + clock.tick(config.flushInterval) + const done = sinon.stub() + exporter.flush(done) + + sinon.assert.notCalled(nativeSpans.setAgentlessEndpoint) + sinon.assert.notCalled(nativeSpans.sendEncodedTraces) + sinon.assert.calledOnce(done) + sinon.assert.calledOnceWithExactly( + logError, + 'DD_API_KEY is required for native agentless trace intake. Traces will not be sent.', + ) + }) + + it('disables agentless export when native configuration fails', () => { + const error = new Error('invalid replacement rule') + config.experimental = { exporter: 'agentless' } + config.DD_API_KEY = 'test-api-key' + nativeSpans.setAgentlessEndpoint.throws(error) + + createExporter() + exportChunk() + clock.tick(config.flushInterval) + + sinon.assert.notCalled(nativeSpans.sendEncodedTraces) + sinon.assert.calledOnceWithExactly(logError, 'Failed to configure native agentless trace intake: %s', error) + }) + + it('registers a process callback when the shared before-exit registry is unavailable', () => { + const globalState = globalThis[Symbol.for('dd-trace')] + const handlers = globalState.beforeExitHandlers + const processOnce = sinon.stub(process, 'once') + globalState.beforeExitHandlers = undefined + try { + createExporter() + + sinon.assert.calledOnceWithMatch(processOnce, 'beforeExit', sinon.match.func) + } finally { + globalState.beforeExitHandlers = handlers + processOnce.restore() + } + }) + + it('warns and keeps the agent route when OTLP has no endpoint', () => { + config.OTEL_TRACES_EXPORTER = 'otlp' + + createExporter() + + sinon.assert.notCalled(nativeSpans.setOtlpEndpoint) + sinon.assert.calledOnce(logWarn) + }) + + it('warns and keeps the native default when the OTLP protocol is unsupported', () => { + config.OTEL_TRACES_EXPORTER = 'otlp' + config.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'http://collector:4318/v1/traces' + config.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL = 'unsupported' + nativeSpans.setOtlpProtocol.throws(new Error('unsupported protocol')) + + createExporter() + + sinon.assert.calledOnceWithExactly(nativeSpans.setOtlpEndpoint, 'http://collector:4318/v1/traces') + sinon.assert.calledOnce(logWarn) + }) + + it('warns and keeps v0.4 when the agent URL cannot be parsed for negotiation', () => { + config.protocolVersion = '0.5' + config.url = 'not a URL' + + createExporter() + + sinon.assert.notCalled(fetchAgentInfo) + sinon.assert.calledOnce(logWarn) + }) + + it('keeps v0.4 when the agent info request fails', () => { + config.protocolVersion = '0.5' + fetchAgentInfo.callsArgWith(1, new Error('agent unavailable')) + + createExporter() + + sinon.assert.notCalled(nativeSpans.setUseV05) + sinon.assert.calledOnce(logDebug) + }) + + it('derives the URL from hostname and port', () => { + delete config.url + config.hostname = 'agent.internal' + config.port = 9126 + + createExporter() + + assert.strictEqual(exporter._url.href, 'http://agent.internal:9126/') + }) + }) + + describe('export', () => { + it('formats BigInt values in lazy debug payloads', () => { + createExporter() + const span = createSpan() + span.meta.value = 1n + + exportChunk([span]) + + const message = logDebug.firstCall.args[0](...logDebug.firstCall.args.slice(1)) + assert.match(message, /"value":"1"/) + }) + + it('formats unserializable values in lazy debug payloads', () => { + createExporter() + const span = createSpan() + span.meta.value = span.meta + + exportChunk([span]) + + const message = logDebug.firstCall.args[0](...logDebug.firstCall.args.slice(1)) + assert.strictEqual(message, 'Queueing payload: [unserializable]') + }) + + it('encodes finalized data before the batching window ends', () => { + createExporter() + const span = createSpan(1) + + exportChunk([span]) + span.resource = 'changed after export' + + sinon.assert.notCalled(nativeSpans.sendEncodedTraces) + clock.tick(config.flushInterval) + + sinon.assert.calledOnce(nativeSpans.sendEncodedTraces) + const decoded = msgpack.decode(nativeSpans.sendEncodedTraces.firstCall.args[0], { useBigInt64: true }) + assert.strictEqual(decoded[0][0].resource, 'GET /') + }) + + it('uses native span events for the feature flag and OTLP', () => { + config.DD_TRACE_NATIVE_SPAN_EVENTS = true + createExporter() + const firstSpan = createSpan() + firstSpan.span_events = [{ name: 'event', startTime: 1.5, attributes: { value: 1 } }] + exportChunk([firstSpan]) + clock.tick(config.flushInterval) + const firstPayload = msgpack.decode(nativeSpans.sendEncodedTraces.firstCall.args[0], { useBigInt64: true }) + assert.strictEqual(firstPayload[0][0].span_events[0].name, 'event') + + config.DD_TRACE_NATIVE_SPAN_EVENTS = false + config.OTEL_TRACES_EXPORTER = 'otlp' + config.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'http://collector:4318/v1/traces' + exporter = new NativeExporter(config, prioritySampler, nativeSpans) + const secondSpan = createSpan() + secondSpan.span_events = [{ name: 'event', startTime: 2.5 }] + exportChunk([secondSpan]) + clock.tick(config.flushInterval) + const secondPayload = msgpack.decode(nativeSpans.sendEncodedTraces.secondCall.args[0], { useBigInt64: true }) + assert.strictEqual(secondPayload[0][0].span_events[0].name, 'event') + }) + + it('handles a synchronous native send error', () => { + nativeSpans.sendEncodedTraces.throws(new Error('send failed')) + createExporter() + + exportChunk() + clock.tick(config.flushInterval) + + sinon.assert.calledOnce(logError) + sinon.assert.calledOnce(nativeSpans.sendEncodedTraces) + }) + + it('flushes immediately at zero interval', () => { + config.flushInterval = 0 + createExporter() + + exportChunk() + + sinon.assert.calledOnce(nativeSpans.sendEncodedTraces) + }) + + it('uses one timer for repeated exports and sends all chunks together', () => { + createExporter() + exportChunk([createSpan(1)]) + clock.tick(config.flushInterval / 2) + exportChunk([createSpan(2)]) + + clock.tick(config.flushInterval / 2 - 1) + sinon.assert.notCalled(nativeSpans.sendEncodedTraces) + clock.tick(1) + + sinon.assert.calledOnce(nativeSpans.sendEncodedTraces) + const decoded = msgpack.decode(nativeSpans.sendEncodedTraces.firstCall.args[0], { useBigInt64: true }) + assert.strictEqual(decoded.length, 2) + assert.strictEqual(decoded[0][0].span_id, 1n) + assert.strictEqual(decoded[1][0].span_id, 2n) + }) + + it('flushes when the encoded payload reaches the byte limit', () => { + createExporter() + const span = createSpan() + span.meta.value = 'x'.repeat(8 * 1024 * 1024) + + exportChunk([span]) + + sinon.assert.calledOnce(nativeSpans.sendEncodedTraces) + }) + }) + + describe('flush', () => { + it('settles immediately when there is no pending chunk', () => { + createExporter() + const done = sinon.stub() + + exporter.flush(done) + + sinon.assert.calledOnce(done) + sinon.assert.notCalled(nativeSpans.sendEncodedTraces) + }) + + it('waits for the native send and applies sampling rates', async () => { + const rates = { 'service:,env:': 0.5 } + nativeSpans.sendEncodedTraces.resolves(JSON.stringify({ rate_by_service: rates })) + createExporter() + exportChunk() + const done = sinon.stub() + + exporter.flush(done) + sinon.assert.notCalled(done) + await settle() + + sinon.assert.calledOnce(done) + sinon.assert.calledOnceWithExactly(prioritySampler.update, rates) + sinon.assert.calledWith(metricsIncrement, `${METRIC_PREFIX}.requests`, true) + sinon.assert.calledWith(metricsIncrement, `${METRIC_PREFIX}.responses`, true) + }) + + it('serializes work queued during an in-flight send', async () => { + let releaseFirst + nativeSpans.sendEncodedTraces.onFirstCall().returns(new Promise(resolve => { releaseFirst = resolve })) + nativeSpans.sendEncodedTraces.onSecondCall().resolves('unchanged') + createExporter() + exportChunk([createSpan(1)]) + exporter.flush() + exportChunk([createSpan(2)]) + const done = sinon.stub() + + exporter.flush(done) + sinon.assert.calledOnce(nativeSpans.sendEncodedTraces) + sinon.assert.notCalled(done) + releaseFirst('unchanged') + await settle() + + sinon.assert.calledTwice(nativeSpans.sendEncodedTraces) + sinon.assert.calledOnce(done) + }) + + it('bounds staged and in-flight payload bytes', async () => { + const sizingEncoder = new AgentEncoder({ flush: sinon.stub() }) + sizingEncoder.encode([createSpan()]) + const payloadSize = sizingEncoder.makePayload().length + const BoundedNativeExporter = proxyquire('../../src/exporters/native', { + '../../agent/info': { fetchAgentInfo }, + '../../log': { + debug: logDebug, + error: logError, + warn: logWarn, + }, + '../../runtime_metrics': { increment: metricsIncrement }, + '../common/limits': { MAX_ACTIVE_BUFFER_SIZE: payloadSize * 2 }, + }) + let releaseFirst + nativeSpans.sendEncodedTraces.onFirstCall().returns(new Promise(resolve => { releaseFirst = resolve })) + nativeSpans.sendEncodedTraces.onSecondCall().resolves('unchanged') + config.flushInterval = 0 + exporter = new BoundedNativeExporter(config, prioritySampler, nativeSpans) + + exportChunk() + exportChunk() + exportChunk() + sinon.assert.calledOnce(nativeSpans.sendEncodedTraces) + + releaseFirst('unchanged') + await settle() + + sinon.assert.calledTwice(nativeSpans.sendEncodedTraces) + sinon.assert.calledWith(logDebug, 'Maximum native export buffer size reached: payload is discarded') + }) + + it('sends one request per chunk at zero interval', async () => { + config.flushInterval = 0 + createExporter() + exportChunk([createSpan(1)]) + exportChunk([createSpan(2)]) + await settle() + + sinon.assert.calledTwice(nativeSpans.sendEncodedTraces) + const firstPayload = msgpack.decode(nativeSpans.sendEncodedTraces.firstCall.args[0], { useBigInt64: true }) + const secondPayload = msgpack.decode(nativeSpans.sendEncodedTraces.secondCall.args[0], { useBigInt64: true }) + assert.strictEqual(firstPayload.length, 1) + assert.strictEqual(secondPayload.length, 1) + assert.strictEqual(firstPayload[0][0].span_id, 1n) + assert.strictEqual(secondPayload[0][0].span_id, 2n) + }) + + it('runs compatibility stats flush after traces finish', async () => { + let releaseTrace + nativeSpans.sendEncodedTraces.returns(new Promise(resolve => { releaseTrace = resolve })) + createExporter() + exportChunk() + const done = sinon.stub() + + exporter._writer.flush(done) + sinon.assert.notCalled(nativeSpans.flushStats) + releaseTrace('unchanged') + await settle() + + sinon.assert.calledOnce(nativeSpans.flushStats) + sinon.assert.calledOnce(done) + }) + + it('completes compatibility flushes when native stats reject', async () => { + nativeSpans.flushStats.rejects(new Error('stats failed')) + createExporter() + const done = sinon.stub() + + exporter._writer.flush(done) + await settle() + + sinon.assert.calledOnce(done) + sinon.assert.calledOnce(logError) + }) + + it('logs failed final native stats flushes', async () => { + nativeSpans.flushStats.rejects(new Error('stats failed')) + createExporter() + let finalFlush + for (const handler of beforeExitHandlers) { + if (!handlersBefore.has(handler)) finalFlush = handler + } + + assert.strictEqual(typeof finalFlush, 'function') + finalFlush() + await settle() + + sinon.assert.calledOnce(logWarn) + }) + + it('runs every flush callback before surfacing a callback error', async () => { + let releaseSend + nativeSpans.sendEncodedTraces.returns(new Promise(resolve => { releaseSend = resolve })) + createExporter() + exportChunk() + const expected = new Error('callback failed') + const second = sinon.stub() + + exporter.flush(() => { throw expected }) + exporter.flush(second) + releaseSend('unchanged') + await settle() + + sinon.assert.calledOnce(second) + assert.throws(() => clock.runAll(), expected) + }) + + it('handles encoding errors without sending a partial payload', () => { + createExporter() + const span = createSpan() + Object.defineProperty(span, 'meta', { + get () { throw new Error('invalid meta') }, + }) + const done = sinon.stub() + + exportChunk([span]) + exporter.flush(done) + + sinon.assert.notCalled(nativeSpans.sendEncodedTraces) + sinon.assert.calledOnce(done) + sinon.assert.calledOnce(logError) + }) + + it('handles non-Error encoding failures', () => { + createExporter() + const span = createSpan() + Object.defineProperty(span, 'meta', { + get () { throw null }, // eslint-disable-line no-throw-literal + }) + + exportChunk([span]) + + sinon.assert.notCalled(nativeSpans.sendEncodedTraces) + sinon.assert.calledWith(metricsIncrement, `${METRIC_PREFIX}.errors.by.name`, 'name:Error', true) + sinon.assert.calledOnce(logError) + }) + + it('handles payload assembly errors without sending', () => { + const makePayload = sinon.stub(AgentEncoder.prototype, 'makePayload').throws(new Error('assembly failed')) + try { + createExporter() + exportChunk() + const done = sinon.stub() + + exporter.flush(done) + + sinon.assert.notCalled(nativeSpans.sendEncodedTraces) + sinon.assert.calledOnce(done) + sinon.assert.calledOnce(logError) + } finally { + makePayload.restore() + } + }) + + it('keeps an in-flight send serialized after an encoding error', async () => { + let releaseSend + nativeSpans.sendEncodedTraces.returns(new Promise(resolve => { releaseSend = resolve })) + createExporter() + exportChunk([createSpan(1)]) + exporter.flush() + const span = createSpan(2) + Object.defineProperty(span, 'meta', { + get () { throw new Error('invalid meta') }, + }) + exportChunk([span]) + const done = sinon.stub() + + exporter.flush(done) + sinon.assert.calledOnce(nativeSpans.sendEncodedTraces) + sinon.assert.notCalled(done) + releaseSend('unchanged') + await settle() + + sinon.assert.calledOnce(nativeSpans.sendEncodedTraces) + sinon.assert.calledOnce(done) + sinon.assert.calledOnce(logError) + }) + + it('settles without sending when the encoder drops an oversized trace', () => { + const encode = sinon.stub(AgentEncoder.prototype, 'encode') + try { + createExporter() + const done = sinon.stub() + + exportChunk() + exporter.flush(done) + + sinon.assert.notCalled(nativeSpans.sendEncodedTraces) + sinon.assert.calledOnce(done) + } finally { + encode.restore() + } + }) + + it('ignores malformed native sampling responses', async () => { + nativeSpans.sendEncodedTraces.resolves('{') + createExporter() + exportChunk() + + exporter.flush() + await settle() + + sinon.assert.notCalled(prioritySampler.update) + sinon.assert.calledOnce(logError) + }) + + it('retries work queued during a transient failure', async () => { + let rejectFirst + nativeSpans.sendEncodedTraces.onFirstCall().returns(new Promise((_resolve, reject) => { rejectFirst = reject })) + nativeSpans.sendEncodedTraces.onSecondCall().resolves('unchanged') + createExporter() + exportChunk([createSpan(1)]) + exporter.flush() + exportChunk([createSpan(2)]) + + rejectFirst(new Error('network failed')) + await settle() + + sinon.assert.calledOnce(nativeSpans.sendEncodedTraces) + clock.tick(config.flushInterval) + await settle() + + sinon.assert.calledTwice(nativeSpans.sendEncodedTraces) + sinon.assert.called(logError) + }) + + it('disables future exports after a fatal native build failure', async () => { + const error = new Error('build failed') + error.name = 'NativeExporterBuildError' + nativeSpans.sendEncodedTraces.rejects(error) + createExporter() + exportChunk([createSpan(1)]) + exporter.flush() + await settle() + nativeSpans.sendEncodedTraces.resetHistory() + + exportChunk([createSpan(2)]) + + sinon.assert.notCalled(nativeSpans.sendEncodedTraces) + }) + + it('records error name and code on failed sends', async () => { + const error = new Error('connection refused') + error.code = 'ECONNREFUSED' + nativeSpans.sendEncodedTraces.rejects(error) + createExporter() + exportChunk() + exporter.flush() + await settle() + + sinon.assert.calledWith(metricsIncrement, `${METRIC_PREFIX}.errors`, true) + sinon.assert.calledWith(metricsIncrement, `${METRIC_PREFIX}.errors.by.name`, 'name:Error', true) + sinon.assert.calledWith(metricsIncrement, `${METRIC_PREFIX}.errors.by.code`, 'code:ECONNREFUSED', true) + }) + }) + + describe('setUrl', () => { + it('updates native state immediately while idle', () => { + createExporter() + + exporter.setUrl('http://agent.internal:9126') + + sinon.assert.calledOnceWithExactly(nativeSpans.setAgentUrl, 'http://agent.internal:9126/') + assert.strictEqual(exporter._url.href, 'http://agent.internal:9126/') + }) + + it('updates the complete agentless intake endpoint instead of the Agent URL', () => { + config.experimental = { exporter: 'agentless' } + config.DD_API_KEY = 'test-api-key' + createExporter() + nativeSpans.setAgentlessEndpoint.resetHistory() + + exporter.setUrl('http://intake.internal:9126/custom/path') + + sinon.assert.calledOnceWithExactly( + nativeSpans.setAgentlessEndpoint, + 'http://intake.internal:9126/api/v2/spans', + 'test-api-key', + ) + sinon.assert.notCalled(nativeSpans.setAgentUrl) + assert.strictEqual(exporter._url.href, 'http://intake.internal:9126/custom/path') + }) + + it('flushes pending chunks before replacing native state', async () => { + let releaseSend + nativeSpans.sendEncodedTraces.returns(new Promise(resolve => { releaseSend = resolve })) + createExporter() + exportChunk() + + exporter.setUrl('http://agent.internal:9126') + sinon.assert.notCalled(nativeSpans.setAgentUrl) + releaseSend('unchanged') + await settle() + + sinon.assert.calledOnceWithExactly(nativeSpans.setAgentUrl, 'http://agent.internal:9126/') + }) + + it('waits for an in-flight send before replacing native state', async () => { + let releaseSend + nativeSpans.sendEncodedTraces.returns(new Promise(resolve => { releaseSend = resolve })) + createExporter() + exportChunk() + exporter.flush() + + exporter.setUrl('http://agent.internal:9126') + sinon.assert.notCalled(nativeSpans.setAgentUrl) + releaseSend('unchanged') + await settle() + + sinon.assert.calledOnceWithExactly(nativeSpans.setAgentUrl, 'http://agent.internal:9126/') + }) + + it('keeps the old URL when native state replacement fails', () => { + nativeSpans.setAgentUrl.throws(new Error('invalid native URL')) + createExporter() + + exporter.setUrl('http://agent.internal:9126') + + assert.strictEqual(exporter._url, 'http://localhost:8126') + sinon.assert.calledOnce(logWarn) + }) + + it('rejects malformed URLs without touching native state', () => { + createExporter() + + exporter.setUrl('not a URL') + + sinon.assert.notCalled(nativeSpans.setAgentUrl) + sinon.assert.calledOnce(logWarn) + }) + + it('drops URL updates after a fatal native build failure', async () => { + let rejectSend + const error = new Error('build failed') + error.name = 'NativeExporterBuildError' + nativeSpans.sendEncodedTraces.returns(new Promise((_resolve, reject) => { rejectSend = reject })) + createExporter() + exportChunk() + exporter.flush() + + exporter.setUrl('http://queued-agent.internal:9126') + rejectSend(error) + await settle() + exporter.setUrl('http://later-agent.internal:9126') + + sinon.assert.notCalled(nativeSpans.setAgentUrl) + assert.strictEqual(exporter._url, 'http://localhost:8126') + }) + }) + + describe('first flush', () => { + it('publishes exactly once even when the first send rejects', async () => { + const observer = sinon.stub() + firstFlushChannel.subscribe(observer) + nativeSpans.sendEncodedTraces.onFirstCall().rejects(new Error('network failed')) + nativeSpans.sendEncodedTraces.onSecondCall().resolves('unchanged') + config.flushInterval = 0 + createExporter() + + exportChunk([createSpan(1)]) + await settle() + exportChunk([createSpan(2)]) + await settle() + + sinon.assert.calledOnce(observer) + firstFlushChannel.unsubscribe(observer) + }) + }) +}) diff --git a/packages/dd-trace/test/native/integration.spec.js b/packages/dd-trace/test/native/integration.spec.js new file mode 100644 index 00000000000..1571ded31b5 --- /dev/null +++ b/packages/dd-trace/test/native/integration.spec.js @@ -0,0 +1,314 @@ +'use strict' + +const assert = require('node:assert/strict') +const { once } = require('node:events') +const http = require('node:http') + +const sinon = require('sinon') + +require('../setup/core') + +const FakeAgent = require('../../../../integration-tests/helpers/fake-agent') +const tags = require('../../../../ext/tags') + +const { RESOURCE_NAME, SERVICE_NAME, SPAN_TYPE } = tags + +describe('Native Spans Integration', () => { + let beforeExitHandlers + let handlersBefore + let agent + let sentTraces + let tracer + + beforeEach(async () => { + beforeExitHandlers = globalThis[Symbol.for('dd-trace')].beforeExitHandlers + handlersBefore = new Set(beforeExitHandlers) + sentTraces = [] + agent = await new FakeAgent().start() + agent.on('message', ({ payload }) => sentTraces.push(...payload)) + + process.env.DD_TRACE_NATIVE_SPAN_EVENTS = 'true' + delete require.cache[require.resolve('../../src/config')] + delete require.cache[require.resolve('../../src/tracer')] + + const getConfig = require('../../src/config') + const config = getConfig({ + flushInterval: 60_000, + hostname: '127.0.0.1', + port: agent.port, + service: 'test-service', + }) + const Tracer = require('../../src/tracer') + tracer = new Tracer(config) + }) + + afterEach(async () => { + delete process.env.DD_TRACE_NATIVE_SPAN_EVENTS + for (const handler of beforeExitHandlers) { + if (!handlersBefore.has(handler)) beforeExitHandlers.delete(handler) + } + sinon.restore() + await agent.stop() + }) + + function materialize () { + return new Promise((resolve) => tracer._exporter.flush(resolve)) + } + + /** + * @param {string} name Span name + * @returns {object|undefined} + */ + function findSpan (name) { + for (const trace of sentTraces) { + const span = trace.find(span => span.name === name) + if (span) return span + } + } + + it('wires one JS span model through the native exporter', () => { + const NativeExporter = require('../../src/exporters/native') + const DatadogSpan = require('../../src/opentracing/span') + + const span = tracer.startSpan('request') + + assert.ok(span instanceof DatadogSpan) + assert.ok(tracer._exporter instanceof NativeExporter) + assert.strictEqual(span.context()._nativeSpanId, undefined) + }) + + it('encodes finalized tags, links, events, and meta_struct for the binding', async () => { + const linked = tracer.startSpan('linked', { startTime: 1000 }) + linked.finish(1001) + + const span = tracer.startSpan('lifecycle', { + startTime: 1000, + tags: { 'custom.tag': 'custom-value', 'numeric.tag': 42 }, + }) + span.setTag('http.url', 'https://example.com') + span.addLink({ context: linked.context(), attributes: { reason: 'test' } }) + span.addEvent('event-1', { key: 'value' }, 1000.5) + span.meta_struct = { + '_dd.appsec.s.req.body': { + account: 'ruben', + omitted: undefined, + }, + } + span.finish(1001) + await materialize() + + const exported = findSpan('lifecycle') + assert.ok(exported) + assert.strictEqual(exported.meta['custom.tag'], 'custom-value') + assert.strictEqual(exported.metrics['numeric.tag'], 42) + assert.strictEqual(JSON.parse(exported.meta['_dd.span_links']).length, 1) + assert.strictEqual(exported.span_events[0].name, 'event-1') + assert.ok(exported.meta_struct['_dd.appsec.s.req.body'] instanceof Uint8Array) + }) + + it('only finishes once', async () => { + const span = tracer.startSpan('double-finish') + const processSpan = sinon.spy(tracer._processor, 'process') + + span.finish() + span.finish() + await materialize() + + sinon.assert.calledOnce(processSpan) + }) + + it('exports a parent and child in one finalized chunk', async () => { + const parent = tracer.startSpan('parent') + + tracer.scope().activate(parent, () => { + tracer.trace('child', {}, child => { + assert.strictEqual(child.context()._parentId.toString(), parent.context()._spanId.toString()) + assert.strictEqual(child.context()._trace, parent.context()._trace) + }) + }) + parent.finish() + await materialize() + + assert.ok(findSpan('parent')) + assert.ok(findSpan('child')) + assert.strictEqual(sentTraces.length, 1) + }) + + it('applies service, resource, and type through tracer.trace options', async () => { + tracer.trace('typed', { service: 'svc', resource: 'GET /x', type: 'web' }, span => { + assert.strictEqual(span.context().getTags()[SERVICE_NAME], 'svc') + assert.strictEqual(span.context().getTags()[RESOURCE_NAME], 'GET /x') + assert.strictEqual(span.context().getTags()[SPAN_TYPE], 'web') + }) + await materialize() + + const exported = findSpan('typed') + assert.strictEqual(exported.service, 'svc') + assert.strictEqual(exported.resource, 'GET /x') + assert.strictEqual(exported.type, 'web') + }) + + it('uses only the final representation after tag replacement and deletion', async () => { + const span = tracer.startSpan('final-tags') + + span.setTag('dynamic.tag', 'first') + span.setTag('dynamic.tag', 42) + span.setTag('removed.tag', 'present') + span.setTag('removed.tag', undefined) + span.addTags({ obj: { a: 1, b: 'x' } }) + span.context().clearTags() + span.setTag('service.name', 'test-service') + span.setTag('dynamic.tag', 42) + span.finish() + await materialize() + + const exported = findSpan('final-tags') + assert.strictEqual(exported.meta['dynamic.tag'], undefined) + assert.strictEqual(exported.metrics['dynamic.tag'], 42) + assert.strictEqual(exported.meta['removed.tag'], undefined) + assert.strictEqual(exported.metrics['obj.a'], undefined) + assert.strictEqual(exported.meta['obj.b'], undefined) + }) + + it('uses the final cleared error state', async () => { + const span = tracer.startSpan('final-error') + + span.setTag('error.message', 'first') + span.context().deleteTag('error.message') + span.setTag('error', 0) + span.finish() + await materialize() + + const exported = findSpan('final-error') + assert.strictEqual(exported.error, 0) + assert.strictEqual(exported.meta['error.message'], undefined) + }) + + it('propagates errors thrown inside tracer.trace callbacks', async () => { + const error = new Error('test') + assert.throws(() => tracer.trace('erroring', {}, () => { throw error }), /^Error: test$/) + await materialize() + }) + + it('round-trips trace context through inject and extract', async () => { + const span = tracer.startSpan('inject-source') + const carrier = {} + + tracer.inject(span.context(), 'text_map', carrier) + const extracted = tracer.extract('text_map', carrier) + + assert.ok(extracted) + assert.strictEqual(extracted._traceId.toString(), span.context()._traceId.toString()) + span.finish() + await materialize() + }) +}) + +describe('Native Agentless Integration', () => { + const envNames = [ + '_DD_APM_TRACING_AGENTLESS_ENABLED', + 'DD_API_KEY', + 'DD_APM_REPLACE_TAGS', + ] + let beforeExitHandlers + let handlersBefore + let previousEnv + let server + + beforeEach(() => { + beforeExitHandlers = globalThis[Symbol.for('dd-trace')].beforeExitHandlers + handlersBefore = new Set(beforeExitHandlers) + previousEnv = new Map(envNames.map(name => [name, process.env[name]])) + }) + + afterEach(async () => { + for (const [name, value] of previousEnv) { + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } + } + for (const handler of beforeExitHandlers) { + if (!handlersBefore.has(handler)) beforeExitHandlers.delete(handler) + } + delete require.cache[require.resolve('../../src/config')] + delete require.cache[require.resolve('../../src/tracer')] + sinon.restore() + if (server) { + server.closeAllConnections?.() + const closed = once(server, 'close') + server.close() + await closed + } + }) + + it('obfuscates finalized spans while preserving structured metadata', async function () { + const libdatadog = require('@datadog/libdatadog') + const pipeline = libdatadog.maybeLoad?.('pipeline') ?? libdatadog.load?.('pipeline') + if (typeof pipeline?.WasmSpanState?.prototype?.setAgentlessEndpoint !== 'function') { + this.skip() + } + + let resolveRequest + const requestReceived = new Promise(resolve => { resolveRequest = resolve }) + server = http.createServer((request, response) => { + const chunks = [] + request.on('data', chunk => chunks.push(chunk)) + request.on('end', () => { + resolveRequest({ + apiKey: request.headers['dd-api-key'], + body: Buffer.concat(chunks), + method: request.method, + url: request.url, + }) + response.writeHead(200) + response.end() + }) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const { port } = server.address() + + process.env._DD_APM_TRACING_AGENTLESS_ENABLED = 'true' + process.env.DD_API_KEY = 'test-api-key' + process.env.DD_APM_REPLACE_TAGS = JSON.stringify([{ + name: 'custom.secret', + pattern: 'sensitive-value', + repl: '?', + }]) + delete require.cache[require.resolve('../../src/config')] + delete require.cache[require.resolve('../../src/tracer')] + + const getConfig = require('../../src/config') + const config = getConfig({ flushInterval: 60_000, service: 'agentless-service' }) + const Tracer = require('../../src/tracer') + const tracer = new Tracer(config) + tracer.setUrl(`http://127.0.0.1:${port}`) + + const span = tracer.startSpan('agentless-request') + span.setTag('custom.secret', 'sensitive-value') + span.meta_struct = { + '_dd.appsec.s.req.body': { + blocked: true, + omitted: undefined, + value: 'appsec-value', + }, + } + span.finish() + await new Promise(resolve => tracer._exporter.flush(resolve)) + + const request = await requestReceived + assert.strictEqual(request.method, 'POST') + assert.strictEqual(request.url, '/api/v2/spans') + assert.strictEqual(request.apiKey, 'test-api-key') + assert.strictEqual(request.body.includes(Buffer.from('sensitive-value')), false) + const payload = JSON.parse(request.body) + const exported = payload.traces[0].spans[0] + assert.strictEqual(exported.meta['custom.secret'], '?') + assert.deepStrictEqual(exported.meta_struct['_dd.appsec.s.req.body'], { + blocked: true, + value: 'appsec-value', + }) + }) +}) diff --git a/packages/dd-trace/test/native/native-spans.spec.js b/packages/dd-trace/test/native/native-spans.spec.js new file mode 100644 index 00000000000..4ce6d4e79a9 --- /dev/null +++ b/packages/dd-trace/test/native/native-spans.spec.js @@ -0,0 +1,371 @@ +'use strict' + +const assert = require('node:assert/strict') + +const proxyquire = require('proxyquire').noCallThru() +const sinon = require('sinon') + +require('../setup/core') + +const baseOptions = { + agentUrl: 'http://localhost:8126', + tracerVersion: '1.0.0', + lang: 'nodejs', + langVersion: 'v20.0.0', + langInterpreter: 'v8', + pid: 12345, + tracerService: 'test-service', +} +const encodedPayload = Buffer.from([0xDD, 0, 0, 0, 1, 0xDD, 0, 0, 0, 0]) + +function deferred () { + let resolveOperation + let rejectOperation + const promise = new Promise((resolve, reject) => { + resolveOperation = resolve + rejectOperation = reject + }) + return { promise, reject: rejectOperation, resolve: resolveOperation } +} + +function createState () { + return { + flushStats: sinon.stub().resolves(true), + free: sinon.stub(), + sendEncodedTraces: sinon.stub().resolves('OK'), + setAgentlessEndpoint: sinon.stub(), + setOtlpEndpoint: sinon.stub(), + setOtlpHeaders: sinon.stub(), + setOtlpProtocol: sinon.stub(), + setUseV05: sinon.stub(), + } +} + +describe('NativeSpansInterface', () => { + let NativeSpansInterface + let WasmSpanState + let logError + let metricsCount + let states + + /** + * @param {object} [options] + * @returns {import('../../src/native/native-spans')} + */ + function createInterface (options = {}) { + return new NativeSpansInterface({ ...baseOptions, ...options }) + } + + beforeEach(() => { + states = [] + WasmSpanState = sinon.stub().callsFake(() => { + const state = createState() + states.push(state) + return state + }) + logError = sinon.stub() + metricsCount = sinon.stub() + NativeSpansInterface = proxyquire('../../src/native/native-spans', { + './index': { WasmSpanState }, + '../log': { debug: sinon.stub(), error: logError }, + '../runtime_metrics': { count: metricsCount }, + }) + }) + + afterEach(() => { + sinon.restore() + }) + + it('constructs the binding state without allocating unused transfer buffers', () => { + createInterface() + + sinon.assert.calledOnce(WasmSpanState) + assert.deepStrictEqual(WasmSpanState.firstCall.args, [ + 'http://localhost:8126', + '1.0.0', + 'nodejs', + 'v20.0.0', + 'v8', + 8, + 0, + 12345, + 'test-service', + false, + '', + '', + '', + '', + false, + ]) + }) + + it('uses runtime defaults for omitted binding metadata', () => { + createInterface({ + lang: undefined, + langVersion: undefined, + langInterpreter: undefined, + pid: undefined, + }) + + assert.deepStrictEqual(WasmSpanState.firstCall.args, [ + 'http://localhost:8126', + '1.0.0', + 'nodejs', + process.version, + 'v8', + 8, + 0, + process.pid, + 'test-service', + false, + '', + '', + '', + '', + false, + ]) + }) + + it('rejects a binding without encoded-trace support and frees its state', () => { + const state = createState() + state.sendEncodedTraces = undefined + WasmSpanState.returns(state) + + assert.throws(() => createInterface(), /pipeline is missing sendEncodedTraces/) + sinon.assert.calledOnce(state.free) + }) + + it('rejects when the native pipeline is unavailable', () => { + NativeSpansInterface = proxyquire('../../src/native/native-spans', { + './index': { WasmSpanState: undefined }, + '../log': { debug: sinon.stub(), error: logError }, + '../runtime_metrics': { count: metricsCount }, + }) + + assert.throws(() => createInterface(), /Native spans module is not available/) + }) + + it('transfers the encoded payload to the binding unchanged', async () => { + const nativeSpans = createInterface() + + assert.strictEqual(await nativeSpans.sendEncodedTraces(encodedPayload), 'OK') + sinon.assert.calledOnceWithExactly(states[0].sendEncodedTraces, encodedPayload) + }) + + it('does not call the stats API when stats are disabled', async () => { + const nativeSpans = createInterface() + + assert.strictEqual(await nativeSpans.flushStats(), true) + sinon.assert.notCalled(states[0].flushStats) + }) + + it('force-flushes stats and reports collapsed spans', async () => { + const nativeSpans = createInterface({ statsEnabled: true }) + states[0].flushStats.resolves({ sent: true, collapsedSpans: 3 }) + + assert.strictEqual(await nativeSpans.flushStats(), true) + sinon.assert.calledOnceWithExactly(states[0].flushStats, true) + sinon.assert.calledOnceWithExactly( + metricsCount, + 'datadog.tracer.stats.collapsed_spans', + 3, + 'collapsed_spans:whole_key', + true, + ) + }) + + it('flushes periodic stats without forcing partial buckets', async () => { + const clock = sinon.useFakeTimers() + createInterface({ statsEnabled: true }) + states[0].flushStats.resolves({ sent: false, collapsedSpans: 2 }) + + await clock.tickAsync(10_000) + + sinon.assert.calledOnceWithExactly(states[0].flushStats, false) + sinon.assert.calledOnceWithExactly( + metricsCount, + 'datadog.tracer.stats.collapsed_spans', + 2, + 'collapsed_spans:whole_key', + true, + ) + }) + + it('logs a rejected periodic stats flush', async () => { + const clock = sinon.useFakeTimers() + const error = new Error('stats failed') + createInterface({ statsEnabled: true }) + states[0].flushStats.rejects(error) + + await clock.tickAsync(10_000) + + sinon.assert.calledOnceWithExactly(logError, 'Error flushing native stats: %s', error) + }) + + it('logs a synchronous periodic stats flush failure', async () => { + const clock = sinon.useFakeTimers() + const error = new Error('stats failed') + createInterface({ statsEnabled: true }) + states[0].flushStats.throws(error) + + await clock.tickAsync(10_000) + + sinon.assert.calledOnceWithExactly(logError, 'Error flushing native stats: %s', error) + }) + + it('rejects a synchronous forced stats flush failure', async () => { + const error = new Error('stats failed') + const nativeSpans = createInterface({ statsEnabled: true }) + states[0].flushStats.throws(error) + + await assert.rejects(nativeSpans.flushStats(), error) + }) + + it('replays successful native configuration when the agent URL changes', () => { + const nativeSpans = createInterface() + nativeSpans.setUseV05(true) + nativeSpans.setOtlpEndpoint('http://collector:4318/v1/traces') + nativeSpans.setOtlpProtocol('http/protobuf') + const headers = ['authorization', 'secret'] + nativeSpans.setOtlpHeaders(headers) + headers[1] = 'changed' + + nativeSpans.setAgentUrl('http://new-agent:8126') + + assert.strictEqual(WasmSpanState.secondCall.args[0], 'http://new-agent:8126') + sinon.assert.calledOnceWithExactly(states[1].setUseV05, true) + sinon.assert.calledOnceWithExactly(states[1].setOtlpEndpoint, 'http://collector:4318/v1/traces') + sinon.assert.calledOnceWithExactly(states[1].setOtlpProtocol, 'http/protobuf') + sinon.assert.calledOnceWithExactly(states[1].setOtlpHeaders, ['authorization', 'secret']) + sinon.assert.calledOnce(states[0].free) + }) + + it('replays agentless configuration when native state is replaced', () => { + const nativeSpans = createInterface() + nativeSpans.setAgentlessEndpoint('https://intake.example/api/v2/spans', 'test-api-key') + + nativeSpans.setAgentUrl('http://new-agent:8126') + + sinon.assert.calledOnceWithExactly( + states[1].setAgentlessEndpoint, + 'https://intake.example/api/v2/spans', + 'test-api-key', + ) + sinon.assert.calledOnce(states[0].free) + }) + + it('replaces native state when the agentless endpoint changes', () => { + const nativeSpans = createInterface() + nativeSpans.setAgentlessEndpoint('https://first.example/api/v2/spans', 'first-key') + + nativeSpans.setAgentlessEndpoint('https://second.example/api/v2/spans', 'second-key') + + sinon.assert.calledOnceWithExactly( + states[1].setAgentlessEndpoint, + 'https://second.example/api/v2/spans', + 'second-key', + ) + sinon.assert.calledOnce(states[0].free) + }) + + it('keeps the active agentless state when replacement configuration fails', async () => { + const error = new Error('invalid replacement rule') + const nativeSpans = createInterface() + nativeSpans.setAgentlessEndpoint('https://first.example/api/v2/spans', 'first-key') + const replacement = createState() + replacement.setAgentlessEndpoint.throws(error) + WasmSpanState.onSecondCall().returns(replacement) + + assert.throws( + () => nativeSpans.setAgentlessEndpoint('https://second.example/api/v2/spans', 'second-key'), + error, + ) + sinon.assert.calledOnce(replacement.free) + sinon.assert.notCalled(states[0].free) + assert.strictEqual(await nativeSpans.sendEncodedTraces(encodedPayload), 'OK') + + nativeSpans.setAgentUrl('http://new-agent:8126') + sinon.assert.calledOnceWithExactly( + states[1].setAgentlessEndpoint, + 'https://first.example/api/v2/spans', + 'first-key', + ) + }) + + it('keeps the old state until all of its asynchronous operations settle', async () => { + const traceSend = deferred() + const statsFlush = deferred() + const nativeSpans = createInterface({ statsEnabled: true }) + states[0].sendEncodedTraces.returns(traceSend.promise) + states[0].flushStats.returns(statsFlush.promise) + + const send = nativeSpans.sendEncodedTraces(encodedPayload) + const flush = nativeSpans.flushStats() + nativeSpans.setAgentUrl('http://new-agent:8126') + sinon.assert.notCalled(states[0].free) + + traceSend.resolve('OK') + await send + sinon.assert.notCalled(states[0].free) + + statsFlush.resolve(true) + await flush + sinon.assert.calledOnce(states[0].free) + }) + + it('releases a retired state after a rejected operation', async () => { + const traceSend = deferred() + const error = new Error('send failed') + const nativeSpans = createInterface() + states[0].sendEncodedTraces.returns(traceSend.promise) + + const send = assert.rejects(nativeSpans.sendEncodedTraces(encodedPayload), error) + nativeSpans.setAgentUrl('http://new-agent:8126') + traceSend.reject(error) + + await send + sinon.assert.calledOnce(states[0].free) + }) + + it('keeps the active state when replacement configuration fails', async () => { + const error = new Error('invalid endpoint') + const nativeSpans = createInterface() + nativeSpans.setOtlpEndpoint('http://collector:4318/v1/traces') + const replacement = createState() + replacement.setOtlpEndpoint.throws(error) + WasmSpanState.onSecondCall().returns(replacement) + + assert.throws(() => nativeSpans.setAgentUrl('http://new-agent:8126'), error) + sinon.assert.calledOnce(replacement.free) + sinon.assert.notCalled(states[0].free) + assert.strictEqual(await nativeSpans.sendEncodedTraces(encodedPayload), 'OK') + sinon.assert.calledOnce(states[0].sendEncodedTraces) + }) + + it('does not persist native configuration that the binding rejects', () => { + const error = new Error('unsupported protocol') + const nativeSpans = createInterface() + states[0].setOtlpProtocol.throws(error) + + assert.throws(() => nativeSpans.setOtlpProtocol('grpc'), error) + nativeSpans.setAgentUrl('http://new-agent:8126') + + sinon.assert.notCalled(states[1].setOtlpProtocol) + }) + + it('normalizes agent URLs at construction and replacement', () => { + const cases = [ + ['unix:///var/run/datadog/apm.socket', 'unix:///var/run/datadog/apm.socket'], + ['unix://./pipe/datadog-apm', 'windows://./pipe/datadog-apm'], + ['windows://./pipe/datadog-apm', 'windows://./pipe/datadog-apm'], + ['https://agent.example:8126', 'https://agent.example:8126'], + ] + + for (const [input, expected] of cases) { + const nativeSpans = createInterface({ agentUrl: input }) + assert.strictEqual(WasmSpanState.lastCall.args[0], expected) + nativeSpans.setAgentUrl(input) + assert.strictEqual(WasmSpanState.lastCall.args[0], expected) + } + }) +}) diff --git a/packages/dd-trace/test/native/response-headers.spec.js b/packages/dd-trace/test/native/response-headers.spec.js new file mode 100644 index 00000000000..65808dc2878 --- /dev/null +++ b/packages/dd-trace/test/native/response-headers.spec.js @@ -0,0 +1,149 @@ +'use strict' + +const assert = require('node:assert/strict') + +const sinon = require('sinon') +const proxyquire = require('proxyquire').noCallThru() + +require('../setup/core') + +describe('native response header observer', () => { + let responseHeaderObserver + let updateContainerTagsHash + + beforeEach(() => { + updateContainerTagsHash = sinon.stub() + const pipeline = { + WasmSpanState: class WasmSpanState {}, + init: sinon.stub(), + setResponseHeaderObserver: sinon.stub().callsFake((observer) => { + responseHeaderObserver = observer + }), + setStorage: sinon.stub(), + } + const native = proxyquire('../../src/native', { + '@datadog/libdatadog': { + load: sinon.stub().returns(pipeline), + pipelineApiVersion: 1, + }, + '../propagation-hash': { updateContainerTagsHash }, + }) + + assert.strictEqual(native.pipelineApiVersion, 1) + assert.ok(native.WasmSpanState) + sinon.assert.calledOnceWithExactly(pipeline.setResponseHeaderObserver, responseHeaderObserver) + }) + + it('feeds Datadog-Container-Tags-Hash to the propagation hash', () => { + // Without this the native path hashes process tags alone, so DBM SQL comments + // and DSM pathway hashes cannot correlate with container tags. + responseHeaderObserver(['Content-Type', 'application/json', 'Datadog-Container-Tags-Hash', 'abc123']) + + sinon.assert.calledOnceWithExactly(updateContainerTagsHash, 'abc123') + }) + + it('reports an older binding without a pipeline API marker', () => { + const native = proxyquire('../../src/native', { + '@datadog/libdatadog': {}, + }) + + assert.strictEqual(native.pipelineApiVersion, 0) + }) + + it('caches the pipeline only after setup completes', () => { + const expected = new Error('storage setup failed') + const pipeline = { + WasmSpanState: class WasmSpanState {}, + init: sinon.stub(), + setResponseHeaderObserver: sinon.stub(), + setStorage: sinon.stub(), + } + pipeline.setStorage.onFirstCall().throws(expected) + const load = sinon.stub().returns(pipeline) + const native = proxyquire('../../src/native', { + '@datadog/libdatadog': { load }, + '../propagation-hash': { updateContainerTagsHash }, + }) + + assert.throws(() => native.WasmSpanState, expected) + assert.strictEqual(native.WasmSpanState, pipeline.WasmSpanState) + sinon.assert.calledTwice(load) + sinon.assert.calledTwice(pipeline.init) + sinon.assert.calledTwice(pipeline.setStorage) + sinon.assert.calledOnce(pipeline.setResponseHeaderObserver) + }) + + it('rejects a pipeline without native span state', () => { + const pipeline = { + init: sinon.stub(), + setResponseHeaderObserver: sinon.stub(), + setStorage: sinon.stub(), + } + const native = proxyquire('../../src/native', { + '@datadog/libdatadog': { load: sinon.stub().returns(pipeline) }, + '../propagation-hash': { updateContainerTagsHash }, + }) + + assert.throws( + () => native.WasmSpanState, + /@datadog\/libdatadog pipeline crate is missing WasmSpanState/, + ) + sinon.assert.notCalled(pipeline.init) + }) + + it('rejects recursive native interface loading', () => { + const legacy = { + enterWith: sinon.stub(), + getStore: sinon.stub(), + run: sinon.stub(), + } + legacy.enterWith.onFirstCall().callsFake(() => native.NativeSpansInterface) + const native = proxyquire('../../src/native', { + '../../../datadog-core': { storage: sinon.stub().returns(legacy) }, + '@datadog/libdatadog': { load: sinon.stub() }, + '../propagation-hash': { updateContainerTagsHash }, + }) + + assert.throws(() => native.NativeSpansInterface, /Recursive native module load detected/) + sinon.assert.calledTwice(legacy.enterWith) + }) + + it('matches the header case-insensitively', () => { + // rawHeaders preserves whatever casing the agent sent. + responseHeaderObserver(['datadog-container-tags-hash', 'lower']) + + sinon.assert.calledOnceWithExactly(updateContainerTagsHash, 'lower') + }) + + it('takes the first value when the agent repeats the header', () => { + responseHeaderObserver([ + 'Datadog-Container-Tags-Hash', 'first', + 'Datadog-Container-Tags-Hash', 'second', + ]) + + sinon.assert.calledOnceWithExactly(updateContainerTagsHash, 'first') + }) + + it('ignores a response without the header', () => { + responseHeaderObserver(['Content-Type', 'application/json']) + + sinon.assert.notCalled(updateContainerTagsHash) + }) + + it('ignores an empty hash value', () => { + responseHeaderObserver(['Datadog-Container-Tags-Hash', '']) + + sinon.assert.notCalled(updateContainerTagsHash) + }) + + it('tolerates a non-array or odd-length payload', () => { + // The transport catches observer throws, but a throw would still mean the + // hash silently stops updating, so handle the shapes here. A throw from any + // of these fails the test directly. + for (const payload of [undefined, null, {}, 'nope', ['Datadog-Container-Tags-Hash']]) { + responseHeaderObserver(payload) + } + + sinon.assert.notCalled(updateContainerTagsHash) + }) +}) diff --git a/packages/dd-trace/test/opentelemetry/context_manager.spec.js b/packages/dd-trace/test/opentelemetry/context_manager.spec.js index 49488c68c4d..c71bf3656a8 100644 --- a/packages/dd-trace/test/opentelemetry/context_manager.spec.js +++ b/packages/dd-trace/test/opentelemetry/context_manager.spec.js @@ -275,8 +275,8 @@ describe('OTel Context Manager', () => { active.addEvent('with-attrs-and-hr-time', { code: 42 }, hrTime) // Single equality guards: no array-indexed attribute leak on the time-only forms, - // numeric startTime (not hrTime array) so span_format's Math.round(startTime * 1e6) - // cannot produce NaN. + // and the recorded startTime is numeric (not an hrTime array) so the downstream + // ms-conversion cannot produce NaN. assert.deepStrictEqual(ddSpan._events, [ { name: 'with-hr-time', startTime: hrTimeMs }, { name: 'with-date', startTime: date.getTime() }, diff --git a/packages/dd-trace/test/opentelemetry/next-otel-span-naming.spec.js b/packages/dd-trace/test/opentelemetry/next-otel-span-naming.spec.js index 0b243ac89a7..89ade319cab 100644 --- a/packages/dd-trace/test/opentelemetry/next-otel-span-naming.spec.js +++ b/packages/dd-trace/test/opentelemetry/next-otel-span-naming.spec.js @@ -20,10 +20,10 @@ const TracerProvider = require('../../src/opentelemetry/tracer_provider') const NEXT_HANDLE_REQUEST = 'BaseServer.handleRequest' // Capture the span as the exporter receives it, i.e. after the trace has been -// formatted and is about to be written. The Next root span is the only span in -// its trace, so `Span.end()` -> `_ddSpan.finish()` builds and exports the -// payload synchronously; asserting here proves the correction reached the wire -// rather than a post-finish re-format that no exported trace ever sees. +// formatted on the JS path or passed to the native exporter. The Next root span +// is the only span in its trace, so `Span.end()` -> `_ddSpan.finish()` builds and +// exports synchronously; asserting here proves the correction reached the export +// boundary rather than a post-finish re-format that no exported trace ever sees. function captureExportedRootSpan (run) { const exporter = tracer._tracer._exporter const originalExport = exporter.export @@ -36,7 +36,15 @@ function captureExportedRootSpan (run) { } finally { exporter.export = originalExport } - return exported + return normalizeExportedSpan(exported) +} + +function normalizeExportedSpan (span) { + const context = span.context?.() + return { + name: span.name ?? context?._name, + resource: span.resource ?? context?.getTag('resource.name'), + } } function startNextRootSpan ({ method = 'GET', initialName } = {}) { diff --git a/packages/dd-trace/test/opentelemetry/span-helpers.spec.js b/packages/dd-trace/test/opentelemetry/span-helpers.spec.js index 1c18780f70c..b81cea3d693 100644 --- a/packages/dd-trace/test/opentelemetry/span-helpers.spec.js +++ b/packages/dd-trace/test/opentelemetry/span-helpers.spec.js @@ -349,16 +349,18 @@ describe('OTel bridge helpers', () => { assert.strictEqual(ddSpan.tags[ERROR_MESSAGE], 'second') }) - it('records the OK transition out of ERROR so future ERRORs are locked', () => { + it('clears ERROR tags and records error=0 when OK overrides ERROR', () => { const ddSpan = createMockDdSpan() applyOtelStatus(ddSpan, 0, { code: 2, message: 'first' }, false) const afterOk = applyOtelStatus(ddSpan, 2, { code: 1 }, false) assert.strictEqual(afterOk, 1) + assert.strictEqual(ddSpan.tags[ERROR_MESSAGE], undefined) + assert.strictEqual(ddSpan.tags[IGNORE_OTEL_ERROR], undefined) + assert.strictEqual(ddSpan.tags.error, 0) const stillOk = applyOtelStatus(ddSpan, 1, { code: 2, message: 'should be ignored' }, false) assert.strictEqual(stillOk, 1) - // The first ERROR's message stays. Tag clearing on OK override is out of scope. - assert.strictEqual(ddSpan.tags[ERROR_MESSAGE], 'first') + assert.strictEqual(ddSpan.tags[ERROR_MESSAGE], undefined) }) describe('setOtelOperationName vs setOtelResource', () => { diff --git a/packages/dd-trace/test/opentelemetry/span.spec.js b/packages/dd-trace/test/opentelemetry/span.spec.js index eab1ef43e3b..e8eb362fdab 100644 --- a/packages/dd-trace/test/opentelemetry/span.spec.js +++ b/packages/dd-trace/test/opentelemetry/span.spec.js @@ -13,16 +13,18 @@ const { timeInputToHrTime } = require('../../../../vendor/dist/@opentelemetry/co require('../setup/core') -const tracer = require('../../').init() +const tracer = require('../../').init({ experimental: { exporter: 'log' } }) +tracer._tracer._exporter.export = sinon.stub() const TracerProvider = require('../../src/opentelemetry/tracer_provider') const SpanContext = require('../../src/opentelemetry/span_context') const { NoopSpanProcessor } = require('../../src/opentelemetry/span_processor') +const DatadogSpan = require('../../src/opentracing/span') +const spanFormat = require('../../src/span_format') const { ERROR_MESSAGE, ERROR_STACK, ERROR_TYPE, IGNORE_OTEL_ERROR } = require('../../src/constants') const { SERVICE_NAME, RESOURCE_NAME, SPAN_KIND } = require('../../../../ext/tags') const kinds = require('../../../../ext/kinds') -const spanFormat = require('../../src/span_format') const spanKindNames = { [api.SpanKind.INTERNAL]: kinds.INTERNAL, @@ -48,9 +50,15 @@ describe('OTel Span', () => { assert.strictEqual(context._hostname, tracer._hostname) }) + it('should use plain Datadog spans', () => { + const span = makeSpan('name') + + assert.strictEqual(span._ddSpan.constructor, DatadogSpan) + }) + it('should apply global config tags (DD_TAGS / OTEL_RESOURCE_ATTRIBUTES) to bridged spans', () => { // OTEL_RESOURCE_ATTRIBUTES and DD_TAGS are parsed into config.tags; the OTel - // bridge must apply them to bridged spans just like the native path does. + // bridge must apply them to bridged spans just like the OpenTracing path does. const { tags } = tracer._tracer._config tags.dd_llmobs_enabled = 'false' try { diff --git a/packages/dd-trace/test/opentelemetry/tracer_provider.spec.js b/packages/dd-trace/test/opentelemetry/tracer_provider.spec.js index 8c758f6ff7e..14a779b9f42 100644 --- a/packages/dd-trace/test/opentelemetry/tracer_provider.spec.js +++ b/packages/dd-trace/test/opentelemetry/tracer_provider.spec.js @@ -12,6 +12,21 @@ const Tracer = require('../../src/opentelemetry/tracer') const { MultiSpanProcessor, NoopSpanProcessor } = require('../../src/opentelemetry/span_processor') require('../../index').init() +/** + * @param {object} exporter + * @param {() => void} callback + */ +function withExporter (exporter, callback) { + const ddTracer = require('../../index')._tracer + const originalExporter = ddTracer._exporter + ddTracer._exporter = exporter + try { + callback() + } finally { + ddTracer._exporter = originalExporter + } +} + describe('OTel TracerProvider', () => { it('should register with OTel API', () => { const provider = new TracerProvider() @@ -118,8 +133,25 @@ describe('OTel TracerProvider', () => { const processor = new NoopSpanProcessor() provider.addSpanProcessor(processor) processor.forceFlush = sinon.stub() + const flush = sinon.stub() + + withExporter({ flush }, () => provider.forceFlush()) + sinon.assert.calledOnce(flush) + sinon.assert.calledOnce(processor.forceFlush) + }) + + it('still delegates forceFlush when the exporter has no flush method', () => { + // A Lambda with neither the extension nor the mini agent gets the stdout + // exporter, which writes synchronously and implements only `export`. An + // unguarded `exporter.flush()` turned forceFlush() into a TypeError there, so + // the active span processor never got flushed either. + const provider = new TracerProvider() + const processor = new NoopSpanProcessor() + provider.addSpanProcessor(processor) + processor.forceFlush = sinon.stub() + + withExporter({ export: sinon.stub() }, () => provider.forceFlush()) - provider.forceFlush() sinon.assert.calledOnce(processor.forceFlush) }) }) diff --git a/packages/dd-trace/test/opentelemetry/traces.spec.js b/packages/dd-trace/test/opentelemetry/traces.spec.js index 4cd0e3fc573..d664895e9c9 100644 --- a/packages/dd-trace/test/opentelemetry/traces.spec.js +++ b/packages/dd-trace/test/opentelemetry/traces.spec.js @@ -732,23 +732,6 @@ describe('OpenTelemetry Traces', () => { exporter.export([createMockSpan({ metrics: { _sampling_priority_v1: -1 } })]) assert(!exportCalled, 'No HTTP request should be made for user-rejected traces') }) - - it('DatadogTracer uses the OTLP exporter when OTEL_TRACES_EXPORTER=otlp', () => { - process.env.OTEL_TRACES_EXPORTER = 'otlp' - const DatadogTracer = proxyquire.noPreserveCache()('../../src/opentracing/tracer', {}) - const tracer = new DatadogTracer(getConfigFresh()) - assert(tracer._exporter instanceof OtlpHttpTraceExporter, - 'Exporter should be the OTLP exporter when OTEL_TRACES_EXPORTER=otlp') - }) - - it('DatadogTracer does not use the OTLP exporter when OTEL_TRACES_EXPORTER is not otlp', () => { - delete process.env.OTEL_TRACES_EXPORTER - const DatadogTracer = proxyquire.noPreserveCache()('../../src/opentracing/tracer', {}) - const tracer = new DatadogTracer(getConfigFresh()) - assert(!(tracer._exporter instanceof OtlpHttpTraceExporter), - 'Exporter should not be the OTLP exporter when OTEL_TRACES_EXPORTER is not otlp') - }) - it('DatadogTracer prefers the Electron exporter over OTLP when OTEL_TRACES_EXPORTER=otlp', () => { process.env.OTEL_TRACES_EXPORTER = 'otlp' const DatadogTracer = proxyquire.noPreserveCache()('../../src/opentracing/tracer', {}) diff --git a/packages/dd-trace/test/opentracing/tracer.spec.js b/packages/dd-trace/test/opentracing/tracer.spec.js index 3ee76a500c5..47871c4bee7 100644 --- a/packages/dd-trace/test/opentracing/tracer.spec.js +++ b/packages/dd-trace/test/opentracing/tracer.spec.js @@ -16,17 +16,28 @@ const Reference = opentracing.Reference describe('Tracer', () => { let Tracer + let loadTracer let tracer - let Span + let DatadogSpan let span let spanCtx let PrioritySampler let prioritySampler - let AgentExporter + let NativeExporter let SpanProcessor let processor let exporter let agentExporter + let AgentExporter + let logExporter + let LogExporter + let agentlessExporter + let AgentlessExporter + let getExporter + let otlpTraceExporter + let createOtlpTraceExporter + let nativeSpansInstance + let NativeSpansInterface let spanContext let fields let carrier @@ -49,23 +60,43 @@ describe('Tracer', () => { addTags: sinon.stub().returns(span), context: sinon.stub().returns(spanCtx), } - Span = sinon.stub().returns(span) + DatadogSpan = sinon.stub().returns(span) prioritySampler = { sample: sinon.stub(), } PrioritySampler = sinon.stub().returns(prioritySampler) - agentExporter = { + exporter = { export: sinon.spy(), } - AgentExporter = sinon.stub().returns(agentExporter) + NativeExporter = sinon.stub().returns(exporter) processor = { process: sinon.spy(), } SpanProcessor = sinon.stub().returns(processor) + agentExporter = { + export: sinon.spy(), + _url: config?.url, + } + AgentExporter = sinon.stub().returns(agentExporter) + + logExporter = { + export: sinon.spy(), + } + LogExporter = sinon.stub().returns(logExporter) + agentlessExporter = { + export: sinon.spy(), + } + AgentlessExporter = sinon.stub().returns(agentlessExporter) + otlpTraceExporter = { export: sinon.spy() } + createOtlpTraceExporter = sinon.stub().returns(otlpTraceExporter) + + nativeSpansInstance = {} + NativeSpansInterface = sinon.stub().returns(nativeSpansInstance) + spanContext = {} carrier = {} @@ -93,38 +124,448 @@ describe('Tracer', () => { use: sinon.spy(), toggle: sinon.spy(), error: sinon.spy(), + warn: sinon.spy(), + debug: sinon.spy(), } - exporter = sinon.stub().returns(AgentExporter) - - Tracer = proxyquire('../../src/opentracing/tracer', { - './span': Span, - './span_context': SpanContext, - '../priority_sampler': PrioritySampler, - '../span_processor': SpanProcessor, - './propagation/text_map': TextMapPropagator, - './propagation/http': HttpPropagator, - './propagation/binary': BinaryPropagator, - './propagation/log': LogPropagator, - '../log': log, - '../exporter': exporter, - }) + loadTracer = ({ + agentlessSupported = true, + encodedTracesSupported = true, + isAWSLambda = false, + nativeError, + pipelineApiVersion = 1, + jsExporter = AgentExporter, + createOtlpSpanStatsExporter = sinon.stub(), + } = {}) => { + getExporter = sinon.stub().returns(jsExporter) + getExporter.withArgs('log').returns(LogExporter) + getExporter.withArgs('agentless').returns(AgentlessExporter) + let WasmSpanState + if (pipelineApiVersion < 1 || !encodedTracesSupported) { + WasmSpanState = class WasmSpanState {} + } else if (!agentlessSupported) { + WasmSpanState = class WasmSpanState { sendEncodedTraces () {} } + } else { + WasmSpanState = class WasmSpanState { + sendEncodedTraces () {} + setAgentlessEndpoint () {} + } + } + return proxyquire('../../src/opentracing/tracer', { + './span_context': SpanContext, + './span': DatadogSpan, + '../exporter': getExporter, + '../priority_sampler': PrioritySampler, + '../span_processor': SpanProcessor, + './propagation/text_map': TextMapPropagator, + './propagation/http': HttpPropagator, + './propagation/binary': BinaryPropagator, + './propagation/log': LogPropagator, + '../log': log, + '../exporters/native': NativeExporter, + '../opentelemetry/trace': { createOtlpTraceExporter }, + '../opentelemetry/metrics': { createOtlpSpanStatsExporter, '@noCallThru': true }, + '../serverless': { getIsAWSLambda: () => isAWSLambda }, + '../native': { + pipelineApiVersion, + WasmSpanState, + get NativeSpansInterface () { + if (nativeError) throw nativeError + return NativeSpansInterface + }, + }, + }) + } + Tracer = loadTracer() }) it('should support recording', () => { tracer = new Tracer(config) - sinon.assert.called(AgentExporter) - sinon.assert.calledWith(AgentExporter, config, prioritySampler) - sinon.assert.calledWith(SpanProcessor, agentExporter, prioritySampler, config) + sinon.assert.called(NativeExporter) + sinon.assert.calledWith(NativeExporter, config, prioritySampler, nativeSpansInstance) + sinon.assert.calledOnceWithExactly(SpanProcessor, exporter, prioritySampler, config, undefined, false) }) it('should allow to configure an alternative prioritySampler', () => { const sampler = {} tracer = new Tracer(config, sampler) - sinon.assert.calledWith(AgentExporter, config, sampler) - sinon.assert.calledWith(SpanProcessor, agentExporter, sampler, config) + sinon.assert.calledWith(NativeExporter, config, sampler, nativeSpansInstance) + sinon.assert.calledOnceWithExactly(SpanProcessor, exporter, sampler, config, undefined, false) + }) + + it('uses the JS pipeline for the configured log exporter', () => { + config.experimental.exporter = 'log' + + tracer = new Tracer(config) + + sinon.assert.notCalled(NativeExporter) + sinon.assert.calledOnceWithExactly(LogExporter, config, prioritySampler) + sinon.assert.calledOnceWithExactly(SpanProcessor, logExporter, prioritySampler, config, undefined) + }) + + it('uses the JS pipeline in Test Optimization mode', () => { + config.isCiVisibility = true + + tracer = new Tracer(config) + + sinon.assert.notCalled(NativeExporter) + sinon.assert.calledOnceWithExactly(AgentExporter, config, prioritySampler) + sinon.assert.calledWith(log.debug, 'CI Visibility mode enabled (JS span pipeline)', undefined) + }) + + it('uses the native pipeline for the configured agentless exporter', () => { + config.experimental.exporter = 'agentless' + config.stats = { DD_TRACE_STATS_COMPUTATION_ENABLED: true } + + tracer = new Tracer(config) + + sinon.assert.notCalled(AgentlessExporter) + sinon.assert.calledOnce(NativeSpansInterface) + assert.strictEqual(NativeSpansInterface.firstCall.args[0].statsEnabled, false) + sinon.assert.calledOnceWithExactly(NativeExporter, config, prioritySampler, nativeSpansInstance) + sinon.assert.calledOnceWithExactly(SpanProcessor, exporter, prioritySampler, config, undefined, false) + }) + + it('warns and uses the native exporter for unsupported APM exporters', () => { + config.experimental.exporter = 'unsupported' + + tracer = new Tracer(config) + + sinon.assert.calledWith( + log.warn, + 'Native exporter ignores unsupported experimental exporter "%s"; using native agent exporter', + 'unsupported' + ) + sinon.assert.calledWith(NativeExporter, config, prioritySampler, nativeSpansInstance) + }) + + it('uses the JS agent pipeline in AWS Lambda when a local agent is present', () => { + Tracer = loadTracer({ isAWSLambda: true }) + + tracer = new Tracer(config) + + assert.strictEqual(tracer._isCiVisibility, false) + sinon.assert.notCalled(NativeExporter) + sinon.assert.notCalled(NativeSpansInterface) + sinon.assert.notCalled(LogExporter) + sinon.assert.calledOnceWithExactly(AgentExporter, config, prioritySampler) + sinon.assert.calledWithExactly(getExporter, undefined) + sinon.assert.calledOnceWithExactly(SpanProcessor, agentExporter, prioritySampler, config, undefined) + sinon.assert.calledWith(log.debug, 'AWS Lambda environment detected (JS span pipeline)') + }) + + it('preserves native agentless export in AWS Lambda environments', () => { + config.experimental.exporter = 'agentless' + Tracer = loadTracer({ isAWSLambda: true }) + + tracer = new Tracer(config) + + sinon.assert.notCalled(AgentlessExporter) + sinon.assert.calledOnce(NativeSpansInterface) + sinon.assert.calledOnceWithExactly(NativeExporter, config, prioritySampler, nativeSpansInstance) + }) + + it('exports to stdout in AWS Lambda when neither the extension nor the mini agent is present', () => { + // The Datadog Forwarder deployment has no local agent: traces are written to + // stdout and shipped from CloudWatch. Sending them to 127.0.0.1:8126 instead + // (config also forces flushInterval=0 here) loses every trace silently. + Tracer = loadTracer({ isAWSLambda: true, jsExporter: LogExporter }) + + tracer = new Tracer(config) + + sinon.assert.notCalled(NativeExporter) + sinon.assert.notCalled(NativeSpansInterface) + sinon.assert.notCalled(AgentExporter) + sinon.assert.calledOnceWithExactly(LogExporter, config, prioritySampler) + sinon.assert.calledWithExactly(getExporter, undefined) + sinon.assert.calledOnceWithExactly(SpanProcessor, logExporter, prioritySampler, config, undefined) + }) + + it('preserves explicit OTLP export in AWS Lambda environments', () => { + config.OTEL_TRACES_EXPORTER = 'otlp' + config.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'http://collector.example:4318/v1/traces' + Tracer = loadTracer({ isAWSLambda: true }) + + tracer = new Tracer(config) + + sinon.assert.notCalled(AgentExporter) + sinon.assert.calledOnce(NativeSpansInterface) + sinon.assert.calledWith(NativeExporter, config, prioritySampler, nativeSpansInstance) + }) + + it('uses the JS agent pipeline when optional libdatadog is omitted', () => { + const nativeError = Object.assign(new Error("Cannot find module '@datadog/libdatadog'"), { + code: 'MODULE_NOT_FOUND', + }) + Tracer = loadTracer({ nativeError }) + TextMapPropagator.returns(propagator) + + tracer = new Tracer(config) + + assert.strictEqual(tracer._isCiVisibility, false) + sinon.assert.notCalled(NativeExporter) + sinon.assert.calledOnceWithExactly(SpanProcessor, agentExporter, prioritySampler, config, undefined) + sinon.assert.calledWith( + log.warn, + 'Native exporter unavailable because %s; using JS exporter pipeline', + 'optional dependency @datadog/libdatadog is not installed' + ) + + tracer.inject(spanCtx, opentracing.FORMAT_TEXT_MAP, carrier) + sinon.assert.calledWith(propagator.inject, spanCtx, carrier) + }) + + it('uses the JS agent pipeline when libdatadog predates encoded trace export', () => { + Tracer = loadTracer({ pipelineApiVersion: 0 }) + + tracer = new Tracer(config) + + sinon.assert.notCalled(NativeExporter) + sinon.assert.calledOnceWithExactly(AgentExporter, config, prioritySampler) + sinon.assert.calledOnceWithExactly(SpanProcessor, agentExporter, prioritySampler, config, undefined) + sinon.assert.calledWith( + log.warn, + 'Native exporter unavailable because %s; using JS exporter pipeline', + 'the installed @datadog/libdatadog does not support encoded trace export', + ) + }) + + it('uses the JS agent pipeline when libdatadog lacks encoded trace export', () => { + Tracer = loadTracer({ encodedTracesSupported: false }) + + tracer = new Tracer(config) + + sinon.assert.notCalled(NativeExporter) + sinon.assert.calledOnceWithExactly(AgentExporter, config, prioritySampler) + sinon.assert.calledOnceWithExactly(SpanProcessor, agentExporter, prioritySampler, config, undefined) + sinon.assert.calledWith( + log.warn, + 'Native exporter unavailable because %s; using JS exporter pipeline', + 'the installed @datadog/libdatadog does not support encoded trace export', + ) + }) + + it('falls back to the JS OTLP exporter when libdatadog is missing', () => { + const nativeError = Object.assign(new Error("Cannot find module '@datadog/libdatadog'"), { + code: 'MODULE_NOT_FOUND', + }) + config.OTEL_TRACES_EXPORTER = 'otlp' + Tracer = loadTracer({ nativeError }) + + tracer = new Tracer(config) + + sinon.assert.notCalled(NativeExporter) + sinon.assert.notCalled(AgentExporter) + sinon.assert.calledOnceWithExactly(createOtlpTraceExporter, config) + sinon.assert.calledOnceWithExactly(SpanProcessor, otlpTraceExporter, prioritySampler, config, undefined) + }) + + it('preserves agentless precedence when libdatadog is missing', () => { + const nativeError = Object.assign(new Error("Cannot find module '@datadog/libdatadog'"), { + code: 'MODULE_NOT_FOUND', + }) + config.experimental.exporter = 'agentless' + config.OTEL_TRACES_EXPORTER = 'otlp' + Tracer = loadTracer({ nativeError }) + + tracer = new Tracer(config) + + sinon.assert.notCalled(NativeExporter) + sinon.assert.notCalled(createOtlpTraceExporter) + sinon.assert.calledOnceWithExactly(AgentlessExporter, config, prioritySampler) + sinon.assert.calledOnceWithExactly(SpanProcessor, agentlessExporter, prioritySampler, config, undefined) + }) + + it('uses the JS agentless pipeline when libdatadog lacks agentless export', () => { + config.experimental.exporter = 'agentless' + Tracer = loadTracer({ agentlessSupported: false }) + + tracer = new Tracer(config) + + sinon.assert.notCalled(NativeExporter) + sinon.assert.calledOnceWithExactly(AgentlessExporter, config, prioritySampler) + sinon.assert.calledOnceWithExactly(SpanProcessor, agentlessExporter, prioritySampler, config, undefined) + sinon.assert.calledWith( + log.warn, + 'Native exporter unavailable because %s; using JS exporter pipeline', + 'the installed @datadog/libdatadog does not support agentless export', + ) + }) + + it('uses the JS agent pipeline when the runtime has no WebAssembly', () => { + // libdatadog's loader throws a bare ReferenceError with no `code`, so the + // missing-module predicate cannot match it. Rethrowing leaves proxy.js with a + // NoopTracer, so `node --jitless` - and any JIT-disabled or hardened + // deployment - loses tracing entirely, silently, on a runtime where the JS + // pipeline works fine. + const nativeError = new ReferenceError('WebAssembly is not defined') + Tracer = loadTracer({ nativeError }) + const wasm = globalThis.WebAssembly + delete globalThis.WebAssembly + try { + tracer = new Tracer(config) + } finally { + globalThis.WebAssembly = wasm + } + + sinon.assert.notCalled(NativeExporter) + sinon.assert.calledOnceWithExactly(AgentExporter, config, prioritySampler) + sinon.assert.calledWith( + log.warn, + 'Native exporter unavailable because %s; using JS exporter pipeline', + 'this runtime has no WebAssembly support' + ) + }) + + it('uses the JS agent pipeline when a custom DNS lookup is configured', () => { + // libdatadog's transport builds its own `http.request` options and takes no + // lookup hook, so the native exporter silently drops the callback and + // traces go wherever the system resolver points. Users who set `lookup` are + // resolving the agent through service discovery, so honouring it matters more + // than using the native exporter. + config.lookup = (hostname, options, callback) => callback(null, '127.0.0.1', 4) + config.getOrigin = sinon.stub().withArgs('lookup').returns('code') + Tracer = loadTracer() + + tracer = new Tracer(config) + + assert.strictEqual(tracer._isCiVisibility, false) + sinon.assert.notCalled(NativeExporter) + sinon.assert.notCalled(NativeSpansInterface) + sinon.assert.calledOnceWithExactly(AgentExporter, config, prioritySampler) + }) + + it('stays on the native exporter when lookup is only the default', () => { + // `config.lookup` is always a function - it defaults to `dns.lookup` - so the + // guard has to key off where the value came from. It cannot compare against + // `dns.lookup` either: the dns plugin wraps that in place, so an identity + // check would report "custom" for every default install once instrumentation + // is active, silently dropping everyone off the native pipeline. + config.lookup = (hostname, options, callback) => callback(null, '127.0.0.1', 4) + config.getOrigin = sinon.stub().withArgs('lookup').returns('default') + Tracer = loadTracer() + + tracer = new Tracer(config) + + sinon.assert.calledOnce(NativeSpansInterface) + }) + + it('keeps OTLP export when a custom DNS lookup is also configured', () => { + // OTLP export lives in libdatadog, so the JS pipeline cannot do it at all. + // Routing there for the sake of `lookup` would quietly ship every span to the + // agent instead of the configured collector - a worse failure than resolving + // the collector with the system resolver. + config.OTEL_TRACES_EXPORTER = 'otlp' + config.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'http://collector.example:4318/v1/traces' + config.lookup = (hostname, options, callback) => callback(null, '127.0.0.1', 4) + config.getOrigin = sinon.stub().withArgs('lookup').returns('code') + Tracer = loadTracer() + + tracer = new Tracer(config) + + sinon.assert.notCalled(AgentExporter) + sinon.assert.calledWith(NativeExporter, config, prioritySampler, nativeSpansInstance) + // The dropped `lookup` must be announced, not silently ignored. + sinon.assert.calledWith( + log.warn, + 'OTLP trace export cannot honour a custom `lookup`; resolving the collector with the system resolver' + ) + }) + + it('uses the JS OTLP exporter in Lambda when libdatadog is missing', () => { + const nativeError = Object.assign(new Error("Cannot find module '@datadog/libdatadog'"), { + code: 'MODULE_NOT_FOUND', + }) + config.OTEL_TRACES_EXPORTER = 'otlp' + Tracer = loadTracer({ nativeError, isAWSLambda: true }) + + tracer = new Tracer(config) + + sinon.assert.notCalled(AgentExporter) + sinon.assert.notCalled(LogExporter) + sinon.assert.calledOnceWithExactly(createOtlpTraceExporter, config) + }) + + it('does not fall back to the JS agent pipeline when installed libdatadog is corrupt', () => { + const nativeError = Object.assign( + new Error("Cannot find module './load'\nRequire stack:\n- node_modules/@datadog/libdatadog/index.js"), + { code: 'MODULE_NOT_FOUND' } + ) + Tracer = loadTracer({ nativeError }) + + assert.throws(() => new Tracer(config), nativeError) + sinon.assert.notCalled(AgentExporter) + }) + + it('treats the agent exporter as the native APM default', () => { + config.experimental.exporter = 'agent' + + tracer = new Tracer(config) + + sinon.assert.notCalled(log.warn) + sinon.assert.calledWith(NativeExporter, config, prioritySampler, nativeSpansInstance) + }) + + it('constructs the default Agent URL from hostname and port', () => { + delete config.url + config.hostname = 'agent.internal' + config.port = 9126 + + tracer = new Tracer(config) + + assert.strictEqual(NativeSpansInterface.firstCall.args[0].agentUrl, 'http://agent.internal:9126/') + }) + + it('forwards the OTLP span stats exporter in the JS exporter pipeline', () => { + // Every other SpanProcessor assertion in this file expects `undefined` as + // the stats-exporter argument, because nothing else here sets + // OTEL_TRACES_SPAN_METRICS_ENABLED — so a branch that hardcoded `undefined` + // would pass the whole suite. Config forces + // DD_TRACE_STATS_COMPUTATION_ENABLED when OTLP span metrics are on, so + // dropping the exporter here ships v0.6 client stats to the agent instead of + // OTLP metrics. + const otlpStats = { export: sinon.spy() } + const createOtlpSpanStatsExporter = sinon.stub().returns(otlpStats) + config.OTEL_TRACES_SPAN_METRICS_ENABLED = true + Tracer = loadTracer({ + isAWSLambda: true, + createOtlpSpanStatsExporter, + }) + + tracer = new Tracer(config) + + sinon.assert.calledOnceWithExactly(createOtlpSpanStatsExporter, config) + sinon.assert.calledOnceWithExactly(SpanProcessor, agentExporter, prioritySampler, config, otlpStats) + }) + + it('forwards the OTLP span stats exporter in the native exporter pipeline', () => { + const otlpStats = { export: sinon.spy() } + const createOtlpSpanStatsExporter = sinon.stub().returns(otlpStats) + config.OTEL_TRACES_SPAN_METRICS_ENABLED = true + Tracer = loadTracer({ createOtlpSpanStatsExporter }) + + tracer = new Tracer(config) + + sinon.assert.calledOnceWithExactly( + SpanProcessor, exporter, prioritySampler, config, otlpStats, false + ) + }) + + it('lets native stats own APM stats when OTLP span metrics are disabled', () => { + config.stats = { DD_TRACE_STATS_COMPUTATION_ENABLED: true } + + tracer = new Tracer(config) + + sinon.assert.calledOnceWithExactly( + SpanProcessor, + exporter, + prioritySampler, + config, + undefined, + true + ) }) describe('startSpan', () => { @@ -135,7 +576,7 @@ describe('Tracer', () => { tracer = new Tracer(config) const testSpan = tracer.startSpan('name', fields) - sinon.assert.calledWith(Span, tracer, processor, prioritySampler, { + sinon.assert.calledWith(DatadogSpan, tracer, processor, prioritySampler, { operationName: 'name', parent: null, startTime: fields.startTime, @@ -143,7 +584,7 @@ describe('Tracer', () => { traceId128BitGenerationEnabled: undefined, integrationName: undefined, links: undefined, - }, true) + }) sinon.assert.calledWith(span.addTags, { foo: 'bar', @@ -163,7 +604,7 @@ describe('Tracer', () => { tracer = new Tracer(config) tracer.startSpan('name', fields) - sinon.assert.calledWithMatch(Span, tracer, processor, prioritySampler, { + sinon.assert.calledWithMatch(DatadogSpan, tracer, processor, prioritySampler, { operationName: 'name', parent, }) @@ -179,7 +620,7 @@ describe('Tracer', () => { tracer = new Tracer(config) tracer.startSpan('name', fields) - sinon.assert.calledWithMatch(Span, tracer, processor, prioritySampler, { + sinon.assert.calledWithMatch(DatadogSpan, tracer, processor, prioritySampler, { operationName: 'name', parent, }) @@ -192,7 +633,7 @@ describe('Tracer', () => { tracer = new Tracer(config) const testSpan = tracer.startSpan('name', fields) - sinon.assert.calledWith(Span, tracer, processor, prioritySampler, { + sinon.assert.calledWith(DatadogSpan, tracer, processor, prioritySampler, { operationName: 'name', parent: null, startTime: fields.startTime, @@ -216,7 +657,7 @@ describe('Tracer', () => { tracer = new Tracer(config) tracer.startSpan('name', fields) - sinon.assert.calledWithMatch(Span, tracer, processor, prioritySampler, { + sinon.assert.calledWithMatch(DatadogSpan, tracer, processor, prioritySampler, { operationName: 'name', parent, }) @@ -232,7 +673,7 @@ describe('Tracer', () => { tracer = new Tracer(config) tracer.startSpan('name', fields) - sinon.assert.calledWithMatch(Span, tracer, processor, prioritySampler, { + sinon.assert.calledWithMatch(DatadogSpan, tracer, processor, prioritySampler, { operationName: 'name', parent: null, }) @@ -290,7 +731,7 @@ describe('Tracer', () => { sinon.assert.calledWith(span.addTags, config.tags) sinon.assert.calledWith(span.addTags, { ...fields.tags, version: undefined }) - sinon.assert.calledWith(Span, tracer, processor, prioritySampler, { + sinon.assert.calledWith(DatadogSpan, tracer, processor, prioritySampler, { operationName: 'name', parent: null, startTime: fields.startTime, @@ -308,7 +749,7 @@ describe('Tracer', () => { tracer = new Tracer(config) const testSpan = tracer.startSpan('name', fields) - sinon.assert.calledWith(Span, tracer, processor, prioritySampler, { + sinon.assert.calledWith(DatadogSpan, tracer, processor, prioritySampler, { operationName: 'name', parent: null, startTime: fields.startTime, @@ -327,7 +768,7 @@ describe('Tracer', () => { tracer = new Tracer(config) const testSpan = tracer.startSpan('name', fields) - sinon.assert.calledWith(Span, tracer, processor, prioritySampler, { + sinon.assert.calledWith(DatadogSpan, tracer, processor, prioritySampler, { operationName: 'name', parent: null, startTime: fields.startTime, diff --git a/packages/dd-trace/test/plugins/agent.js b/packages/dd-trace/test/plugins/agent.js index 182939f2c33..1e0a04f0d85 100644 --- a/packages/dd-trace/test/plugins/agent.js +++ b/packages/dd-trace/test/plugins/agent.js @@ -269,17 +269,72 @@ function unformatSpanEvents (span) { }) } + // Native pipeline (DD_TRACE_NATIVE_SPAN_EVENTS enabled): span events land in + // the top-level v0.4 `span_events` field instead of the legacy `meta.events` + // JSON string. Attributes arrive as typed OTLP wrappers (including + // `array_value` for arrays), so decode them back to the same + // `{ name, startTime, attributes }` shape the plugin specs assert against. + if (Array.isArray(span.span_events)) { + return span.span_events.map(event => { + return { + name: event.name, + // `time_unix_nano` decodes as a BigInt (msgpack `useBigInt64`). + startTime: Number(event.time_unix_nano) / 1e6, + attributes: decodeNativeSpanEventAttributes(event.attributes), + } + }) + } + return [] // Return an empty array if no events are found } +// Unwrap a native span-event attribute value from its typed OTLP wrapper +// (`{ type, string_value | bool_value | int_value | double_value | array_value }`) +// to a plain JS value. Keyed off the value field (not `type`) so an omitted/zero +// discriminant is tolerated and an attribute literally named `type` can't collide. +function unwrapSpanEventAttributeValue (wrapper) { + if (wrapper === null || typeof wrapper !== 'object') return wrapper + if ('string_value' in wrapper) return wrapper.string_value + if ('bool_value' in wrapper) return wrapper.bool_value + if ('int_value' in wrapper) return Number(wrapper.int_value) // decodes as BigInt (i64) + if ('double_value' in wrapper) return wrapper.double_value + if ('array_value' in wrapper) return (wrapper.array_value?.values ?? []).map(unwrapSpanEventAttributeValue) + return wrapper +} + +// Decode a native span-event attribute map, unwrapping each typed OTLP value +// (arrays arrive as `array_value` and unwrap to real arrays) so the shape +// matches the legacy `meta.events` attributes. +function decodeNativeSpanEventAttributes (attributes) { + if (!attributes || typeof attributes !== 'object') return undefined + const keys = Object.keys(attributes) + if (keys.length === 0) return undefined + + const out = {} + for (const key of keys) { + out[key] = unwrapSpanEventAttributeValue(attributes[key]) + } + return out +} + /** * @param {express.Request} req * @param {express.Response} res */ function handleTraceRequest (req, res) { res.status(200).send({ rate_by_service: { 'service:,env:': 1 } }) + const trace = req.body + // libdatadog's v0.4 msgpack encoder omits `error` and `parent_id` when they + // are 0 (the agent protocol treats an absent field as its default, and the + // real agent does the same). The legacy JS AgentWriter always emitted both, so + // backfill them here for the native exporter. This only fills the 0 default — + // an expected non-zero value that arrived absent stays absent and still fails + // its assertion. `parent_id` is decoded as BigInt (useBigInt64), so backfill 0n. + for (const span of trace.flat(Infinity)) { + if (span && span.error === undefined) span.error = 0 + if (span && span.parent_id === undefined) span.parent_id = 0n + } for (const { handler, spanResourceMatch } of traceHandlers) { - const trace = req.body const spans = trace.flatMap(span => span) if (isMatchingTrace(spans, spanResourceMatch)) { handler(trace) @@ -634,6 +689,15 @@ module.exports = { }) agent.put('/v0.4/traces', handleTraceRequest) + + // The native (libdatadog) exporter sends traces via POST, whereas the + // legacy JS AgentWriter uses PUT. Handle both so `assertSomeTraces` works + // regardless of which exporter produced the payload. + agent.post('/v0.5/traces', (req, res) => { + res.status(404).end() + }) + + agent.post('/v0.4/traces', handleTraceRequest) agent.post('/api/v2/citestcycle', ciVisRequestHandler) agent.post('/evp_proxy/v2/api/v2/citestcycle', ciVisRequestHandler) diff --git a/packages/dd-trace/test/plugins/util/inferred_proxy.spec.js b/packages/dd-trace/test/plugins/util/inferred_proxy.spec.js index f3fe7d4d7eb..62d64a6aecc 100644 --- a/packages/dd-trace/test/plugins/util/inferred_proxy.spec.js +++ b/packages/dd-trace/test/plugins/util/inferred_proxy.spec.js @@ -593,7 +593,7 @@ Object.entries(proxyConfigs).forEach(([proxyType, config]) => { }, }) - assert.strictEqual(spans[0].error, 0) + assert.strictEqual(spans[0].error ?? 0, 0) }) }) @@ -624,7 +624,7 @@ Object.entries(proxyConfigs).forEach(([proxyType, config]) => { }, }) - assert.strictEqual(spans[0].error, 0) + assert.strictEqual(spans[0].error ?? 0, 0) }) }) }) diff --git a/packages/dd-trace/test/process-tags.spec.js b/packages/dd-trace/test/process-tags.spec.js index 387d73fbf2a..1a9a00d8712 100644 --- a/packages/dd-trace/test/process-tags.spec.js +++ b/packages/dd-trace/test/process-tags.spec.js @@ -3,10 +3,9 @@ const assert = require('node:assert/strict') const { inspect } = require('node:util') -const { describe, it, beforeEach, afterEach } = require('mocha') +const { describe, it, beforeEach } = require('mocha') const { assertObjectContains } = require('../../../integration-tests/helpers') -const { getConfigFresh } = require('./helpers/config') require('./setup/core') describe('process-tags', () => { @@ -254,67 +253,4 @@ describe('process-tags', () => { assert.strictEqual(sanitize('package_name-2.4.6/lib/index.js'), 'package_name-2.4.6/lib/index.js') }) }) - - describe('DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED', () => { - let env - let SpanProcessor - - beforeEach(() => { - env = process.env - process.env = {} - }) - - afterEach(() => { - process.env = env - delete require.cache[require.resolve('../src/span_processor')] - delete require.cache[require.resolve('../src/process-tags')] - }) - - it('should enable process tags propagation when set to true', () => { - process.env.DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED = 'true' - - const config = getConfigFresh() - const processTagsModule = require('../src/process-tags') - processTagsModule.initialize() - - assert.strictEqual(config.DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED, true) - - SpanProcessor = require('../src/span_processor') - const processor = new SpanProcessor(undefined, undefined, config) - - assert.strictEqual(typeof processor._processTags, 'string') - assert.match(processor._processTags, /entrypoint/) - }) - - it('should disable process tags propagation when set to false', () => { - process.env.DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED = 'false' - - const config = getConfigFresh() - const processTagsModule = require('../src/process-tags') - processTagsModule.initialize() - - assert.strictEqual(config.DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED, false) - - SpanProcessor = require('../src/span_processor') - const processor = new SpanProcessor(undefined, undefined, config) - - assert.strictEqual(processor._processTags, false) - }) - - it('should enable process tags propagation when not set', () => { - // Don't set the environment variable — default is enabled - - const config = getConfigFresh() - const processTagsModule = require('../src/process-tags') - processTagsModule.initialize() - - assert.strictEqual(config.DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED, true) - - SpanProcessor = require('../src/span_processor') - const processor = new SpanProcessor(undefined, undefined, config) - - assert.strictEqual(typeof processor._processTags, 'string') - assert.match(processor._processTags, /entrypoint/) - }) - }) }) diff --git a/packages/dd-trace/test/setup/core.js b/packages/dd-trace/test/setup/core.js index 23a89a64ed7..7ce29044f3f 100644 --- a/packages/dd-trace/test/setup/core.js +++ b/packages/dd-trace/test/setup/core.js @@ -15,6 +15,19 @@ if (process.env.CI) { process.env.DD_INSTRUMENTATION_TELEMETRY_ENABLED = 'false' +// Clear any OTEL_* exporter env vars leaked from the host environment (e.g. an +// observability tool whose telemetry points at a real backend). Tests assume +// an unconfigured exporter so they can stub http.request and route traces to +// the in-process fake agent. +for (const key of Object.keys(process.env)) { + if (key.startsWith('OTEL_EXPORTER_OTLP_') || + key === 'OTEL_LOGS_EXPORTER' || + key === 'OTEL_TRACES_EXPORTER' || + key === 'OTEL_METRICS_EXPORTER') { + delete process.env[key] + } +} + // If this is a release PR, set the SSI variables. if (/^v\d+\.x$/.test(process.env.GITHUB_BASE_REF || '')) { process.env.DD_INJECTION_ENABLED = 'true' diff --git a/packages/dd-trace/test/span_format.spec.js b/packages/dd-trace/test/span_format.spec.js index a3285672d59..7082abf913d 100644 --- a/packages/dd-trace/test/span_format.spec.js +++ b/packages/dd-trace/test/span_format.spec.js @@ -360,6 +360,25 @@ describe('spanFormat', () => { assert.deepStrictEqual(getExtraServices(), ['foo']) }) + + it('should not register the tracer own service as an extra service', () => { + // Every normal span carries the tracer's own service, so registering it + // would put the primary service into `client_tracer.extra_services` and + // consume one of Remote Configuration's 64 slots. + span.context()._tags['service.name'] = 'test' + + trace = spanFormat(span) + + assert.deepStrictEqual(getExtraServices(), []) + }) + + it('should not register a case-only variant of the tracer service', () => { + span.context()._tags['service.name'] = 'TEST' + + trace = spanFormat(span) + + assert.deepStrictEqual(getExtraServices(), []) + }) }) it('should extract Datadog specific tags', () => { diff --git a/packages/dd-trace/test/span_processor.spec.js b/packages/dd-trace/test/span_processor.spec.js index 06433980cd6..16cdc35c5fd 100644 --- a/packages/dd-trace/test/span_processor.spec.js +++ b/packages/dd-trace/test/span_processor.spec.js @@ -5,7 +5,7 @@ const { inspect } = require('node:util') const { describe, it, beforeEach } = require('mocha') const sinon = require('sinon') -const proxyquire = require('proxyquire') +const proxyquire = require('proxyquire').noCallThru() require('./setup/core') @@ -24,6 +24,8 @@ describe('SpanProcessor', () => { let config let SpanSampler let sample + let SpanStatsProcessor + let onSpanFinished before(() => { require('../src/process-tags').initialize() @@ -34,6 +36,7 @@ describe('SpanProcessor', () => { trace = { started: [], finished: [], + tags: {}, } let tags = {} @@ -65,21 +68,46 @@ describe('SpanProcessor', () => { DD_TRACE_STATS_COMPUTATION_ENABLED: false, }, appsec: {}, + sampler: {}, } - spanFormat = sinon.stub().returns({ formatted: true }) + spanFormat = sinon.stub().returns({ formatted: true, meta: {}, metrics: {} }) sample = sinon.stub() SpanSampler = sinon.stub().returns({ sample, }) + onSpanFinished = sinon.stub() + SpanStatsProcessor = sinon.stub().returns({ onSpanFinished }) SpanProcessor = proxyquire('../src/span_processor', { './span_format': spanFormat, './span_sampler': SpanSampler, + './span_stats': { SpanStatsProcessor }, }) processor = new SpanProcessor(exporter, prioritySampler, config) }) + /** @param {string} name */ + function createFinishedSpan (name) { + let tags = {} + const context = { + _trace: trace, + _sampling: {}, + getTags: () => tags, + getTag: key => tags[key], + setTag: (key, value) => { tags[key] = value }, + hasTag: key => key in tags, + clearTags: () => { tags = Object.create(null) }, + } + + return { + name, + _duration: 100, + tracer: sinon.stub().returns(tracer), + context: sinon.stub().returns(context), + } + } + it('should generate sampling priority', () => { processor.process(finishedSpan) @@ -131,10 +159,11 @@ describe('SpanProcessor', () => { trace.finished = [finishedSpan, finishedSpan, finishedSpan] processor.process(finishedSpan) - sinon.assert.calledWith(exporter.export, [ - { formatted: true }, - { formatted: true }, - { formatted: true }, + sinon.assert.calledOnce(exporter.export) + assert.deepStrictEqual(exporter.export.firstCall.args[0], [ + { formatted: true, meta: {}, metrics: {} }, + { formatted: true, meta: {}, metrics: {} }, + { formatted: true, meta: {}, metrics: {} }, ]) assert.ok('started' in trace) @@ -235,65 +264,52 @@ describe('SpanProcessor', () => { sinon.assert.calledWith(spanFormat.getCall(3), finishedSpan, false, processor._processTags) }) - it('should add APM disabled marker to every span in a chunk when APM tracing is disabled', () => { + it('should add APM disabled marker to the first span in a chunk when APM tracing is disabled', () => { config.apmTracingEnabled = false - config.flushMinSpans = 2 const processor = new SpanProcessor(exporter, prioritySampler, config) - const firstFormatted = { metrics: {} } - const secondFormatted = { metrics: {} } - spanFormat.onFirstCall().returns(firstFormatted) - spanFormat.onSecondCall().returns(secondFormatted) - trace.started = [activeSpan, finishedSpan, finishedSpan] - trace.finished = [finishedSpan, finishedSpan] + const first = createFinishedSpan('first') + const second = createFinishedSpan('second') + trace.started = [first, second] + trace.finished = [first, second] - processor.process(finishedSpan) + processor.process(first) - assert.strictEqual(firstFormatted.metrics[APM_TRACING_ENABLED_KEY], 0) - assert.strictEqual(secondFormatted.metrics[APM_TRACING_ENABLED_KEY], 0) - sinon.assert.calledWith(exporter.export, [firstFormatted, secondFormatted]) + assert.strictEqual(first.context().getTag(APM_TRACING_ENABLED_KEY), 0) + assert.strictEqual(second.context().getTag(APM_TRACING_ENABLED_KEY), undefined) + sinon.assert.calledWithExactly(spanFormat.firstCall, first, true, false) + sinon.assert.calledWithExactly(spanFormat.secondCall, second, false, false) }) it('should add APM disabled marker to every chunk when a delayed child flushes alone', () => { - // Reproduces the standalone-ASM billing regression: the entry span flushes - // in one chunk, then a long-lived child (e.g. delayed http.request) flushes - // later in its own chunk. Both chunks must carry _dd.apm.enabled:0. config.apmTracingEnabled = false const processor = new SpanProcessor(exporter, prioritySampler, config) - const parentFormatted = { metrics: {} } - const childFormatted = { metrics: {} } - spanFormat.onFirstCall().returns(parentFormatted) - spanFormat.onSecondCall().returns(childFormatted) - - const parentSpan = { ...finishedSpan } - const childSpan = { ...finishedSpan } + const parentSpan = createFinishedSpan('parent') + const childSpan = createFinishedSpan('child') trace.started = [parentSpan] trace.finished = [parentSpan] processor.process(parentSpan) - assert.strictEqual(parentFormatted.metrics[APM_TRACING_ENABLED_KEY], 0) - sinon.assert.calledWith(exporter.export, [parentFormatted]) + assert.strictEqual(parentSpan.context().getTag(APM_TRACING_ENABLED_KEY), 0) trace.started = [childSpan] trace.finished = [childSpan] - processor.process(childSpan) - assert.strictEqual(childFormatted.metrics[APM_TRACING_ENABLED_KEY], 0) - sinon.assert.calledWith(exporter.export.secondCall, [childFormatted]) + assert.strictEqual(childSpan.context().getTag(APM_TRACING_ENABLED_KEY), 0) + sinon.assert.calledTwice(exporter.export) }) it('should not add APM disabled marker when APM tracing is enabled', () => { config.apmTracingEnabled = true const processor = new SpanProcessor(exporter, prioritySampler, config) - const formattedSpan = { metrics: {} } - spanFormat.returns(formattedSpan) - trace.started = [finishedSpan] - trace.finished = [finishedSpan] + const span = createFinishedSpan('enabled') + trace.started = [span] + trace.finished = [span] - processor.process(finishedSpan) + processor.process(span) - assert.ok(!Object.hasOwn(formattedSpan.metrics, APM_TRACING_ENABLED_KEY)) + assert.strictEqual(span.context().getTag(APM_TRACING_ENABLED_KEY), undefined) }) describe('with DD_TRACE_OTEL_SEMANTICS_ENABLED', () => { @@ -355,4 +371,58 @@ describe('SpanProcessor', () => { assert.deepStrictEqual(statsView, { method: 'GET', statusCode: '200', endpoint: '/u' }) }) }) + it('computes v0.6 APM stats when client-side stats are enabled', () => { + config.stats.DD_TRACE_STATS_COMPUTATION_ENABLED = true + const processor = new SpanProcessor(exporter, prioritySampler, config) + const span = createFinishedSpan('web.request') + trace.started = [span] + trace.finished = [span] + + processor.process(span) + + sinon.assert.calledWithNew(SpanStatsProcessor) + sinon.assert.calledOnceWithExactly(SpanStatsProcessor, config, undefined) + sinon.assert.calledOnceWithExactly(onSpanFinished, spanFormat.firstCall.returnValue) + }) + + it('does not compute APM stats for CI Visibility spans', () => { + config.isCiVisibility = true + config.stats.DD_TRACE_STATS_COMPUTATION_ENABLED = true + const processor = new SpanProcessor(exporter, prioritySampler, config) + const span = createFinishedSpan('ci.test') + trace.started = [span] + trace.finished = [span] + + processor.process(span) + + sinon.assert.notCalled(SpanStatsProcessor) + sinon.assert.notCalled(onSpanFinished) + }) + + it('does not duplicate APM stats when native stats own the trace', () => { + config.stats.DD_TRACE_STATS_COMPUTATION_ENABLED = true + const processor = new SpanProcessor(exporter, prioritySampler, config, undefined, true) + const span = createFinishedSpan('web.request') + trace.started = [span] + trace.finished = [span] + + processor.process(span) + + sinon.assert.notCalled(SpanStatsProcessor) + sinon.assert.notCalled(onSpanFinished) + }) + + it('uses an injected OTLP span metrics exporter when provided', () => { + const otlpStatsExporter = { export: sinon.stub() } + const processor = new SpanProcessor(exporter, prioritySampler, config, otlpStatsExporter) + const span = createFinishedSpan('web.request') + trace.started = [span] + trace.finished = [span] + + processor.process(span) + + sinon.assert.calledWithNew(SpanStatsProcessor) + sinon.assert.calledOnceWithExactly(SpanStatsProcessor, config, otlpStatsExporter) + sinon.assert.calledOnceWithExactly(onSpanFinished, spanFormat.firstCall.returnValue) + }) }) diff --git a/packages/dd-trace/test/span_sampler.spec.js b/packages/dd-trace/test/span_sampler.spec.js index 02b93fbc454..69fc6a4444c 100644 --- a/packages/dd-trace/test/span_sampler.spec.js +++ b/packages/dd-trace/test/span_sampler.spec.js @@ -168,7 +168,6 @@ describe('span sampler', () => { ], }) - // Create two span contexts const started = [] const firstSpanContext = { _spanId: id('1234567812345678'), @@ -186,7 +185,6 @@ describe('span sampler', () => { _name: 'second operation', } - // Add spans for both to the context started.push({ context: sinon.stub().returns(firstSpanContext), tracer: sinon.stub().returns({ @@ -237,7 +235,6 @@ describe('span sampler', () => { ], }) - // Create two span contexts const started = [] const firstSpanContext = { _spanId: id('1234567812345678'), @@ -255,7 +252,6 @@ describe('span sampler', () => { _name: 'second operation', } - // Add spans for both to the context started.push({ context: sinon.stub().returns(firstSpanContext), tracer: sinon.stub().returns({ diff --git a/packages/dd-trace/test/tracer.spec.js b/packages/dd-trace/test/tracer.spec.js index ee9c4d5ec05..aa41e612ad4 100644 --- a/packages/dd-trace/test/tracer.spec.js +++ b/packages/dd-trace/test/tracer.spec.js @@ -25,7 +25,7 @@ describe('Tracer', () => { let config beforeEach(() => { - config = getConfig({ service: 'service' }) + config = getConfig({ service: 'service', experimental: { exporter: 'log' } }) tracer = new Tracer(config) tracer._exporter.setUrl = sinon.stub() diff --git a/vendor/package.json b/vendor/package.json index 6e1ed0e12f3..b1c66f96b55 100644 --- a/vendor/package.json +++ b/vendor/package.json @@ -1,7 +1,7 @@ { "license": "(Apache-2.0 OR BSD-3-Clause)", "scripts": { - "postinstall": "node rspack" + "postinstall": "node -e \"try { require.resolve('@rspack/core') } catch (e) { if (e.code === 'MODULE_NOT_FOUND') process.exit(0); throw e } require('./rspack')\"" }, "dependencies": { "@apm-js-collab/code-transformer": "^0.18.1", diff --git a/yarn.lock b/yarn.lock index 4a919cb26ee..3dc337260e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -252,10 +252,10 @@ dependencies: spark-md5 "^3.0.2" -"@datadog/libdatadog@0.12.1": - version "0.12.1" - resolved "https://registry.yarnpkg.com/@datadog/libdatadog/-/libdatadog-0.12.1.tgz#0b15c4781208a77aa08f0efb74d9deb645e800c4" - integrity sha512-4cKRaO1mB9npfklJjOizzJaNBdZvw1V62EVbSD6Y32zX92bTBq/vAno/TTN9dMAvzomXYmvADpGo4798E9fMoA== +"@datadog/libdatadog@0.18.1": + version "0.18.1" + resolved "https://registry.yarnpkg.com/@datadog/libdatadog/-/libdatadog-0.18.1.tgz#f576799b2f69c46e2bd0188ff8b4d7424c1dc582" + integrity sha512-Q7kEmNW8FI7tOoQRtJBOu0lfUwoqUfaWRr4dZNK+sazJ5yKHuONM0C2yzH1TmwxRWfK1P/Hut4+fxFiV5fU4sQ== "@datadog/native-appsec@11.0.1": version "11.0.1"