Skip to content

Commit 635402e

Browse files
authored
feat(profiler): add custom profiling labels API (#7879)
* feat(profiler): add custom profiling labels API Add tracer.profiling.runWithLabels() and tracer.profiling.setCustomLabelKeys() APIs that allow users to attach custom labels to wall profiler samples, enabling flame graph filtering by business logic dimensions (e.g. customer, region) in the Datadog UI. This brings Node.js profiler to parity with Java and Go. The implementation stores custom labels alongside span profiling context in pprof's CPED AsyncLocalStorage as a 2-element array [profilingContext, customLabels]. A monotonic flag avoids getContext() overhead when custom labels have never been used. Labels propagate correctly through async continuations. Internal labels (span id, thread name, etc.) always take precedence over custom labels with the same key. Requires AsyncContextFrame (ACF) to be enabled; gracefully falls back to a passthrough (just calls fn()) when ACF is off or profiling is disabled.
1 parent 1fd6eb2 commit 635402e

9 files changed

Lines changed: 627 additions & 8 deletions

File tree

docs/test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,14 @@ const res = {} as OutgoingMessage
522522
resBlockRequest = tracer.appsec.blockRequest(req, res)
523523
tracer.appsec.setUser(user)
524524

525+
// Profiling custom labels
526+
tracer.profiling.setCustomLabelKeys(['customer', 'region'])
527+
tracer.profiling.setCustomLabelKeys(new Set(['customer', 'region']))
528+
const labelResult: number = tracer.profiling.runWithLabels({ customer: 'acme', region: 'us-east' }, () => 42)
529+
tracer.profiling.runWithLabels({ tier: 'premium' }, () => {
530+
tracer.profiling.runWithLabels({ region: 'eu-west' }, () => {})
531+
})
532+
525533
// OTel TracerProvider registers and provides a tracer
526534
const provider: opentelemetry.TracerProvider = new tracer.TracerProvider();
527535
provider.register();

index.d.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ interface Tracer extends opentracing.Tracer {
130130

131131
appsec: tracer.Appsec;
132132

133+
/**
134+
* Profiling API for attaching custom labels to profiler samples.
135+
*/
136+
profiling: tracer.Profiling;
137+
133138
TracerProvider: tracer.opentelemetry.TracerProvider;
134139

135140
dogstatsd: tracer.DogStatsD;
@@ -1570,6 +1575,35 @@ declare namespace tracer {
15701575
trackUserLoginFailure(login: string, metadata?: any): void;
15711576
}
15721577

1578+
export interface Profiling {
1579+
/**
1580+
* Declares the set of custom label keys that will be used with
1581+
* {@link runWithLabels}. This is used for profile upload metadata
1582+
* (so the Datadog UI knows which keys to index for filtering) and
1583+
* for pprof serialization optimization.
1584+
*
1585+
* @param keys Custom label key names.
1586+
*/
1587+
setCustomLabelKeys(keys: Iterable<string>): void;
1588+
1589+
/**
1590+
* Runs a function with custom profiling labels attached to all wall profiler
1591+
* samples taken during its execution. Labels are key-value pairs that appear
1592+
* in the pprof output and can be used to filter flame graphs in the Datadog UI.
1593+
*
1594+
* Requires AsyncContextFrame (ACF) to be enabled. Supports nesting: inner
1595+
* calls merge labels with outer calls, with inner values taking precedence.
1596+
*
1597+
* When profiling is not enabled or ACF is not active, the function is still
1598+
* called but labels are silently dropped.
1599+
*
1600+
* @param labels Custom labels to attach to profiler samples.
1601+
* @param fn Function to execute with the labels.
1602+
* @returns The return value of fn.
1603+
*/
1604+
runWithLabels<T>(labels: Record<string, string | number>, fn: () => T): T;
1605+
}
1606+
15731607
export interface Appsec {
15741608
/**
15751609
* Links a successful login event to the current trace. Will link the passed user to the current trace with Appsec.setUser() internally.

packages/dd-trace/src/noop/proxy.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ const noopDogStatsDClient = new NoopDogStatsDClient()
1313
const noopLLMObs = new NoopLLMObsSDK(noop)
1414
const noopOpenFeatureProvider = new NoopFlaggingProvider()
1515
const noopAIGuard = new NoopAIGuardSDK()
16+
const noopProfiling = {
17+
setCustomLabelKeys () {},
18+
runWithLabels (labels, fn) { return fn() },
19+
}
1620

1721
/** @type {import('../../src/index')} Proxy */
1822
class NoopProxy {
@@ -98,6 +102,10 @@ class NoopProxy {
98102
return this
99103
}
100104

105+
get profiling () {
106+
return noopProfiling
107+
}
108+
101109
get TracerProvider () {
102110
return require('../opentelemetry/tracer_provider')
103111
}

packages/dd-trace/src/profiler.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,26 @@ module.exports = {
1414
stop: () => {
1515
profiler.stop()
1616
},
17+
18+
/**
19+
* Declares the set of custom label keys that will be used with
20+
* {@link runWithLabels}.
21+
*
22+
* @param {Iterable<string>} keys - Custom label key names
23+
*/
24+
setCustomLabelKeys: (keys) => {
25+
profiler.setCustomLabelKeys(keys)
26+
},
27+
28+
/**
29+
* Runs a function with custom profiling labels attached to wall profiler samples.
30+
*
31+
* @param {Record<string, string | number>} labels - Custom labels to attach
32+
* @param {function(): T} fn - Function to execute with the labels
33+
* @returns {T} The return value of fn
34+
* @template T
35+
*/
36+
runWithLabels: (labels, fn) => {
37+
return profiler.runWithLabels(labels, fn)
38+
},
1739
}

packages/dd-trace/src/profiling/exporters/event_serializer.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class EventSerializer {
2222
return `${type}.pprof`
2323
}
2424

25-
getEventJSON ({ profiles, infos, start, end, tags = {}, endpointCounts }) {
25+
getEventJSON ({ profiles, infos, start, end, tags = {}, endpointCounts, customAttributes }) {
2626
const event = {
2727
attachments: Object.keys(profiles).map(t => this.typeToFile(t)),
2828
start: start.toISOString(),
@@ -80,6 +80,10 @@ class EventSerializer {
8080
},
8181
}
8282

83+
if (customAttributes) {
84+
event.custom_attributes = customAttributes
85+
}
86+
8387
if (processTags.serialized) {
8488
event[processTags.PROFILING_FIELD_NAME] = processTags.serialized
8589
}

packages/dd-trace/src/profiling/profiler.js

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class Profiler extends EventEmitter {
5151
#compressionFnInitialized = false
5252
#compressionOptions
5353
#config
54+
#customLabelKeys = new Set()
5455
#enabled = false
5556
#endpointCounts = new Map()
5657
#lastStart
@@ -135,6 +136,45 @@ class Profiler extends EventEmitter {
135136
return this.#enabled
136137
}
137138

139+
/**
140+
* Declares the set of custom label keys that will be used with
141+
* {@link runWithLabels}. This is used for profile upload metadata and
142+
* for pprof serialization optimization (low-cardinality deduplication).
143+
*
144+
* @param {Iterable<string>} keys - Custom label key names
145+
*/
146+
setCustomLabelKeys (keys) {
147+
this.#customLabelKeys.clear()
148+
for (const key of keys) {
149+
this.#customLabelKeys.add(key)
150+
}
151+
if (this.#config) {
152+
for (const profiler of this.#config.profilers) {
153+
profiler.setCustomLabelKeys?.(this.#customLabelKeys)
154+
}
155+
}
156+
}
157+
158+
/**
159+
* Runs a function with custom profiling labels attached to wall profiler samples.
160+
*
161+
* @param {Record<string, string | number>} labels - Custom labels to attach
162+
* @param {function(): T} fn - Function to execute with the labels
163+
* @returns {T} The return value of fn
164+
* @template T
165+
*/
166+
runWithLabels (labels, fn) {
167+
if (!this.#enabled || !this.#config) {
168+
return fn()
169+
}
170+
for (const profiler of this.#config.profilers) {
171+
if (profiler.runWithLabels) {
172+
return profiler.runWithLabels(labels, fn)
173+
}
174+
}
175+
return fn()
176+
}
177+
138178
#logError (err) {
139179
logError(this.#logger, err)
140180
}
@@ -410,7 +450,10 @@ class Profiler extends EventEmitter {
410450

411451
tags.snapshot = snapshotKind
412452
tags.profile_seq = this.#profileSeq++
413-
const exportSpec = { profiles, infos, start, end, tags, endpointCounts }
453+
const customAttributes = this.#customLabelKeys.size > 0
454+
? [...this.#customLabelKeys]
455+
: undefined
456+
const exportSpec = { profiles, infos, start, end, tags, endpointCounts, customAttributes }
414457
const tasks = this.#config.exporters.map(exporter =>
415458
exporter.export(exportSpec).catch(err => {
416459
if (this.#logger) {

packages/dd-trace/src/profiling/profilers/wall.js

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ class NativeWallProfiler {
108108
#captureSpanData = false
109109
#codeHotspotsEnabled = false
110110
#cpuProfilingEnabled = false
111+
#customLabelsActive = false
112+
#customLabelKeys
111113
#endpointCollectionEnabled = false
112114
#flushIntervalMillis = 0
113115
#logger
@@ -245,7 +247,24 @@ class NativeWallProfiler {
245247
// context -- we simply can't tell which one it might've been across all
246248
// possible async context frames.
247249
if (this.#asyncContextFrameEnabled) {
248-
this.#pprof.time.setContext(sampleContext)
250+
if (this.#customLabelsActive) {
251+
// Custom labels may be active in this async context. The current CPED
252+
// context could be a 2-element array [profilingContext, customLabels].
253+
// Replace the profiling context while preserving the custom labels.
254+
// This flag is monotonic (once set, stays true) because async
255+
// continuations from runWithLabels can fire at any time after the
256+
// synchronous runWithLabels call has returned.
257+
const current = this.#pprof.time.getContext()
258+
if (Array.isArray(current)) {
259+
if (current[0] !== sampleContext) {
260+
this.#pprof.time.setContext([sampleContext, current[1]])
261+
}
262+
} else if (current !== sampleContext) {
263+
this.#pprof.time.setContext(sampleContext)
264+
}
265+
} else {
266+
this.#pprof.time.setContext(sampleContext)
267+
}
249268
} else {
250269
const sampleCount = this._profilerState[kSampleCount]
251270
if (sampleCount !== this._lastSampleCount) {
@@ -344,6 +363,13 @@ class NativeWallProfiler {
344363
const lowCardinalityLabels = Object.keys(getThreadLabels())
345364
lowCardinalityLabels.push(TRACE_ENDPOINT_LABEL)
346365

366+
// Custom labels are expected to be low-cardinality (e.g. customer tier, region)
367+
if (this.#customLabelKeys) {
368+
for (const key of this.#customLabelKeys) {
369+
lowCardinalityLabels.push(key)
370+
}
371+
}
372+
347373
const profile = this.#pprof.time.stop(restart, this.#boundGenerateLabels, lowCardinalityLabels)
348374

349375
if (restart) {
@@ -383,7 +409,29 @@ class NativeWallProfiler {
383409
return getThreadLabels()
384410
}
385411

386-
const labels = { ...getThreadLabels() }
412+
// Native profiler doesn't set context.context for some samples, such as idle samples or when
413+
// the context was otherwise unavailable when the sample was taken. Note that with ACF, we don't
414+
// use the "ref" indirection.
415+
let ref
416+
let customLabels
417+
const cctx = context.context
418+
if (this.#asyncContextFrameEnabled) {
419+
// When custom labels are active with ACF, context.context is a 2-element array:
420+
// [profilingContext, customLabels]. Otherwise it's a plain object.
421+
if (Array.isArray(cctx)) {
422+
[ref, customLabels] = cctx
423+
} else {
424+
ref = cctx
425+
}
426+
} else {
427+
ref = cctx?.ref
428+
}
429+
430+
// Custom labels are spread first so that internal labels always take
431+
// precedence and overwrite them.
432+
const labels = customLabels === undefined
433+
? { ...getThreadLabels() }
434+
: { ...customLabels, ...getThreadLabels() }
387435

388436
if (this.#timelineEnabled) {
389437
// Incoming timestamps are in microseconds, we emit nanos.
@@ -395,10 +443,6 @@ class NativeWallProfiler {
395443
labels['async id'] = asyncId
396444
}
397445

398-
// Native profiler doesn't set context.context for some samples, such as idle samples or when
399-
// the context was otherwise unavailable when the sample was taken. Note that with async context
400-
// frame, we don't use the "ref" indirection.
401-
const ref = this.#asyncContextFrameEnabled ? context.context : context.context?.ref
402446
if (typeof ref !== 'object') {
403447
return labels
404448
}
@@ -421,6 +465,45 @@ class NativeWallProfiler {
421465
return labels
422466
}
423467

468+
/**
469+
* Sets the custom label keys used for pprof low-cardinality deduplication.
470+
* Called once by the top-level Profiler when keys are declared.
471+
*
472+
* @param {Iterable<string>} keys
473+
*/
474+
setCustomLabelKeys (keys) {
475+
this.#customLabelKeys = keys
476+
}
477+
478+
/**
479+
* Runs a function with custom profiling labels attached to all wall profiler
480+
* samples taken during its execution. Labels are key-value pairs that appear
481+
* in the pprof output and can be used to filter flame graphs in the Datadog UI.
482+
*
483+
* Requires AsyncContextFrame (ACF) to be enabled. Supports nesting: inner
484+
* calls merge labels with outer calls, with inner values taking precedence.
485+
*
486+
* @param {Record<string, string | number>} labels - Custom labels to attach
487+
* @param {function(): T} fn - Function to execute with the labels
488+
* @returns {T} The return value of fn
489+
* @template T
490+
*/
491+
runWithLabels (labels, fn) {
492+
if (!this.#asyncContextFrameEnabled || !this.#withContexts) {
493+
return fn()
494+
}
495+
496+
// Read current context; merge custom labels if already in a runWithLabels scope
497+
const current = this.#pprof.time.getContext()
498+
const isCurrentArray = Array.isArray(current)
499+
const customLabels = isCurrentArray ? { ...current[1], ...labels } : labels
500+
501+
const profilingContext = (isCurrentArray ? current[0] : current) ?? {}
502+
503+
this.#customLabelsActive = true
504+
return this.#pprof.time.runWithContext([profilingContext, customLabels], fn)
505+
}
506+
424507
profile (restart) {
425508
return this.#stop(restart)
426509
}

packages/dd-trace/src/proxy.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,25 @@ class Tracer extends NoopProxy {
330330
}
331331
}
332332

333+
/**
334+
* @override
335+
*/
336+
get profiling () {
337+
// Lazily require the profiler module and cache the result. If profiling
338+
// is not enabled, runWithLabels still works as a passthrough (just calls fn()).
339+
const profilerModule = require('./profiler')
340+
const profiling = {
341+
setCustomLabelKeys (keys) {
342+
profilerModule.setCustomLabelKeys(keys)
343+
},
344+
runWithLabels (labels, fn) {
345+
return profilerModule.runWithLabels(labels, fn)
346+
},
347+
}
348+
Reflect.defineProperty(this, 'profiling', { value: profiling, configurable: true, enumerable: true })
349+
return profiling
350+
}
351+
333352
/**
334353
* @override
335354
*/

0 commit comments

Comments
 (0)