dd-trace 2.3.1 → 2.4.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/index.d.ts +51 -0
- package/package.json +2 -2
- package/packages/datadog-instrumentations/index.js +8 -0
- package/packages/datadog-instrumentations/src/amqp10.js +70 -0
- package/packages/datadog-instrumentations/src/amqplib.js +58 -0
- package/packages/datadog-instrumentations/src/cassandra-driver.js +191 -0
- package/packages/datadog-instrumentations/src/cucumber.js +2 -0
- package/packages/datadog-instrumentations/src/helpers/instrument.js +3 -3
- package/packages/datadog-instrumentations/src/mocha.js +122 -0
- package/packages/datadog-instrumentations/src/mongodb-core.js +179 -0
- package/packages/datadog-instrumentations/src/pg.js +75 -0
- package/packages/datadog-instrumentations/src/rhea.js +224 -0
- package/packages/datadog-instrumentations/src/tedious.js +66 -0
- package/packages/datadog-plugin-amqp10/src/index.js +79 -122
- package/packages/datadog-plugin-amqplib/src/index.js +77 -142
- package/packages/datadog-plugin-cassandra-driver/src/index.js +52 -224
- package/packages/datadog-plugin-cucumber/src/index.js +3 -1
- package/packages/datadog-plugin-jest/src/jest-jasmine2.js +5 -3
- package/packages/datadog-plugin-mocha/src/index.js +96 -207
- package/packages/datadog-plugin-mongodb-core/src/index.js +119 -3
- package/packages/datadog-plugin-pg/src/index.js +32 -69
- package/packages/datadog-plugin-rhea/src/index.js +59 -225
- package/packages/datadog-plugin-tedious/src/index.js +38 -86
- package/packages/dd-trace/lib/version.js +1 -1
- package/packages/dd-trace/src/appsec/recommended.json +137 -116
- package/packages/dd-trace/src/config.js +6 -0
- package/packages/dd-trace/src/noop/tracer.js +4 -0
- package/packages/dd-trace/src/opentracing/propagation/text_map.js +34 -1
- package/packages/dd-trace/src/proxy.js +4 -0
- package/packages/dd-trace/src/tracer.js +16 -0
- package/packages/datadog-plugin-mongodb-core/src/legacy.js +0 -59
- package/packages/datadog-plugin-mongodb-core/src/unified.js +0 -138
- package/packages/datadog-plugin-mongodb-core/src/util.js +0 -143
|
@@ -1,201 +1,79 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const Plugin = require('../../dd-trace/src/plugins/plugin')
|
|
4
|
+
const { storage } = require('../../datadog-core')
|
|
3
5
|
const analyticsSampler = require('../../dd-trace/src/analytics_sampler')
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
class RheaPlugin extends Plugin {
|
|
8
|
+
static get name () {
|
|
9
|
+
return 'rhea'
|
|
10
|
+
}
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
constructor (...args) {
|
|
13
|
+
super(...args)
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
const name = getResourceNameFromSender(this)
|
|
19
|
-
const { host, port } = getHostAndPort(this.connection)
|
|
20
|
-
return tracer.trace('amqp.send', {
|
|
15
|
+
this.addSub(`apm:rhea:send:start`, ({ targetAddress, host, port, msg }) => {
|
|
16
|
+
const store = storage.getStore()
|
|
17
|
+
const childOf = store ? store.span : store
|
|
18
|
+
const name = targetAddress || 'amq.topic'
|
|
19
|
+
const span = this.tracer.startSpan('amqp.send', {
|
|
20
|
+
childOf,
|
|
21
21
|
tags: {
|
|
22
22
|
'component': 'rhea',
|
|
23
23
|
'resource.name': name,
|
|
24
|
-
'service.name': config.service || `${tracer._service}-amqp-producer`,
|
|
24
|
+
'service.name': this.config.service || `${this.tracer._service}-amqp-producer`,
|
|
25
25
|
'span.kind': 'producer',
|
|
26
26
|
'amqp.link.target.address': name,
|
|
27
27
|
'amqp.link.role': 'sender',
|
|
28
28
|
'out.host': host,
|
|
29
29
|
'out.port': port
|
|
30
30
|
}
|
|
31
|
-
}, (span, done) => {
|
|
32
|
-
analyticsSampler.sample(span, config.measured)
|
|
33
|
-
addDeliveryAnnotations(msg, tracer, span)
|
|
34
|
-
const delivery = send.apply(this, arguments)
|
|
35
|
-
delivery[dd] = { done, span }
|
|
36
|
-
addToInFlightDeliveries(this.connection, delivery)
|
|
37
|
-
return delivery
|
|
38
31
|
})
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function createWrapConnectionDispatch (tracer, config) {
|
|
44
|
-
return function wrapDispatch (dispatch) {
|
|
45
|
-
return function dispatchWithTrace (eventName, obj) {
|
|
46
|
-
if (eventName === 'disconnected') {
|
|
47
|
-
const error = obj.error || this.saved_error
|
|
48
|
-
if (this[inFlightDeliveries]) {
|
|
49
|
-
this[inFlightDeliveries].forEach(delivery => {
|
|
50
|
-
const { span } = delivery[dd]
|
|
51
|
-
span.addTags({ error })
|
|
52
|
-
finish(delivery, null)
|
|
53
|
-
})
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return dispatch.apply(this, arguments)
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
32
|
+
analyticsSampler.sample(span, this.config.measured)
|
|
33
|
+
addDeliveryAnnotations(msg, this.tracer, span)
|
|
60
34
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return function dispatchWithTrace (eventName, msgObj) {
|
|
64
|
-
if (!canTrace(this)) {
|
|
65
|
-
// we can't handle disconnects or ending spans, so we can't safely instrument
|
|
66
|
-
return dispatch.apply(this, arguments)
|
|
67
|
-
}
|
|
68
|
-
if (eventName === 'message' && msgObj) {
|
|
69
|
-
const name = getResourceNameFromMessage(msgObj)
|
|
70
|
-
const childOf = getAnnotations(msgObj, tracer)
|
|
71
|
-
return tracer.trace('amqp.receive', {
|
|
72
|
-
type: 'worker',
|
|
73
|
-
tags: {
|
|
74
|
-
'component': 'rhea',
|
|
75
|
-
'resource.name': name,
|
|
76
|
-
'service.name': config.service || tracer._service,
|
|
77
|
-
'span.kind': 'consumer',
|
|
78
|
-
'amqp.link.source.address': name,
|
|
79
|
-
'amqp.link.role': 'receiver'
|
|
80
|
-
},
|
|
81
|
-
childOf
|
|
82
|
-
}, (span, done) => {
|
|
83
|
-
analyticsSampler.sample(span, config.measured, true)
|
|
84
|
-
if (msgObj.delivery) {
|
|
85
|
-
msgObj.delivery[dd] = { done, span }
|
|
86
|
-
msgObj.delivery.update = wrapDeliveryUpdate(msgObj.delivery.update)
|
|
87
|
-
addToInFlightDeliveries(this.connection, msgObj.delivery)
|
|
88
|
-
}
|
|
89
|
-
return dispatch.apply(this, arguments)
|
|
90
|
-
})
|
|
91
|
-
}
|
|
35
|
+
this.enter(span, store)
|
|
36
|
+
})
|
|
92
37
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
}
|
|
38
|
+
this.addSub(`apm:rhea:receive:start`, ({ msgObj, connection }) => {
|
|
39
|
+
const name = getResourceNameFromMessage(msgObj)
|
|
97
40
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
return popIf.apply(this, arguments)
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function wrapDeliveryUpdate (update) {
|
|
117
|
-
return function wrappedUpdate (settled, stateData) {
|
|
118
|
-
if (this[dd]) {
|
|
119
|
-
const state = getStateFromData(stateData)
|
|
120
|
-
this[dd].span.setTag('amqp.delivery.state', state)
|
|
121
|
-
}
|
|
122
|
-
return update.apply(this, arguments)
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function patchCircularBuffer (proto, instrumenter) {
|
|
127
|
-
Object.defineProperty(proto, 'outgoing', {
|
|
128
|
-
configurable: true,
|
|
129
|
-
get () {},
|
|
130
|
-
set (outgoing) {
|
|
131
|
-
delete proto.outgoing // removes the setter on the prototype
|
|
132
|
-
this.outgoing = outgoing // assigns on the instance, like normal
|
|
133
|
-
if (outgoing) {
|
|
134
|
-
let CircularBuffer
|
|
135
|
-
if (outgoing.deliveries) {
|
|
136
|
-
CircularBuffer = outgoing.deliveries.constructor
|
|
137
|
-
}
|
|
138
|
-
if (CircularBuffer && !patched.has(CircularBuffer.prototype)) {
|
|
139
|
-
instrumenter.wrap(CircularBuffer.prototype, 'pop_if', createWrapCircularBufferPopIf())
|
|
140
|
-
patched.add(CircularBuffer.prototype)
|
|
141
|
-
const Session = proto.constructor
|
|
142
|
-
if (Session) {
|
|
143
|
-
Session[circularBufferConstructor] = CircularBuffer
|
|
144
|
-
}
|
|
41
|
+
const store = storage.getStore()
|
|
42
|
+
const childOf = extractTextMap(msgObj, this.tracer)
|
|
43
|
+
const span = this.tracer.startSpan('amqp.receive', {
|
|
44
|
+
childOf,
|
|
45
|
+
tags: {
|
|
46
|
+
'span.type': 'worker',
|
|
47
|
+
'component': 'rhea',
|
|
48
|
+
'resource.name': name,
|
|
49
|
+
'service.name': this.config.service || this.tracer._service,
|
|
50
|
+
'span.kind': 'consumer',
|
|
51
|
+
'amqp.link.source.address': name,
|
|
52
|
+
'amqp.link.role': 'receiver'
|
|
145
53
|
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
})
|
|
149
|
-
}
|
|
54
|
+
})
|
|
55
|
+
analyticsSampler.sample(span, this.config.measured, true)
|
|
150
56
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (!deliveries) {
|
|
154
|
-
deliveries = new Set()
|
|
155
|
-
connection[inFlightDeliveries] = deliveries
|
|
156
|
-
}
|
|
157
|
-
deliveries.add(delivery)
|
|
158
|
-
delivery[dd].connection = connection
|
|
159
|
-
}
|
|
57
|
+
this.enter(span, store)
|
|
58
|
+
})
|
|
160
59
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (connection && connection.options) {
|
|
165
|
-
host = connection.options.host
|
|
166
|
-
port = connection.options.port
|
|
167
|
-
}
|
|
168
|
-
return { host, port }
|
|
169
|
-
}
|
|
60
|
+
this.addSub(`apm:rhea:error`, error => {
|
|
61
|
+
storage.getStore().span.setTag('error', error)
|
|
62
|
+
})
|
|
170
63
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
}
|
|
64
|
+
this.addSub(`apm:rhea:async-end`, () => {
|
|
65
|
+
const span = storage.getStore().span
|
|
66
|
+
span.finish()
|
|
67
|
+
})
|
|
177
68
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
case 0x24: return 'accepted'
|
|
182
|
-
case 0x25: return 'rejected'
|
|
183
|
-
case 0x26: return 'released'
|
|
184
|
-
case 0x27: return 'modified'
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
69
|
+
this.addSub(`apm:rhea:end`, () => {
|
|
70
|
+
this.exit()
|
|
71
|
+
})
|
|
188
72
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
delivery[dd].done()
|
|
195
|
-
if (delivery[dd].connection && delivery[dd].connection[inFlightDeliveries]) {
|
|
196
|
-
delivery[dd].connection[inFlightDeliveries].delete(delivery)
|
|
197
|
-
}
|
|
198
|
-
delete delivery[dd]
|
|
73
|
+
this.addSub(`apm:rhea:dispatch`, ({ state }) => {
|
|
74
|
+
const span = storage.getStore().span
|
|
75
|
+
span.setTag('amqp.delivery.state', state)
|
|
76
|
+
})
|
|
199
77
|
}
|
|
200
78
|
}
|
|
201
79
|
|
|
@@ -211,62 +89,18 @@ function getResourceNameFromMessage (msgObj) {
|
|
|
211
89
|
return resourceName
|
|
212
90
|
}
|
|
213
91
|
|
|
214
|
-
function
|
|
215
|
-
let resourceName = 'amq.topic'
|
|
216
|
-
if (sender.options && sender.options.target && sender.options.target.address) {
|
|
217
|
-
resourceName = sender.options.target.address
|
|
218
|
-
}
|
|
219
|
-
return resourceName
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function getAnnotations (msgObj, tracer) {
|
|
92
|
+
function extractTextMap (msgObj, tracer) {
|
|
223
93
|
if (msgObj.message) {
|
|
224
94
|
return tracer.extract('text_map', msgObj.message.delivery_annotations)
|
|
225
95
|
}
|
|
226
96
|
}
|
|
227
97
|
|
|
228
|
-
function
|
|
229
|
-
|
|
230
|
-
}
|
|
98
|
+
function addDeliveryAnnotations (msg, tracer, span) {
|
|
99
|
+
if (msg) {
|
|
100
|
+
msg.delivery_annotations = msg.delivery_annotations || {}
|
|
231
101
|
|
|
232
|
-
|
|
233
|
-
{
|
|
234
|
-
name: 'rhea',
|
|
235
|
-
versions: ['>=1'],
|
|
236
|
-
file: 'lib/link.js',
|
|
237
|
-
patch ({ Sender, Receiver }, tracer, config) {
|
|
238
|
-
this.wrap(Sender.prototype, 'send', createWrapSend(tracer, config, this))
|
|
239
|
-
this.wrap(Receiver.prototype, 'dispatch', createWrapReceiverDispatch(tracer, config, this))
|
|
240
|
-
},
|
|
241
|
-
unpatch ({ Sender, Receiver }, tracer) {
|
|
242
|
-
this.unwrap(Sender.prototype, 'send')
|
|
243
|
-
this.unwrap(Receiver.prototype, 'dispatch')
|
|
244
|
-
}
|
|
245
|
-
},
|
|
246
|
-
{
|
|
247
|
-
name: 'rhea',
|
|
248
|
-
versions: ['>=1'],
|
|
249
|
-
file: 'lib/connection.js',
|
|
250
|
-
patch (Connection, tracer, config) {
|
|
251
|
-
this.wrap(Connection.prototype, 'dispatch', createWrapConnectionDispatch(tracer, config))
|
|
252
|
-
},
|
|
253
|
-
unpatch (Connection, tracer) {
|
|
254
|
-
this.unwrap(Connection.prototype, 'dispatch')
|
|
255
|
-
}
|
|
256
|
-
},
|
|
257
|
-
{
|
|
258
|
-
name: 'rhea',
|
|
259
|
-
versions: ['>=1'],
|
|
260
|
-
file: 'lib/session.js',
|
|
261
|
-
patch (Session, tracer, config) {
|
|
262
|
-
patchCircularBuffer(Session.prototype, this)
|
|
263
|
-
},
|
|
264
|
-
unpatch (Session, tracer) {
|
|
265
|
-
const CircularBuffer = Session[circularBufferConstructor]
|
|
266
|
-
if (CircularBuffer) {
|
|
267
|
-
patched.delete(CircularBuffer.prototype)
|
|
268
|
-
this.unwrap(CircularBuffer.prototype, 'pop_if')
|
|
269
|
-
}
|
|
270
|
-
}
|
|
102
|
+
tracer.inject(span, 'text_map', msg.delivery_annotations)
|
|
271
103
|
}
|
|
272
|
-
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = RheaPlugin
|
|
@@ -1,102 +1,54 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
const
|
|
3
|
+
const Plugin = require('../../dd-trace/src/plugins/plugin')
|
|
4
|
+
const { storage } = require('../../datadog-core')
|
|
5
5
|
const analyticsSampler = require('../../dd-trace/src/analytics_sampler')
|
|
6
|
-
const tx = require('../../dd-trace/src/plugins/util/tx')
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return
|
|
11
|
-
|
|
12
|
-
const scope = tracer.scope()
|
|
13
|
-
const childOf = scope.active()
|
|
14
|
-
const queryOrProcedure = getQueryOrProcedure(request)
|
|
7
|
+
class TediousPlugin extends Plugin {
|
|
8
|
+
static get name () {
|
|
9
|
+
return 'tedious'
|
|
10
|
+
}
|
|
15
11
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
12
|
+
constructor (...args) {
|
|
13
|
+
super(...args)
|
|
19
14
|
|
|
20
|
-
|
|
15
|
+
this.addSub(`apm:tedious:request:start`, ({ queryOrProcedure, connectionConfig }) => {
|
|
16
|
+
const store = storage.getStore()
|
|
17
|
+
const childOf = store ? store.span : store
|
|
18
|
+
const span = this.tracer.startSpan('tedious.request', {
|
|
21
19
|
childOf,
|
|
22
20
|
tags: {
|
|
23
|
-
|
|
21
|
+
'span.kind': 'client',
|
|
24
22
|
'db.type': 'mssql',
|
|
25
23
|
'span.type': 'sql',
|
|
26
24
|
'component': 'tedious',
|
|
27
|
-
'service.name': config.service || `${tracer._service}-mssql`,
|
|
28
|
-
'resource.name': queryOrProcedure
|
|
25
|
+
'service.name': this.config.service || `${this.tracer._service}-mssql`,
|
|
26
|
+
'resource.name': queryOrProcedure,
|
|
27
|
+
'out.host': connectionConfig.server,
|
|
28
|
+
'out.port': connectionConfig.options.port,
|
|
29
|
+
'db.user': connectionConfig.userName || connectionConfig.authentication.options.userName,
|
|
30
|
+
'db.name': connectionConfig.options.database,
|
|
31
|
+
'db.instance': connectionConfig.options.instanceName
|
|
29
32
|
}
|
|
30
33
|
})
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const rowToPacketTransform = getRowStream.apply(this, arguments)
|
|
49
|
-
return scope.bind(rowToPacketTransform)
|
|
50
|
-
}
|
|
34
|
+
analyticsSampler.sample(span, this.config.measured)
|
|
35
|
+
this.enter(span, store)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
this.addSub(`apm:tedious:request:end`, () => {
|
|
39
|
+
this.exit()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
this.addSub(`apm:tedious:request:error`, err => {
|
|
43
|
+
const span = storage.getStore().span
|
|
44
|
+
span.setTag('error', err)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
this.addSub(`apm:tedious:request:async-end`, () => {
|
|
48
|
+
const span = storage.getStore().span
|
|
49
|
+
span.finish()
|
|
50
|
+
})
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
if (!request.parameters) return
|
|
56
|
-
|
|
57
|
-
const statement = request.parametersByName.statement || request.parametersByName.stmt
|
|
58
|
-
|
|
59
|
-
if (!statement) {
|
|
60
|
-
return request.sqlTextOrProcedure
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return statement.value
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function addConnectionTags (span, connectionConfig) {
|
|
67
|
-
span.setTag('out.host', connectionConfig.server)
|
|
68
|
-
span.setTag('out.port', connectionConfig.options.port)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function addDatabaseTags (span, connectionConfig) {
|
|
72
|
-
span.setTag('db.user', connectionConfig.userName || connectionConfig.authentication.options.userName)
|
|
73
|
-
span.setTag('db.name', connectionConfig.options.database)
|
|
74
|
-
span.setTag('db.instance', connectionConfig.options.instanceName)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
module.exports = [
|
|
78
|
-
{
|
|
79
|
-
name: 'tedious',
|
|
80
|
-
versions: [ '>=1.0.0' ],
|
|
81
|
-
patch (tedious, tracer, config) {
|
|
82
|
-
this.wrap(tedious.Connection.prototype, 'makeRequest', createWrapMakeRequest(tracer, config))
|
|
83
|
-
|
|
84
|
-
if (tedious.BulkLoad && tedious.BulkLoad.prototype.getRowStream) {
|
|
85
|
-
this.wrap(tedious.BulkLoad.prototype, 'getRowStream', createWrapGetRowStream(tracer))
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
tracer.scope().bind(tedious.Request.prototype)
|
|
89
|
-
tracer.scope().bind(tedious.Connection.prototype)
|
|
90
|
-
},
|
|
91
|
-
unpatch (tedious, tracer) {
|
|
92
|
-
this.unwrap(tedious.Connection.prototype, 'makeRequest')
|
|
93
|
-
|
|
94
|
-
if (tedious.BulkLoad) {
|
|
95
|
-
this.unwrap(tedious.BulkLoad.prototype, 'getRowStream')
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
tracer.scope().unbind(tedious.Request.prototype)
|
|
99
|
-
tracer.scope().unbind(tedious.Connection.prototype)
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
]
|
|
54
|
+
module.exports = TediousPlugin
|
|
@@ -1 +1 @@
|
|
|
1
|
-
module.exports = '2.
|
|
1
|
+
module.exports = '2.4.0'
|