Skip to content

Commit 579c97e

Browse files
fix(id): drop stale telemetry and RC tag on identity refresh
Buffered/aggregated telemetry recorded before a MicroVM snapshot would otherwise export or flush under every clone's refreshed identity instead of being dropped with the rest of the pre-clone state. Reset it as part of the identity-refresh path, in each of the affected subsystems: - dogstatsd: MetricsAggregationClient drops pending counters/gauges/ histograms when the wrapped client's tags actually change - agentless exporter: Writer#resetPendingBatch() discards the pending encoded trace batch - OTLP logs: BatchLogRecordProcessor#resetPendingState() discards queued log records and clears the batch timer - OTLP metrics: PeriodicMetricReader#resetPendingState() discards queued measurements and rebases sync Counter/Histogram cumulative state - span stats: SpanStatsProcessor replaces its bucket map - runtime metrics: rebase CPU/event-loop/ELU sampler baselines so the next collection reports a delta since the resume, not one spanning the snapshot pause Also fixes a separate identity-refresh gap: an RC lib-config update rebuilds config.tags from tracked sources (config/remote_config.js's tracing_tags transformer), dropping the directly-set _dd.rc.client_id key. refreshIdentity()'s guard only wrote the refreshed value back when the tag was already present, so once that sequence happened, config.tags (and the DogStatsD/OTLP tags built from it) permanently lost _dd.rc.client_id after an identity refresh, even though the RC client's own id field kept updating correctly. Gate the write on the RC client existing instead, and write it unconditionally in that case. SpanStatsProcessor and AgentlessExporter also subscribed to the identity-refresh channel per instance with no cleanup. Harmless in production (both are process-lifetime singletons), but each of the many instances constructed across a test run stayed subscribed forever. Now replace the previous subscription on construction, matching the pattern already used for the OTel logs/metrics initializers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 6cac3ff commit 579c97e

20 files changed

Lines changed: 353 additions & 14 deletions

File tree

packages/dd-trace/src/dogstatsd.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,18 +61,21 @@ class DogStatsDClient {
6161
* before the swap.
6262
*
6363
* @param {string[]} tags - DogStatsD-formatted tags (e.g. `['key:value']`)
64+
* @returns {boolean} True if the tag prefix actually changed (and buffered lines were dropped)
6465
*/
6566
updateTags (tags) {
6667
const tagsPrefix = tags.length ? `|#${tags.join(',')}` : ''
6768

6869
this._tags = tags
6970

70-
if (tagsPrefix === this.#tagsPrefix) return
71+
if (tagsPrefix === this.#tagsPrefix) return false
7172

7273
this.#tagsPrefix = tagsPrefix
7374
this._queue = []
7475
this._buffer = ''
7576
this._offset = 0
77+
78+
return true
7679
}
7780

7881
increment (stat, value, tags) {
@@ -241,11 +244,16 @@ class MetricsAggregationClient {
241244
}
242245

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

251259
flush () {

packages/dd-trace/src/exporters/agentless/index.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,20 @@
33
const { URL } = require('node:url')
44
const os = require('node:os')
55

6+
const { channel } = require('dc-polyfill')
7+
68
const log = require('../../log')
79
const { entityId } = require('../common/docker')
810
const tracerVersion = require('../../../../../package.json').version
911
const Writer = require('./writer')
1012
const { computeIntakeUrl } = require('./intake')
1113

14+
const identityRefreshChannel = channel('datadog:identity:refresh')
15+
16+
// Only one AgentlessExporter is ever live in a real process, so replacing the subscription on
17+
// construction is safe - it just keeps tests (which build several) from piling up listeners.
18+
let unsubscribeBatchReset = null
19+
1220
/**
1321
* Agentless exporter for APM trace intake.
1422
* Sends traces directly to the Datadog intake without requiring a local agent.
@@ -56,6 +64,12 @@ class AgentlessExporter {
5664
metadata,
5765
})
5866

67+
// A clone resume shouldn't flush spans buffered before the snapshot under its own identity.
68+
unsubscribeBatchReset?.()
69+
const onIdentityRefresh = () => this._writer.resetPendingBatch()
70+
identityRefreshChannel.subscribe(onIdentityRefresh)
71+
unsubscribeBatchReset = () => identityRefreshChannel.unsubscribe(onIdentityRefresh)
72+
5973
const ddTrace = globalThis[Symbol.for('dd-trace')]
6074
if (ddTrace?.beforeExitHandlers) {
6175
ddTrace.beforeExitHandlers.add(this.flush.bind(this))

packages/dd-trace/src/exporters/common/writer.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ class Writer {
7070
setUrl (url) {
7171
this._url = url
7272
}
73+
74+
/**
75+
* Discards whatever's queued in the encoder. Used on a MicroVM clone resume, where anything
76+
* buffered before the snapshot would otherwise flush under every clone's identity.
77+
* @returns {void}
78+
*/
79+
resetPendingBatch () {
80+
this._encoder.reset()
81+
}
7382
}
7483

7584
module.exports = Writer

packages/dd-trace/src/opentelemetry/logs/batch_log_processor.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,16 @@ class BatchLogRecordProcessor {
6060
this.#export()
6161
}
6262

63+
/**
64+
* Discards whatever's queued. Used on a MicroVM clone resume, where log records buffered
65+
* before the snapshot would otherwise export under every clone's identity.
66+
* @returns {void}
67+
*/
68+
resetPendingState () {
69+
this.#logRecords = []
70+
this.#clearTimer()
71+
}
72+
6373
/**
6474
* Starts the batch timeout timer.
6575
* @private

packages/dd-trace/src/opentelemetry/logs/index.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
'use strict'
22

3+
const { channel } = require('dc-polyfill')
4+
35
const { buildResourceAttributes, registerResourceAttributeRefresh } = require('../resource-attributes')
46

57
/**
68
* @typedef {import('../../config')} Config
79
*/
810

11+
const identityRefreshChannel = channel('datadog:identity:refresh')
12+
13+
// initializeOpenTelemetryLogs() can be called again (e.g. re-init); drop the old subscription
14+
// first so it doesn't stack.
15+
let unsubscribeLogsPendingStateReset = null
16+
917
/**
1018
* OpenTelemetry Logs Implementation for `dd-trace-js`
1119
*
@@ -59,6 +67,12 @@ function initializeOpenTelemetryLogs (config) {
5967
loggerProvider.register()
6068

6169
registerResourceAttributeRefresh(exporter, () => buildResourceAttributes(config))
70+
71+
// A clone resume shouldn't export log records queued before the snapshot under its own identity.
72+
unsubscribeLogsPendingStateReset?.()
73+
const onIdentityRefresh = () => processor.resetPendingState()
74+
identityRefreshChannel.subscribe(onIdentityRefresh)
75+
unsubscribeLogsPendingStateReset = () => identityRefreshChannel.unsubscribe(onIdentityRefresh)
6276
}
6377

6478
module.exports = {

packages/dd-trace/src/opentelemetry/metrics/index.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
const os = require('os')
44

55
const { metrics } = require('@opentelemetry/api')
6+
const { channel } = require('dc-polyfill')
67

78
const { VERSION } = require('../../../../../version')
89
const processTags = require('../../process-tags')
@@ -18,6 +19,12 @@ const OtlpHttpMetricExporter = require('./otlp_http_metric_exporter')
1819
* @typedef {import('../../config')} Config
1920
*/
2021

22+
const identityRefreshChannel = channel('datadog:identity:refresh')
23+
24+
// initializeOpenTelemetryMetrics() can be called again (e.g. re-init); drop the old subscription
25+
// first so it doesn't stack.
26+
let unsubscribeMetricsPendingStateReset = null
27+
2128
/**
2229
* @file OpenTelemetry Metrics Implementation for dd-trace-js
2330
*
@@ -64,6 +71,12 @@ function initializeOpenTelemetryMetrics (config) {
6471
metrics.setGlobalMeterProvider(meterProvider)
6572

6673
registerResourceAttributeRefresh(exporter, () => buildGeneralResourceAttributes(config))
74+
75+
// A clone resume shouldn't export measurements queued before the snapshot under its own identity.
76+
unsubscribeMetricsPendingStateReset?.()
77+
const onIdentityRefresh = () => reader.resetPendingState()
78+
identityRefreshChannel.subscribe(onIdentityRefresh)
79+
unsubscribeMetricsPendingStateReset = () => identityRefreshChannel.unsubscribe(onIdentityRefresh)
6780
}
6881

6982
function buildResourceAttributes (tags, { reportHostname, otelSemanticsEnabled, service, env, serviceVersion } = {}) {

packages/dd-trace/src/opentelemetry/metrics/periodic_metric_reader.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,26 @@ class PeriodicMetricReader {
207207
this.#collectAndExport()
208208
}
209209

210+
/**
211+
* Discards queued measurements and sync-instrument cumulative state. Used on a MicroVM clone
212+
* resume so measurements recorded before the snapshot don't get exported under the clone's
213+
* identity.
214+
*
215+
* Only clears `#lastExportedState` entries that have a matching `#cumulativeState` entry (sync
216+
* Counter/Histogram delta baselines) - an ObservableCounter's baseline lives only in
217+
* `#lastExportedState`, and clearing it too would turn its next export into an absolute
218+
* reading instead of a delta.
219+
* @returns {void}
220+
*/
221+
resetPendingState () {
222+
this.#measurements = []
223+
224+
for (const key of this.#cumulativeState.keys()) {
225+
this.#lastExportedState.delete(key)
226+
}
227+
this.#cumulativeState.clear()
228+
}
229+
210230
/**
211231
* Shuts down the reader and stops periodic collection.
212232
* @returns {void}

packages/dd-trace/src/remote_config/index.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -601,10 +601,10 @@ function getTagsString (config, repositoryUrl, commitSHA) {
601601
*/
602602
function refreshIdentity (config) {
603603
clientId = uuid()
604-
if (config.tags['_dd.rc.client_id']) {
605-
config.tags['_dd.rc.client_id'] = clientId
606-
}
607604
if (client !== undefined) {
605+
// Unconditional, because an RC lib-config update rebuilds config.tags from scratch (see
606+
// tracing_tags() in config/remote_config.js) and drops this directly-set key.
607+
config.tags['_dd.rc.client_id'] = clientId
608608
client.id = clientId
609609
client.client_tracer.runtime_id = config.tags['runtime-id']
610610
const { commitSHA, repositoryUrl } = getGitMetadata(config)

packages/dd-trace/src/runtime_metrics/client.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,15 @@ function createMetricsClient (config) {
4848
*
4949
* @param {MetricsAggregationClient} client - The client returned by `createMetricsClient()`
5050
* @param {import('../config/config-base')} config - Tracer configuration
51+
* @param {() => void} [onRefresh] - Called after the tag update, e.g. to rebase sampler
52+
* baselines (CPU usage, event-loop delay) that would otherwise span the snapshot pause
5153
* @returns {() => void} Unsubscribe function; call it from the owning module's `stop()`
5254
*/
53-
function subscribeToIdentityRefresh (client, config) {
54-
const onIdentityRefresh = () => client.updateTags(buildClientConfig(config).tags)
55+
function subscribeToIdentityRefresh (client, config, onRefresh) {
56+
const onIdentityRefresh = () => {
57+
client.updateTags(buildClientConfig(config).tags)
58+
onRefresh?.()
59+
}
5560
identityRefreshChannel.subscribe(onIdentityRefresh)
5661
return () => identityRefreshChannel.unsubscribe(onIdentityRefresh)
5762
}

packages/dd-trace/src/runtime_metrics/otlp_runtime_metrics.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ module.exports = {
6363
this.stop()
6464

6565
client = createMetricsClient(config)
66-
unsubscribeIdentityRefresh = subscribeToIdentityRefresh(client, config)
66+
unsubscribeIdentityRefresh = subscribeToIdentityRefresh(client, config, resetSamplerBaselines)
6767
flushInterval = setInterval(() => {
6868
client.flush()
6969
}, config.DD_RUNTIME_METRICS_FLUSH_INTERVAL ?? 10_000)
@@ -251,6 +251,20 @@ module.exports = {
251251
},
252252
}
253253

254+
/**
255+
* Rebases the event-loop-delay and ELU sampler baselines to now. Without this, a MicroVM clone's
256+
* first collection after resume would report a delta spanning the snapshot pause instead of just
257+
* the time since resume.
258+
* @returns {void}
259+
*/
260+
function resetSamplerBaselines () {
261+
eventLoopHistogram?.reset()
262+
263+
if (lastElu !== null) {
264+
lastElu = performance.eventLoopUtilization()
265+
}
266+
}
267+
254268
/**
255269
* @param {Function} callback
256270
* @param {object} instrument

0 commit comments

Comments
 (0)