Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions packages/dd-trace/src/dogstatsd.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,11 @@ class DogStatsDClient {
* before the swap.
*
* @param {string[]} tags - DogStatsD-formatted tags (e.g. `['key:value']`)
* @returns {boolean} True if the tag prefix actually changed (and buffered lines were dropped)
*/
updateTags (tags) {
const tagsPrefix = tags.length ? `|#${tags.join(',')}` : ''
if (tagsPrefix === this.#tagsPrefix) return
if (tagsPrefix === this.#tagsPrefix) return false

this.#updateTagPrefixes(tagsPrefix)
this.#metrics.queue = []
Expand All @@ -167,6 +168,8 @@ class DogStatsDClient {
payload.message = ''
payload.offset = 0
}

return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear DogStatsD self-telemetry accumulators on refresh

When the refreshed tags do change, this method drops the encoded telemetry payload but leaves telemetry.metricsByType, aggregatedContextsByType, bytesSent/bytesDropped, and packetsSent/packetsDropped untouched. If the image is snapshotted before the telemetry interval flushes, every clone later reports those inherited pre-snapshot counts under the refreshed prefix, duplicating DogStatsD client telemetry even though the application metric buffers were reset; clear these interval accumulators at the same identity boundary.

AGENTS.md reference: AGENTS.md:L79-L79

Useful? React with 👍 / 👎.

}

/**
Expand Down Expand Up @@ -595,11 +598,16 @@ class MetricsAggregationClient {
}

/**
* Recomputes the wrapped client's cached tags (e.g. after a MicroVM clone resume).
* Recomputes the wrapped client's cached tags (e.g. after a MicroVM clone resume). Pending
* counters/gauges/histograms were aggregated under the old identity, so they're reset along
* with the client's buffered lines — but only if the tags actually changed, so a no-op resume
* doesn't discard in-flight aggregation for nothing.
* @param {string[]} tags - DogStatsD-formatted tags (e.g. `['key:value']`)
*/
updateTags (tags) {
this._client.updateTags(tags)
if (this._client.updateTags(tags)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Reset aggregates on each identity refresh

MicroVM clones can send duplicate aggregate metrics from the snapshot.

Assertion details
  • Input: A MicroVM identity refresh with the default runtimeMetricsRuntimeId setting and pending aggregate metrics.
  • Expected: Each identity refresh must remove aggregate data that the clone inherits from the snapshot.
  • Actual: The new condition resets aggregate data only when the serialized tag prefix changes. The default setting omits the runtime ID, so a clone refresh can leave the prefix unchanged and preserve inherited counters, gauges, and histograms.

Was this helpful? React 👍 or 👎
🤖 Datadog Autotest · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest · Open Bits AI session

this.reset()
}
Comment on lines +608 to +610

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reset aggregates even when identity tags are unchanged

When runtimeMetricsRuntimeId has its default value of false, generateClientConfig() excludes the refreshed runtime ID, so the DogStatsD tag prefix remains unchanged on every MicroVM identity refresh. This condition then preserves counters, gauges, and histograms captured before the snapshot, allowing every clone to flush the same inherited aggregate; the identity-refresh path must reset pending aggregation independently of whether the serialized tags changed.

Useful? React with 👍 / 👎.

}

/**
Expand Down
14 changes: 14 additions & 0 deletions packages/dd-trace/src/exporters/agentless/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,19 @@
const { URL } = require('node:url')
const os = require('node:os')

const { channel } = require('dc-polyfill')

const log = require('../../log')
const { containerId } = require('../common/docker')
const Writer = require('./writer')
const { computeIntakeUrl } = require('./intake')

const identityRefreshChannel = channel('datadog:identity:refresh')

// Only one AgentlessExporter is ever live in a real process, so replacing the subscription on
// construction is safe - it just keeps tests (which build several) from piling up listeners.
let unsubscribeBatchReset = null

/**
* Agentless exporter for APM trace intake.
* Sends traces directly to the Datadog intake without requiring a local agent.
Expand Down Expand Up @@ -52,6 +60,12 @@ class AgentlessExporter {
metadata,
})

// A clone resume shouldn't flush spans buffered before the snapshot under its own identity.
unsubscribeBatchReset?.()
const onIdentityRefresh = () => this._writer.resetPendingBatch()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reset batches for the agent exporter too

When a MicroVM uses the normal Agent exporter—for example through a local Agent or Lambda extension—packages/dd-trace/src/exporters/agent/index.js:23-31 constructs the same common writer but never subscribes to the identity-refresh channel. Traces buffered before the image snapshot therefore remain in its AgentEncoder, and every clone can flush the inherited traces when its timer fires; wire resetPendingBatch() into AgentExporter as well.

AGENTS.md reference: AGENTS.md:L79-L79

Useful? React with 👍 / 👎.

identityRefreshChannel.subscribe(onIdentityRefresh)
unsubscribeBatchReset = () => identityRefreshChannel.unsubscribe(onIdentityRefresh)

const ddTrace = globalThis[Symbol.for('dd-trace')]
if (ddTrace?.beforeExitHandlers) {
ddTrace.beforeExitHandlers.add(this.flush.bind(this))
Expand Down
9 changes: 9 additions & 0 deletions packages/dd-trace/src/exporters/common/writer.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ class Writer {
setUrl (url) {
this._url = url
}

/**
* Discards whatever's queued in the encoder. Used on a MicroVM clone resume, where anything
* buffered before the snapshot would otherwise flush under every clone's identity.
* @returns {void}
*/
resetPendingBatch () {
this._encoder.reset()
}
}

module.exports = Writer
18 changes: 17 additions & 1 deletion packages/dd-trace/src/span_stats.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
'use strict'

const os = require('node:os')
const pkg = require('../../../package.json')

const { channel } = require('dc-polyfill')

const pkg = require('../../../package.json')
const { LogCollapsingLowestDenseDDSketch } = require('../../../vendor/dist/@datadog/sketches-js')
const {
MEASURED,
Expand All @@ -29,6 +31,12 @@ const {
DEFAULT_SERVICE_NAME,
} = require('./encode/tags-processors')

const identityRefreshChannel = channel('datadog:identity:refresh')

// Only one SpanStatsProcessor is ever live in a real process, so replacing the subscription on
// construction is safe - it just keeps tests (which build several) from piling up listeners.
let unsubscribeBucketReset = null

class SpanAggStats {
constructor (aggKey) {
this.aggKey = aggKey
Expand Down Expand Up @@ -229,6 +237,14 @@ class SpanStatsProcessor {
this.timer = setInterval(this.onInterval.bind(this), intervalMs)
this.timer.unref?.()
}

// A clone resume shouldn't export buckets accumulated before the snapshot under its own identity.
unsubscribeBucketReset?.()
const onIdentityRefresh = () => {
this.buckets = new TimeBuckets()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Keep OTLP trace-root grouping after refresh

OTLP span metrics become incorrect after the first identity refresh.

Assertion details
  • Input: An identity refresh while OTLP span metrics are active, followed by root and child spans with the same dimensions.
  • Expected: The new bucket set must keep trace-root grouping when the OTLP exporter is active.
  • Actual: The refresh handler replaces OTLP TimeBuckets with the default mode. This mode merges trace roots with other spans and removes the datadog.is_trace_root attribute.
Suggested change
this.buckets = new TimeBuckets()
this.buckets = new TimeBuckets(Boolean(this.otlpExporter))

Was this helpful? React 👍 or 👎
🤖 Datadog Autotest · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest · Open Bits AI session

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve OTLP trace-root bucketing after refresh

When OTLP span statistics are enabled and the MicroVM identity-refresh channel fires, this replaces the original TimeBuckets(true) with the default non-OTLP configuration. All subsequently recorded root and child spans are therefore merged under the same aggregation key and exported without the datadog.is_trace_root attribute; recreate the buckets with Boolean(this.otlpExporter) so the constructor's OTLP behavior survives the reset.

AGENTS.md reference: AGENTS.md:L79-L80

Useful? React with 👍 / 👎.

}
identityRefreshChannel.subscribe(onIdentityRefresh)
unsubscribeBucketReset = () => identityRefreshChannel.unsubscribe(onIdentityRefresh)
}

onInterval () {
Expand Down
47 changes: 47 additions & 0 deletions packages/dd-trace/test/dogstatsd.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1359,6 +1359,53 @@ describe('dogstatsd', () => {

assert.strictEqual(udp4.send.firstCall.args[0].toString(), 'test.buffered:1|d|#runtime-id:initial-id\n')
})

it('should drop pending aggregated counters/gauges/histograms when an identity refresh changes its tags',
() => {
const config = {
dogstatsd: {
hostname: '127.0.0.1',
port: 8125,
},
lookup: dns.lookup,
runtimeMetricsRuntimeId: true,
tags: { 'runtime-id': 'initial-id' },
}

client = new CustomMetrics(config)
client.increment('test.count', 10)
client.gauge('test.avg', 5)
client.histogram('test.hist', 1)

config.tags['runtime-id'] = 'refreshed-id'
identityRefreshChannel.publish(config)

client.flush()

sinon.assert.notCalled(udp4.send)
})

it('should preserve pending aggregated counters/gauges/histograms when an identity refresh does not change ' +
'its tags', () => {
const config = {
dogstatsd: {
hostname: '127.0.0.1',
port: 8125,
},
lookup: dns.lookup,
runtimeMetricsRuntimeId: true,
tags: { 'runtime-id': 'initial-id' },
}

client = new CustomMetrics(config)
client.increment('test.count', 10)

identityRefreshChannel.publish(config)
client.flush()

sinon.assert.called(udp4.send)
assert.strictEqual(udp4.send.firstCall.args[0].toString(), 'test.count:10|c|#runtime-id:initial-id\n')
})
})

describe('MetricsAggregationClient', () => {
Expand Down
25 changes: 25 additions & 0 deletions packages/dd-trace/test/exporters/agentless/exporter.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ const { inspect } = require('node:util')
const { describe, it, beforeEach, afterEach } = require('mocha')
const sinon = require('sinon')
const proxyquire = require('proxyquire')
const { channel } = require('dc-polyfill')

const { assertObjectContains } = require('../../../../../integration-tests/helpers')

require('../../setup/core')

const identityRefreshChannel = channel('datadog:identity:refresh')

describe('AgentlessExporter', () => {
let Exporter
let exporter
Expand All @@ -26,6 +29,7 @@ describe('AgentlessExporter', () => {
append: sinon.stub(),
flush: sinon.stub().callsFake((cb) => cb && cb()),
setUrl: sinon.stub(),
resetPendingBatch: sinon.stub(),
}

const Writer = function () {
Expand Down Expand Up @@ -282,6 +286,27 @@ describe('AgentlessExporter', () => {
})
})

describe('identity refresh', () => {
it('drops the pending trace batch when the identity-refresh channel fires', () => {
exporter = new Exporter({})

identityRefreshChannel.publish({ tags: {} })

sinon.assert.calledOnce(writer.resetPendingBatch)
})

it('stops reacting once a newer exporter takes over', () => {
exporter = new Exporter({})
new Exporter({}) // eslint-disable-line no-new
writer.resetPendingBatch.resetHistory()

identityRefreshChannel.publish({ tags: {} })

// Only one reset, not two - the first exporter's subscription was replaced, not stacked on.
sinon.assert.calledOnce(writer.resetPendingBatch)
})
})

describe('setUrl', () => {
let log

Expand Down
6 changes: 6 additions & 0 deletions packages/dd-trace/test/exporters/common/writer.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,4 +179,10 @@ describe('common Writer', () => {
assert.strictEqual(writer.append(payload), false)
sinon.assert.calledOnceWithExactly(encoder.encode, payload)
})

it('resetPendingBatch discards the pending encoded batch', () => {
writer.resetPendingBatch()

sinon.assert.calledOnce(encoder.reset)
})
})
33 changes: 33 additions & 0 deletions packages/dd-trace/test/span_stats.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const proxyquire = require('proxyquire')

require('./setup/core')

const identityRefreshChannel = channel('datadog:identity:refresh')

const { LogCollapsingLowestDenseDDSketch } = require('../../../vendor/dist/@datadog/sketches-js')
const { version } = require('../src/pkg')
const pkg = require('../../../package.json')
Expand Down Expand Up @@ -776,4 +778,35 @@ describe('SpanStatsProcessor', () => {
p.onSpanFinished(topLevelSpan)
assert.strictEqual(p.buckets.size, 1)
})

it('should clear pending buckets when the identity-refresh channel fires', () => {
const p = new SpanStatsProcessor(config)
clearTimeout(p.timer)

p.onSpanFinished(topLevelSpan)
assert.strictEqual(p.buckets.size, 1)

const previousBuckets = p.buckets
identityRefreshChannel.publish(config)

assert.notStrictEqual(p.buckets, previousBuckets)
assert.strictEqual(p.buckets.size, 0)
})

it('should stop reacting to identity refresh once a newer instance takes over', () => {
const first = new SpanStatsProcessor(config)
clearTimeout(first.timer)
const firstBuckets = first.buckets

const second = new SpanStatsProcessor(config)
clearTimeout(second.timer)
const secondBuckets = second.buckets

identityRefreshChannel.publish(config)

// Only the second (newest) instance should react - the first's subscription was replaced,
// not stacked on top of.
assert.strictEqual(first.buckets, firstBuckets)
assert.notStrictEqual(second.buckets, secondBuckets)
})
})
Loading