@@ -99,12 +99,20 @@ describe('otel-thread-ctx', () => {
9999 this . traceId = traceId
100100 this . spanId = spanId
101101 this . attributes = attributes
102- this . appendAttributes = sinon . stub ( )
103- this . invalidate = sinon . stub ( )
104- this . isTruncated = sinon . stub ( ) . returns ( false )
102+ // Spied per instance so tests can assert call history on the context,
103+ // while the methods themselves stay on the prototype where start()'s
104+ // compatibility check looks for them, as they are on the native class.
105+ sinon . spy ( this , 'appendAttributes' )
106+ sinon . spy ( this , 'invalidate' )
105107 constructedContexts . push ( this )
106108 }
107109
110+ appendAttributes ( ) { }
111+
112+ invalidate ( ) { }
113+
114+ isTruncated ( ) { return false }
115+
108116 enter ( ) { setActive ( this ) }
109117 }
110118
@@ -114,6 +122,10 @@ describe('otel-thread-ctx', () => {
114122 ThreadContext : StubThreadContext ,
115123 getContext : sinon . stub ( ) . callsFake ( ( ) => activeContext ) ,
116124 clearContext : sinon . stub ( ) . callsFake ( ( ) => setActive ( ) ) ,
125+ getProcessContextAttributes : sinon . stub ( ) . returns ( {
126+ 'threadlocal.schema_version' : 'nodejs_v1_dev' ,
127+ 'threadlocal.attribute_key_map' : [ ] ,
128+ } ) ,
117129 } ,
118130 }
119131
@@ -186,6 +198,78 @@ describe('otel-thread-ctx', () => {
186198 assert . equal ( m . start ( ) , false )
187199 sinon . assert . calledWithMatch ( log . warn , / o t e l T h r e a d C t x A P I / )
188200 } )
201+
202+ it ( 'returns false when ThreadContext is missing a method the writer calls' , ( ) => {
203+ // An older or overridden @datadog /pprof can expose the gated namespace
204+ // without every ThreadContext method. Starting anyway would defer the
205+ // failure to a diagnostic-channel subscriber: a missing invalidate() would
206+ // throw out of the span-finish path and up through DatadogSpan#finish()
207+ // into application code.
208+ for ( const method of [ 'appendAttributes' , 'enter' , 'invalidate' ] ) {
209+ const Incomplete = class extends StubThreadContext { }
210+ Incomplete . prototype [ method ] = undefined
211+ const m = loadModule ( {
212+ pprof : {
213+ '@noCallThru' : true ,
214+ otelThreadCtx : { ...pprofStub . otelThreadCtx , ThreadContext : Incomplete } ,
215+ } ,
216+ } )
217+ assert . equal ( m . start ( ) , false , `expected start() to refuse a ThreadContext without ${ method } ` )
218+ sinon . assert . calledWithMatch ( log . warn , / o t e l T h r e a d C t x A P I / , `ThreadContext.prototype.${ method } ` )
219+ log . warn . resetHistory ( )
220+ }
221+ } )
222+
223+ it ( 'returns false when a context cannot be installed in this process' , ( ) => {
224+ // @datadog /pprof infers AsyncContextFrame availability from process.execArgv
225+ // and throws from enter() when it concludes it is unavailable, which
226+ // disagrees with our own feature detection when the flag arrived via
227+ // NODE_OPTIONS or a worker's execArgv was overridden. Subscribers run inline
228+ // with storage.enterWith, so this must not be discovered on first activation.
229+ const Throwing = class extends StubThreadContext {
230+ enter ( ) { throw new Error ( 'async_context_frame support is unavailable' ) }
231+ }
232+ const m = loadModule ( {
233+ pprof : {
234+ '@noCallThru' : true ,
235+ otelThreadCtx : { ...pprofStub . otelThreadCtx , ThreadContext : Throwing } ,
236+ } ,
237+ } )
238+ assert . equal ( m . start ( ) , false )
239+ sinon . assert . calledWithMatch ( log . warn , / c a n n o t i n s t a l l a t h r e a d c o n t e x t / )
240+ // No subscriber may be left behind, so a subsequent activation must not
241+ // reach the writer — and so must not hit the throwing enter() either.
242+ activeSpan = makeSpan ( )
243+ enterCh . publish ( )
244+ assert . equal ( constructedContexts . length , 1 ) // just the failed probe
245+ } )
246+
247+ it ( 'leaves no context installed after the start-up probe succeeds' , ( ) => {
248+ const m = loadModule ( )
249+ assert . equal ( m . start ( ) , true )
250+ assert . equal ( constructedContexts . length , 1 )
251+ sinon . assert . calledOnce ( pprofStub . otelThreadCtx . clearContext )
252+ assert . equal ( activeContext , undefined )
253+ } )
254+
255+ it ( 'returns false when otelThreadCtx is missing getProcessContextAttributes' , ( ) => {
256+ // start() must refuse to install subscribers if the metadata publisher
257+ // can't be produced — otherwise the writer emits records that no reader
258+ // can decode.
259+ const m = loadModule ( {
260+ pprof : {
261+ '@noCallThru' : true ,
262+ otelThreadCtx : {
263+ ThreadContext : StubThreadContext ,
264+ getContext : sinon . stub ( ) ,
265+ clearContext : sinon . stub ( ) ,
266+ // no getProcessContextAttributes
267+ } ,
268+ } ,
269+ } )
270+ assert . equal ( m . start ( ) , false )
271+ sinon . assert . calledWithMatch ( log . warn , / o t e l T h r e a d C t x A P I / )
272+ } )
189273 } )
190274
191275 describe ( 'subscribed behavior' , ( ) => {
@@ -194,6 +278,11 @@ describe('otel-thread-ctx', () => {
194278 beforeEach ( ( ) => {
195279 otelThreadCtx = loadModule ( )
196280 assert . equal ( otelThreadCtx . start ( ) , true )
281+ // start() installs and detaches one throwaway context to prove the process
282+ // can; drop its traces so the tests below see only their own activity.
283+ constructedContexts . length = 0
284+ setActive . resetHistory ( )
285+ pprofStub . otelThreadCtx . clearContext . resetHistory ( )
197286 } )
198287
199288 it ( 'clearContext when no active span' , ( ) => {
@@ -513,4 +602,66 @@ describe('otel-thread-ctx', () => {
513602 assert . equal ( constructedContexts . length , 0 )
514603 } )
515604 } )
605+
606+ describe ( 'getThreadLocalMetadata()' , ( ) => {
607+ // start() is always called before getThreadLocalMetadata() in the
608+ // real init sequence (proxy.js kicks off the writer, then storeConfig()
609+ // pulls the metadata). getThreadLocalMetadata is a no-op unless start
610+ // succeeded, so every happy-path test runs start() first.
611+ it ( 'returns undefined when start() has not been called' , ( ) => {
612+ pprofStub . otelThreadCtx . getProcessContextAttributes = sinon . stub ( )
613+ const m = loadModule ( )
614+ assert . equal ( m . getThreadLocalMetadata ( ) , undefined )
615+ sinon . assert . notCalled ( pprofStub . otelThreadCtx . getProcessContextAttributes )
616+ } )
617+
618+ it ( 'returns the process-context snapshot in libdatadog-nodejs shape' , ( ) => {
619+ pprofStub . otelThreadCtx . getProcessContextAttributes = sinon . stub ( ) . returns ( {
620+ 'threadlocal.schema_version' : 'nodejs_v1_dev' ,
621+ 'threadlocal.attribute_key_map' : [ 'datadog.trace_endpoint' , 'datadog.thread_name' ] ,
622+ 'threadlocal.wrapped_object_offset' : 24 ,
623+ 'threadlocal.tagged_size' : 8 ,
624+ } )
625+ const m = loadModule ( )
626+ assert . equal ( m . start ( ) , true )
627+ const md = m . getThreadLocalMetadata ( )
628+ sinon . assert . calledOnceWithExactly (
629+ pprofStub . otelThreadCtx . getProcessContextAttributes ,
630+ m . ATTRIBUTE_KEYS
631+ )
632+ assert . deepEqual ( md . attributeKeys , [ 'datadog.trace_endpoint' , 'datadog.thread_name' ] )
633+ assert . equal ( md . schemaVersion , 'nodejs_v1_dev' )
634+ // Order isn't guaranteed since Object.entries is object-key ordered.
635+ const byKey = Object . fromEntries ( md . extraAttributes . map ( a => [ a . key , a ] ) )
636+ assert . deepEqual ( byKey [ 'threadlocal.wrapped_object_offset' ] ,
637+ { key : 'threadlocal.wrapped_object_offset' , intValue : 24 } )
638+ assert . deepEqual ( byKey [ 'threadlocal.tagged_size' ] ,
639+ { key : 'threadlocal.tagged_size' , intValue : 8 } )
640+ } )
641+
642+ it ( 'encodes string-valued extra attributes as stringValue' , ( ) => {
643+ pprofStub . otelThreadCtx . getProcessContextAttributes = sinon . stub ( ) . returns ( {
644+ 'threadlocal.schema_version' : 'nodejs_v1_dev' ,
645+ 'threadlocal.attribute_key_map' : [ ] ,
646+ 'threadlocal.runtime.name' : 'nodejs' ,
647+ } )
648+ const m = loadModule ( )
649+ assert . equal ( m . start ( ) , true )
650+ const md = m . getThreadLocalMetadata ( )
651+ assert . deepEqual ( md . extraAttributes , [
652+ { key : 'threadlocal.runtime.name' , stringValue : 'nodejs' } ,
653+ ] )
654+ } )
655+
656+ it ( 'throws on an extra attribute with an unsupported value type' , ( ) => {
657+ pprofStub . otelThreadCtx . getProcessContextAttributes = sinon . stub ( ) . returns ( {
658+ 'threadlocal.schema_version' : 'nodejs_v1_dev' ,
659+ 'threadlocal.attribute_key_map' : [ ] ,
660+ 'threadlocal.weird' : true , // booleans aren't wired through yet
661+ } )
662+ const m = loadModule ( )
663+ assert . equal ( m . start ( ) , true )
664+ assert . throws ( ( ) => m . getThreadLocalMetadata ( ) , / u n s u p p o r t e d v a l u e t y p e / )
665+ } )
666+ } )
516667} )
0 commit comments