dd-trace 4.19.0 → 4.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/dd-trace/src/appsec/index.js +1 -1
- package/packages/dd-trace/src/appsec/rule_manager.js +9 -6
- package/packages/dd-trace/src/config.js +6 -1
- package/packages/dd-trace/src/id.js +12 -0
- package/packages/dd-trace/src/opentracing/propagation/text_map.js +14 -5
- package/packages/dd-trace/src/profiling/config.js +9 -10
- package/packages/dd-trace/src/profiling/profilers/events.js +6 -1
- package/packages/dd-trace/src/profiling/profilers/wall.js +93 -67
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const fs = require('fs')
|
|
3
4
|
const waf = require('./waf')
|
|
4
5
|
const { ACKNOWLEDGED, ERROR } = require('./remote_config/apply_states')
|
|
5
6
|
const blocking = require('./blocking')
|
|
@@ -13,13 +14,15 @@ let appliedExclusions = new Map()
|
|
|
13
14
|
let appliedCustomRules = new Map()
|
|
14
15
|
let appliedActions = new Map()
|
|
15
16
|
|
|
16
|
-
function
|
|
17
|
-
defaultRules = rules
|
|
17
|
+
function loadRules (config) {
|
|
18
|
+
defaultRules = config.rules
|
|
19
|
+
? JSON.parse(fs.readFileSync(config.rules))
|
|
20
|
+
: require('./recommended.json')
|
|
18
21
|
|
|
19
|
-
waf.init(
|
|
22
|
+
waf.init(defaultRules, config)
|
|
20
23
|
|
|
21
|
-
if (
|
|
22
|
-
blocking.updateBlockingConfiguration(
|
|
24
|
+
if (defaultRules.actions) {
|
|
25
|
+
blocking.updateBlockingConfiguration(defaultRules.actions.find(action => action.id === 'block'))
|
|
23
26
|
}
|
|
24
27
|
}
|
|
25
28
|
|
|
@@ -252,7 +255,7 @@ function clearAllRules () {
|
|
|
252
255
|
}
|
|
253
256
|
|
|
254
257
|
module.exports = {
|
|
255
|
-
|
|
258
|
+
loadRules,
|
|
256
259
|
updateWafFromRC,
|
|
257
260
|
clearAllRules
|
|
258
261
|
}
|
|
@@ -309,6 +309,10 @@ class Config {
|
|
|
309
309
|
options.tracePropagationStyle,
|
|
310
310
|
defaultPropagationStyle
|
|
311
311
|
)
|
|
312
|
+
const DD_TRACE_PROPAGATION_EXTRACT_FIRST = coalesce(
|
|
313
|
+
process.env.DD_TRACE_PROPAGATION_EXTRACT_FIRST,
|
|
314
|
+
false
|
|
315
|
+
)
|
|
312
316
|
const DD_TRACE_RUNTIME_ID_ENABLED = coalesce(
|
|
313
317
|
options.experimental && options.experimental.runtimeId,
|
|
314
318
|
process.env.DD_TRACE_EXPERIMENTAL_RUNTIME_ID_ENABLED,
|
|
@@ -579,6 +583,7 @@ ken|consumer_?(?:id|key|secret)|sign(?:ed|ature)?|auth(?:entication|orization)?)
|
|
|
579
583
|
inject: DD_TRACE_PROPAGATION_STYLE_INJECT,
|
|
580
584
|
extract: DD_TRACE_PROPAGATION_STYLE_EXTRACT
|
|
581
585
|
}
|
|
586
|
+
this.tracePropagationExtractFirst = isTrue(DD_TRACE_PROPAGATION_EXTRACT_FIRST)
|
|
582
587
|
this.experimental = {
|
|
583
588
|
runtimeId: isTrue(DD_TRACE_RUNTIME_ID_ENABLED),
|
|
584
589
|
exporter: DD_TRACE_EXPORTER,
|
|
@@ -610,7 +615,7 @@ ken|consumer_?(?:id|key|secret)|sign(?:ed|ature)?|auth(?:entication|orization)?)
|
|
|
610
615
|
this.tagsHeaderMaxLength = parseInt(DD_TRACE_X_DATADOG_TAGS_MAX_LENGTH)
|
|
611
616
|
this.appsec = {
|
|
612
617
|
enabled: DD_APPSEC_ENABLED,
|
|
613
|
-
rules: DD_APPSEC_RULES
|
|
618
|
+
rules: DD_APPSEC_RULES,
|
|
614
619
|
customRulesProvided: !!DD_APPSEC_RULES,
|
|
615
620
|
rateLimit: DD_APPSEC_TRACE_RATE_LIMIT,
|
|
616
621
|
wafTimeout: DD_APPSEC_WAF_TIMEOUT,
|
|
@@ -42,6 +42,18 @@ class Identifier {
|
|
|
42
42
|
toJSON () {
|
|
43
43
|
return this.toString()
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
equals (other) {
|
|
47
|
+
const length = this._buffer.length
|
|
48
|
+
const otherLength = other._buffer.length
|
|
49
|
+
|
|
50
|
+
// Only compare the bytes available in both IDs.
|
|
51
|
+
for (let i = length, j = otherLength; i >= 0 && j >= 0; i--, j--) {
|
|
52
|
+
if (this._buffer[i] !== other._buffer[j]) return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
// Create a buffer, using an optional hexadecimal value if provided.
|
|
@@ -236,11 +236,20 @@ class TextMapPropagator {
|
|
|
236
236
|
_extractDatadogContext (carrier) {
|
|
237
237
|
const spanContext = this._extractGenericContext(carrier, traceKey, spanKey, 10)
|
|
238
238
|
|
|
239
|
-
if (spanContext)
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
239
|
+
if (!spanContext) return spanContext
|
|
240
|
+
|
|
241
|
+
this._extractOrigin(carrier, spanContext)
|
|
242
|
+
this._extractBaggageItems(carrier, spanContext)
|
|
243
|
+
this._extractSamplingPriority(carrier, spanContext)
|
|
244
|
+
this._extractTags(carrier, spanContext)
|
|
245
|
+
|
|
246
|
+
if (this._config.tracePropagationExtractFirst) return spanContext
|
|
247
|
+
|
|
248
|
+
const tc = this._extractTraceparentContext(carrier)
|
|
249
|
+
|
|
250
|
+
if (tc && spanContext._traceId.equals(tc._traceId)) {
|
|
251
|
+
spanContext._traceparent = tc._traceparent
|
|
252
|
+
spanContext._tracestate = tc._tracestate
|
|
244
253
|
}
|
|
245
254
|
|
|
246
255
|
return spanContext
|
|
@@ -131,10 +131,12 @@ class Config {
|
|
|
131
131
|
: getProfilers({
|
|
132
132
|
DD_PROFILING_HEAP_ENABLED,
|
|
133
133
|
DD_PROFILING_WALLTIME_ENABLED,
|
|
134
|
-
DD_PROFILING_EXPERIMENTAL_TIMELINE_ENABLED,
|
|
135
134
|
DD_PROFILING_PROFILERS
|
|
136
135
|
})
|
|
137
136
|
|
|
137
|
+
this.timelineEnabled = isTrue(coalesce(options.timelineEnabled,
|
|
138
|
+
DD_PROFILING_EXPERIMENTAL_TIMELINE_ENABLED, false))
|
|
139
|
+
|
|
138
140
|
this.codeHotspotsEnabled = isTrue(coalesce(options.codeHotspotsEnabled,
|
|
139
141
|
DD_PROFILING_CODEHOTSPOTS_ENABLED,
|
|
140
142
|
DD_PROFILING_EXPERIMENTAL_CODEHOTSPOTS_ENABLED, false))
|
|
@@ -147,8 +149,7 @@ class Config {
|
|
|
147
149
|
module.exports = { Config }
|
|
148
150
|
|
|
149
151
|
function getProfilers ({
|
|
150
|
-
DD_PROFILING_HEAP_ENABLED, DD_PROFILING_WALLTIME_ENABLED,
|
|
151
|
-
DD_PROFILING_PROFILERS, DD_PROFILING_EXPERIMENTAL_TIMELINE_ENABLED
|
|
152
|
+
DD_PROFILING_HEAP_ENABLED, DD_PROFILING_WALLTIME_ENABLED, DD_PROFILING_PROFILERS
|
|
152
153
|
}) {
|
|
153
154
|
// First consider "legacy" DD_PROFILING_PROFILERS env variable, defaulting to wall + space
|
|
154
155
|
// Use a Set to avoid duplicates
|
|
@@ -172,11 +173,6 @@ function getProfilers ({
|
|
|
172
173
|
}
|
|
173
174
|
}
|
|
174
175
|
|
|
175
|
-
// Events profiler is a profiler for timeline events that goes with the wall
|
|
176
|
-
// profiler
|
|
177
|
-
if (profilers.has('wall') && DD_PROFILING_EXPERIMENTAL_TIMELINE_ENABLED) {
|
|
178
|
-
profilers.add('events')
|
|
179
|
-
}
|
|
180
176
|
return [...profilers]
|
|
181
177
|
}
|
|
182
178
|
|
|
@@ -238,8 +234,6 @@ function getProfiler (name, options) {
|
|
|
238
234
|
return new WallProfiler(options)
|
|
239
235
|
case 'space':
|
|
240
236
|
return new SpaceProfiler(options)
|
|
241
|
-
case 'events':
|
|
242
|
-
return new EventsProfiler(options)
|
|
243
237
|
default:
|
|
244
238
|
options.logger.error(`Unknown profiler "${name}"`)
|
|
245
239
|
}
|
|
@@ -257,6 +251,11 @@ function ensureProfilers (profilers, options) {
|
|
|
257
251
|
}
|
|
258
252
|
}
|
|
259
253
|
|
|
254
|
+
// Events profiler is a profiler for timeline events
|
|
255
|
+
if (options.timelineEnabled) {
|
|
256
|
+
profilers.push(new EventsProfiler(options))
|
|
257
|
+
}
|
|
258
|
+
|
|
260
259
|
// Filter out any invalid profilers
|
|
261
260
|
return profilers.filter(v => v)
|
|
262
261
|
}
|
|
@@ -36,7 +36,7 @@ class EventsProfiler {
|
|
|
36
36
|
this._observer = new PerformanceObserver(add.bind(this))
|
|
37
37
|
}
|
|
38
38
|
// Currently only support GC
|
|
39
|
-
this._observer.observe({
|
|
39
|
+
this._observer.observe({ entryTypes: ['gc'] })
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
stop () {
|
|
@@ -46,6 +46,11 @@ class EventsProfiler {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
profile () {
|
|
49
|
+
if (this.entries.length === 0) {
|
|
50
|
+
// No events in the period; don't produce a profile
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
49
54
|
const stringTable = new StringTable()
|
|
50
55
|
const timestampLabelKey = stringTable.dedup(END_TIMESTAMP)
|
|
51
56
|
const kindLabelKey = stringTable.dedup('gc type')
|
|
@@ -15,7 +15,7 @@ const spanFinishCh = dc.channel('dd-trace:span:finish')
|
|
|
15
15
|
const profilerTelemetryMetrics = telemetryMetrics.manager.namespace('profilers')
|
|
16
16
|
const threadName = `${threadNamePrefix} Event Loop`
|
|
17
17
|
|
|
18
|
-
const
|
|
18
|
+
const MemoizedWebTags = Symbol('NativeWallProfiler.MemoizedWebTags')
|
|
19
19
|
|
|
20
20
|
let kSampleCount
|
|
21
21
|
|
|
@@ -28,28 +28,6 @@ function getStartedSpans (context) {
|
|
|
28
28
|
return context._trace.started
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
function generateLabels ({ context: { spanId, rootSpanId, webTags, endpoint }, timestamp }) {
|
|
32
|
-
const labels = {
|
|
33
|
-
[THREAD_NAME]: threadName,
|
|
34
|
-
// Incoming timestamps are in microseconds, we emit nanos.
|
|
35
|
-
[END_TIMESTAMP]: timestamp * 1000n
|
|
36
|
-
}
|
|
37
|
-
if (spanId) {
|
|
38
|
-
labels['span id'] = spanId
|
|
39
|
-
}
|
|
40
|
-
if (rootSpanId) {
|
|
41
|
-
labels['local root span id'] = rootSpanId
|
|
42
|
-
}
|
|
43
|
-
if (webTags && Object.keys(webTags).length !== 0) {
|
|
44
|
-
labels['trace endpoint'] = endpointNameFromTags(webTags)
|
|
45
|
-
} else if (endpoint) {
|
|
46
|
-
// fallback to endpoint computed when sample was taken
|
|
47
|
-
labels['trace endpoint'] = endpoint
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return labels
|
|
51
|
-
}
|
|
52
|
-
|
|
53
31
|
function isWebServerSpan (tags) {
|
|
54
32
|
return tags[SPAN_TYPE] === WEB
|
|
55
33
|
}
|
|
@@ -61,6 +39,38 @@ function endpointNameFromTags (tags) {
|
|
|
61
39
|
].filter(v => v).join(' ')
|
|
62
40
|
}
|
|
63
41
|
|
|
42
|
+
function getWebTags (startedSpans, i, span) {
|
|
43
|
+
// Are web tags for this span already memoized?
|
|
44
|
+
const memoizedWebTags = span[MemoizedWebTags]
|
|
45
|
+
if (memoizedWebTags !== undefined) {
|
|
46
|
+
return memoizedWebTags
|
|
47
|
+
}
|
|
48
|
+
// No, we'll have to memoize a new value
|
|
49
|
+
function memoize (tags) {
|
|
50
|
+
span[MemoizedWebTags] = tags
|
|
51
|
+
return tags
|
|
52
|
+
}
|
|
53
|
+
// Is this span itself a web span?
|
|
54
|
+
const context = span.context()
|
|
55
|
+
const tags = context._tags
|
|
56
|
+
if (isWebServerSpan(tags)) {
|
|
57
|
+
return memoize(tags)
|
|
58
|
+
}
|
|
59
|
+
// It isn't. Get parent's web tags (memoize them too recursively.)
|
|
60
|
+
// There might be several webspans, for example with next.js, http plugin creates the first span
|
|
61
|
+
// and then next.js plugin creates a child span, and this child span has the correct endpoint
|
|
62
|
+
// information. That's why we always use the tags of the closest ancestor web span.
|
|
63
|
+
const parentId = context._parentId
|
|
64
|
+
while (--i >= 0) {
|
|
65
|
+
const ispan = startedSpans[i]
|
|
66
|
+
if (ispan.context()._spanId === parentId) {
|
|
67
|
+
return memoize(getWebTags(startedSpans, i, ispan))
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Local root span with no web span
|
|
71
|
+
return memoize(null)
|
|
72
|
+
}
|
|
73
|
+
|
|
64
74
|
class NativeWallProfiler {
|
|
65
75
|
constructor (options = {}) {
|
|
66
76
|
this.type = 'wall'
|
|
@@ -68,14 +78,30 @@ class NativeWallProfiler {
|
|
|
68
78
|
this._flushIntervalMillis = options.flushInterval || 60 * 1e3 // 60 seconds
|
|
69
79
|
this._codeHotspotsEnabled = !!options.codeHotspotsEnabled
|
|
70
80
|
this._endpointCollectionEnabled = !!options.endpointCollectionEnabled
|
|
71
|
-
this.
|
|
81
|
+
this._timelineEnabled = !!options.timelineEnabled
|
|
82
|
+
// We need to capture span data into the sample context for either code hotspots
|
|
83
|
+
// or endpoint collection.
|
|
84
|
+
this._captureSpanData = this._codeHotspotsEnabled || this._endpointCollectionEnabled
|
|
85
|
+
// We need to run the pprof wall profiler with sample contexts if we're either
|
|
86
|
+
// capturing span data or timeline is enabled (so we need sample timestamps, and for now
|
|
87
|
+
// timestamps require the sample contexts feature in the pprof wall profiler.)
|
|
88
|
+
this._withContexts = this._captureSpanData || this._timelineEnabled
|
|
72
89
|
this._v8ProfilerBugWorkaroundEnabled = !!options.v8ProfilerBugWorkaroundEnabled
|
|
73
90
|
this._mapper = undefined
|
|
74
91
|
this._pprof = undefined
|
|
75
92
|
|
|
76
|
-
// Bind to this so
|
|
77
|
-
|
|
78
|
-
|
|
93
|
+
// Bind these to this so they can be used as callbacks
|
|
94
|
+
if (this._withContexts) {
|
|
95
|
+
if (this._captureSpanData) {
|
|
96
|
+
this._enter = this._enter.bind(this)
|
|
97
|
+
this._spanFinished = this._spanFinished.bind(this)
|
|
98
|
+
}
|
|
99
|
+
this._generateLabels = this._generateLabels.bind(this)
|
|
100
|
+
} else {
|
|
101
|
+
// Explicitly assigning, to express the intent that this is meant to be
|
|
102
|
+
// undefined when passed to pprof.time.stop() when not using sample contexts.
|
|
103
|
+
this._generateLabels = undefined
|
|
104
|
+
}
|
|
79
105
|
this._logger = options.logger
|
|
80
106
|
this._started = false
|
|
81
107
|
}
|
|
@@ -113,17 +139,20 @@ class NativeWallProfiler {
|
|
|
113
139
|
})
|
|
114
140
|
|
|
115
141
|
if (this._withContexts) {
|
|
116
|
-
this._profilerState = this._pprof.time.getState()
|
|
117
142
|
this._currentContext = {}
|
|
118
143
|
this._pprof.time.setContext(this._currentContext)
|
|
119
|
-
this._lastSpan = undefined
|
|
120
|
-
this._lastStartedSpans = undefined
|
|
121
|
-
this._lastWebTags = undefined
|
|
122
|
-
this._lastSampleCount = 0
|
|
123
144
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
145
|
+
if (this._captureSpanData) {
|
|
146
|
+
this._profilerState = this._pprof.time.getState()
|
|
147
|
+
this._lastSpan = undefined
|
|
148
|
+
this._lastStartedSpans = undefined
|
|
149
|
+
this._lastWebTags = undefined
|
|
150
|
+
this._lastSampleCount = 0
|
|
151
|
+
|
|
152
|
+
beforeCh.subscribe(this._enter)
|
|
153
|
+
enterCh.subscribe(this._enter)
|
|
154
|
+
spanFinishCh.subscribe(this._spanFinished)
|
|
155
|
+
}
|
|
127
156
|
}
|
|
128
157
|
|
|
129
158
|
this._started = true
|
|
@@ -149,33 +178,7 @@ class NativeWallProfiler {
|
|
|
149
178
|
const startedSpans = getStartedSpans(context)
|
|
150
179
|
this._lastStartedSpans = startedSpans
|
|
151
180
|
if (this._endpointCollectionEnabled) {
|
|
152
|
-
|
|
153
|
-
if (cachedWebTags === undefined) {
|
|
154
|
-
let found = false
|
|
155
|
-
// Find the first webspan starting from the end:
|
|
156
|
-
// There might be several webspans, for example with next.js, http plugin creates a first span
|
|
157
|
-
// and then next.js plugin creates a child span, and this child span has the correct endpoint information.
|
|
158
|
-
let nextSpanId = context._spanId
|
|
159
|
-
for (let i = startedSpans.length - 1; i >= 0; i--) {
|
|
160
|
-
const nextContext = startedSpans[i].context()
|
|
161
|
-
if (nextContext._spanId === nextSpanId) {
|
|
162
|
-
const tags = nextContext._tags
|
|
163
|
-
if (isWebServerSpan(tags)) {
|
|
164
|
-
this._lastWebTags = tags
|
|
165
|
-
span[CachedWebTags] = tags
|
|
166
|
-
found = true
|
|
167
|
-
break
|
|
168
|
-
}
|
|
169
|
-
nextSpanId = nextContext._parentId
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
if (!found) {
|
|
173
|
-
this._lastWebTags = undefined
|
|
174
|
-
span[CachedWebTags] = null // cache negative lookup result
|
|
175
|
-
}
|
|
176
|
-
} else {
|
|
177
|
-
this._lastWebTags = cachedWebTags
|
|
178
|
-
}
|
|
181
|
+
this._lastWebTags = getWebTags(startedSpans, startedSpans.length, span)
|
|
179
182
|
}
|
|
180
183
|
} else {
|
|
181
184
|
this._lastStartedSpans = undefined
|
|
@@ -204,8 +207,8 @@ class NativeWallProfiler {
|
|
|
204
207
|
}
|
|
205
208
|
|
|
206
209
|
_spanFinished (span) {
|
|
207
|
-
if (span[
|
|
208
|
-
span[
|
|
210
|
+
if (span[MemoizedWebTags]) {
|
|
211
|
+
span[MemoizedWebTags] = undefined
|
|
209
212
|
}
|
|
210
213
|
}
|
|
211
214
|
|
|
@@ -221,12 +224,12 @@ class NativeWallProfiler {
|
|
|
221
224
|
|
|
222
225
|
_stop (restart) {
|
|
223
226
|
if (!this._started) return
|
|
224
|
-
if (this.
|
|
227
|
+
if (this._captureSpanData) {
|
|
225
228
|
// update last sample context if needed
|
|
226
229
|
this._enter()
|
|
227
230
|
this._lastSampleCount = 0
|
|
228
231
|
}
|
|
229
|
-
const profile = this._pprof.time.stop(restart, this.
|
|
232
|
+
const profile = this._pprof.time.stop(restart, this._generateLabels)
|
|
230
233
|
if (restart) {
|
|
231
234
|
const v8BugDetected = this._pprof.time.v8ProfilerStuckEventLoopDetected()
|
|
232
235
|
if (v8BugDetected !== 0) {
|
|
@@ -236,6 +239,29 @@ class NativeWallProfiler {
|
|
|
236
239
|
return profile
|
|
237
240
|
}
|
|
238
241
|
|
|
242
|
+
_generateLabels ({ context: { spanId, rootSpanId, webTags, endpoint }, timestamp }) {
|
|
243
|
+
const labels = this._timelineEnabled ? {
|
|
244
|
+
[THREAD_NAME]: threadName,
|
|
245
|
+
// Incoming timestamps are in microseconds, we emit nanos.
|
|
246
|
+
[END_TIMESTAMP]: timestamp * 1000n
|
|
247
|
+
} : {}
|
|
248
|
+
|
|
249
|
+
if (spanId) {
|
|
250
|
+
labels['span id'] = spanId
|
|
251
|
+
}
|
|
252
|
+
if (rootSpanId) {
|
|
253
|
+
labels['local root span id'] = rootSpanId
|
|
254
|
+
}
|
|
255
|
+
if (webTags && Object.keys(webTags).length !== 0) {
|
|
256
|
+
labels['trace endpoint'] = endpointNameFromTags(webTags)
|
|
257
|
+
} else if (endpoint) {
|
|
258
|
+
// fallback to endpoint computed when sample was taken
|
|
259
|
+
labels['trace endpoint'] = endpoint
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return labels
|
|
263
|
+
}
|
|
264
|
+
|
|
239
265
|
profile () {
|
|
240
266
|
return this._stop(true)
|
|
241
267
|
}
|
|
@@ -248,7 +274,7 @@ class NativeWallProfiler {
|
|
|
248
274
|
if (!this._started) return
|
|
249
275
|
|
|
250
276
|
const profile = this._stop(false)
|
|
251
|
-
if (this.
|
|
277
|
+
if (this._captureSpanData) {
|
|
252
278
|
beforeCh.unsubscribe(this._enter)
|
|
253
279
|
enterCh.unsubscribe(this._enter)
|
|
254
280
|
spanFinishCh.unsubscribe(this._spanFinished)
|