dd-trace 6.20.0 → 6.21.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 +91 -3
- package/package.json +6 -7
- package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js +57 -4
- package/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +1 -0
- package/packages/datadog-instrumentations/src/jest.js +119 -1
- package/packages/datadog-instrumentations/src/mocha/utils.js +124 -36
- package/packages/datadog-instrumentations/src/mocha/webdriverio-protocol.js +61 -34
- package/packages/datadog-instrumentations/src/mocha/worker.js +6 -3
- package/packages/datadog-instrumentations/src/playwright.js +3 -0
- package/packages/datadog-instrumentations/src/vitest-main.js +0 -2
- package/packages/datadog-instrumentations/src/webdriverio.js +115 -15
- package/packages/datadog-plugin-cypress/src/cypress-plugin.js +17 -0
- package/packages/datadog-plugin-mocha/src/index.js +216 -29
- package/packages/datadog-plugin-mocha/src/webdriverio-video.js +449 -0
- package/packages/dd-trace/src/config/supported-configurations.json +2 -2
- package/packages/dd-trace/src/debugger/constants.js +1 -0
- package/packages/dd-trace/src/debugger/devtools_client/index.js +5 -3
- package/packages/dd-trace/src/debugger/devtools_client/remote_config.js +17 -12
- package/packages/dd-trace/src/debugger/devtools_client/send.js +6 -2
- package/packages/dd-trace/src/debugger/devtools_client/worker-error.js +21 -0
- package/packages/dd-trace/src/debugger/index.js +24 -2
- package/packages/dd-trace/src/debugger/probe_sampler.js +108 -11
- package/packages/dd-trace/src/exporters/common/request.js +1 -0
- package/packages/dd-trace/src/exporters/common/url.js +1 -1
- package/packages/dd-trace/src/id.js +1 -4
- package/packages/dd-trace/src/llmobs/agent-manifest.js +296 -0
- package/packages/dd-trace/src/llmobs/constants/tags.js +1 -0
- package/packages/dd-trace/src/llmobs/sdk.js +23 -6
- package/packages/dd-trace/src/llmobs/span_processor.js +17 -6
- package/packages/dd-trace/src/llmobs/tagger.js +54 -1
- package/packages/dd-trace/src/llmobs/writers/base.js +3 -1
- package/packages/dd-trace/src/llmobs/writers/util.js +3 -1
- package/packages/dd-trace/src/openfeature/agentless_configuration_source.js +30 -1
- package/packages/dd-trace/src/openfeature/configuration_source.js +17 -2
- package/packages/dd-trace/src/openfeature/debug-logging-hook.js +25 -0
- package/packages/dd-trace/src/openfeature/flagging_provider.js +29 -0
- package/packages/dd-trace/src/openfeature/remote_config.js +9 -0
- package/packages/dd-trace/src/openfeature/writers/util.js +43 -4
- package/packages/dd-trace/src/opentelemetry/resource-attributes.js +1 -1
- package/packages/dd-trace/src/otel-thread-ctx.js +2 -2
- package/packages/dd-trace/src/plugins/util/url.js +6 -1
- package/packages/dd-trace/src/profiling/profilers/wall.js +4 -10
- package/packages/dd-trace/src/proxy.js +2 -0
- package/packages/dd-trace/src/serverless.js +5 -0
- package/packages/dd-trace/src/storage-channels.js +112 -46
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { spawn, spawnSync } = require('node:child_process')
|
|
4
|
+
const { mkdtempSync, rmSync, writeFileSync } = require('node:fs')
|
|
5
|
+
const { tmpdir } = require('node:os')
|
|
6
|
+
const { join } = require('node:path')
|
|
7
|
+
const { performance } = require('node:perf_hooks')
|
|
8
|
+
const { clearInterval, clearTimeout, setInterval, setTimeout } = require('node:timers')
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
VIDEO_UPLOAD_RESULT_ERROR,
|
|
12
|
+
VIDEO_UPLOAD_RESULT_UPLOADED,
|
|
13
|
+
} = require('../../dd-trace/src/ci-visibility/test-video')
|
|
14
|
+
const log = require('../../dd-trace/src/log')
|
|
15
|
+
|
|
16
|
+
const CAPTURE_INTERVAL_MS = 500
|
|
17
|
+
const CAPTURE_TIMEOUT_MS = 5000
|
|
18
|
+
const ENCODING_TIMEOUT_MS = 30_000
|
|
19
|
+
const MAX_PENDING_ENCODINGS = 32
|
|
20
|
+
const MAX_PIXELS = 16 * 1024 * 1024
|
|
21
|
+
const MAX_FRAME_BYTES = 200 * 1024 * 1024
|
|
22
|
+
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
|
|
23
|
+
const { queueMicrotask } = globalThis
|
|
24
|
+
const captureOwners = new WeakMap()
|
|
25
|
+
// A local timeout does not cancel the WebDriver request; keep its slot until it actually settles.
|
|
26
|
+
const pendingCaptures = new WeakSet()
|
|
27
|
+
const unsafePromptBrowsers = new WeakSet()
|
|
28
|
+
|
|
29
|
+
let ffmpegAvailable
|
|
30
|
+
// PNGs remain charged through encoding and upload, until their temporary files are removed.
|
|
31
|
+
let frameBytes = 0
|
|
32
|
+
|
|
33
|
+
/** @type {Array<() => void>} */
|
|
34
|
+
const encodingQueue = []
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Runs one encoder at a time across all attempts in this WDIO process. Uploads do not hold the slot.
|
|
38
|
+
*
|
|
39
|
+
* @param {{ directory: string, index: number, frames: number[], filePath: string, size: number[] }} recording
|
|
40
|
+
* @param {(error?: Error) => void} onDone
|
|
41
|
+
*/
|
|
42
|
+
function encodeVideo (recording, onDone) {
|
|
43
|
+
// Include the active encoder in the queue limit.
|
|
44
|
+
if (encodingQueue.length > MAX_PENDING_ENCODINGS) {
|
|
45
|
+
queueMicrotask(() => onDone(new Error('WebdriverIO video encoding queue is full')))
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
let encoder
|
|
49
|
+
let timeout
|
|
50
|
+
const complete = error => {
|
|
51
|
+
const index = encodingQueue.indexOf(start)
|
|
52
|
+
if (index === -1) return
|
|
53
|
+
encodingQueue.splice(index, 1)
|
|
54
|
+
// Defer the next start so repeated process startup failures cannot recurse through the queue.
|
|
55
|
+
if (encodingQueue.length) queueMicrotask(encodingQueue[0])
|
|
56
|
+
onDone(error)
|
|
57
|
+
}
|
|
58
|
+
const start = () => {
|
|
59
|
+
// A queued start may have been cancelled before its microtask runs.
|
|
60
|
+
if (encoder || !encodingQueue.includes(start)) return
|
|
61
|
+
try {
|
|
62
|
+
const { directory, index, frames, filePath, size: [width, height] } = recording
|
|
63
|
+
const manifestPath = join(directory, `${index}.ffconcat`)
|
|
64
|
+
let manifest = 'ffconcat version 1.0\n'
|
|
65
|
+
for (let frame = 0; frame < frames.length; frame++) {
|
|
66
|
+
// Command captures are irregular: fixed 2 fps stretches assertion polling into long videos.
|
|
67
|
+
const duration = frame + 1 < frames.length ? (frames[frame + 1] - frames[frame]) / 1000 : 0.04
|
|
68
|
+
manifest += `file ${index}-${frame}.png\nduration ${duration.toFixed(6)}\n`
|
|
69
|
+
}
|
|
70
|
+
// The PNG demuxer uses 25 Hz timestamps. Repeat the final image one tick later so it survives
|
|
71
|
+
// rounding when the last two captures are less than 40 ms apart. WebM preserves variable timing.
|
|
72
|
+
manifest += `file ${index}-${frames.length - 1}.png\n`
|
|
73
|
+
writeFileSync(manifestPath, manifest)
|
|
74
|
+
encoder = spawn('ffmpeg', [
|
|
75
|
+
'-hide_banner', '-loglevel', 'error', '-nostdin', '-y',
|
|
76
|
+
'-threads', '1', '-f', 'concat', '-i', manifestPath,
|
|
77
|
+
'-frames:v', String(frames.length + 1), '-an', '-c:v', 'libvpx', '-deadline', 'realtime',
|
|
78
|
+
'-threads', '1', '-filter_threads', '1', '-pix_fmt', 'yuv420p',
|
|
79
|
+
'-vf', `scale=${width}:${height},setsar=1`, filePath,
|
|
80
|
+
], { windowsHide: true, stdio: 'ignore' })
|
|
81
|
+
let encodingError
|
|
82
|
+
timeout = setTimeout(() => {
|
|
83
|
+
encodingError = new Error('WebdriverIO video encoding timed out')
|
|
84
|
+
// A kill request does not release the slot until the process actually closes.
|
|
85
|
+
encoder.kill('SIGKILL')
|
|
86
|
+
}, ENCODING_TIMEOUT_MS)
|
|
87
|
+
encoder.once('error', error => { encodingError = error })
|
|
88
|
+
encoder.once('close', code => {
|
|
89
|
+
clearTimeout(timeout)
|
|
90
|
+
if (code !== 0) encodingError ||= new Error(`WebdriverIO video encoder exited with code ${code}`)
|
|
91
|
+
complete(encodingError)
|
|
92
|
+
})
|
|
93
|
+
} catch (error) {
|
|
94
|
+
// Let the caller retain its cancellation callback even when process startup throws.
|
|
95
|
+
queueMicrotask(() => complete(error))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
encodingQueue.push(start)
|
|
99
|
+
if (encodingQueue.length === 1) start()
|
|
100
|
+
return () => {
|
|
101
|
+
const index = encodingQueue.indexOf(start)
|
|
102
|
+
if (index === -1) return
|
|
103
|
+
if (encoder) {
|
|
104
|
+
clearTimeout(timeout)
|
|
105
|
+
// Shutdown must not wait for a process that cannot terminate. Keep its slot until close.
|
|
106
|
+
encoder.unref?.()
|
|
107
|
+
if (!encoder.kill('SIGKILL')) log.error('Error stopping WebdriverIO video encoder')
|
|
108
|
+
} else {
|
|
109
|
+
encodingQueue.splice(index, 1)
|
|
110
|
+
if (index === 0 && encodingQueue.length) queueMicrotask(encodingQueue[0])
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Like wdio-video-reporter, collect WebDriver screenshots and encode only failed attempts.
|
|
116
|
+
// Encoding uses a customer-installed FFmpeg executable, isolated from the WDIO test process.
|
|
117
|
+
class WebdriverioVideo {
|
|
118
|
+
#browser
|
|
119
|
+
#directory
|
|
120
|
+
#frames = []
|
|
121
|
+
#sizes = []
|
|
122
|
+
#bytes = 0
|
|
123
|
+
#capturing = false
|
|
124
|
+
#finalCapture = false
|
|
125
|
+
#completeCapture
|
|
126
|
+
#disabled = false
|
|
127
|
+
#stopped = false
|
|
128
|
+
#captureTimeout
|
|
129
|
+
#interval
|
|
130
|
+
#onCapture
|
|
131
|
+
#error
|
|
132
|
+
#onResult
|
|
133
|
+
#cancelEncoding
|
|
134
|
+
#finished = false
|
|
135
|
+
#onDone
|
|
136
|
+
|
|
137
|
+
/** @param {object} browser - WebdriverIO browser or multiremote browser */
|
|
138
|
+
constructor (browser) {
|
|
139
|
+
this.#browser = browser
|
|
140
|
+
this.#directory = mkdtempSync(join(tmpdir(), 'dd-trace-wdio-video-'))
|
|
141
|
+
captureOwners.set(browser, this)
|
|
142
|
+
this.#onResult = /** @param {{command?: string, endpoint?: string}} event */ (event) => {
|
|
143
|
+
const { command, endpoint } = event
|
|
144
|
+
if (command === 'takeScreenshot' || endpoint?.endsWith('/screenshot')) return
|
|
145
|
+
// WDIO looks up the context before BiDi navigation. Capturing in that gap can block navigation.
|
|
146
|
+
if (command === 'getWindowHandle' || command === 'getWindowHandles') return
|
|
147
|
+
this.capture()
|
|
148
|
+
}
|
|
149
|
+
browser.on?.('result', this.#onResult)
|
|
150
|
+
this.#interval = setInterval(() => this.capture(), CAPTURE_INTERVAL_MS)
|
|
151
|
+
this.#interval.unref?.()
|
|
152
|
+
this.capture()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Captures at most one in-flight screenshot, including all multiremote sessions. */
|
|
156
|
+
capture () {
|
|
157
|
+
if ((this.#stopped && !this.#finalCapture) || this.#capturing || this.#error || this.#disabled ||
|
|
158
|
+
pendingCaptures.has(this.#browser) || captureOwners.get(this.#browser) !== this) return
|
|
159
|
+
this.#finalCapture = false
|
|
160
|
+
this.#capturing = true
|
|
161
|
+
let completed = false
|
|
162
|
+
const complete = (error, screenshots, capturedAt) => {
|
|
163
|
+
if (completed || this.#finished) return
|
|
164
|
+
completed = true
|
|
165
|
+
this.#capturing = false
|
|
166
|
+
if (error) {
|
|
167
|
+
log.error('Error capturing WebdriverIO video frame: %s', error.message)
|
|
168
|
+
} else if (!this.#disabled && captureOwners.get(this.#browser) === this) {
|
|
169
|
+
try {
|
|
170
|
+
this.#writeFrames(screenshots, capturedAt)
|
|
171
|
+
} catch (error) {
|
|
172
|
+
this.#error = error
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (this.#finalCapture) {
|
|
176
|
+
this.capture()
|
|
177
|
+
if (this.#capturing) return
|
|
178
|
+
}
|
|
179
|
+
this.#finalCapture = false
|
|
180
|
+
clearTimeout(this.#captureTimeout)
|
|
181
|
+
this.#captureTimeout = undefined
|
|
182
|
+
this.#completeCapture = undefined
|
|
183
|
+
const onCapture = this.#onCapture
|
|
184
|
+
this.#onCapture = undefined
|
|
185
|
+
onCapture?.()
|
|
186
|
+
}
|
|
187
|
+
this.#completeCapture = complete
|
|
188
|
+
// An overlapping capture and its fresh final frame share the existing five-second deadline.
|
|
189
|
+
this.#captureTimeout ??= setTimeout(() => {
|
|
190
|
+
this.#completeCapture?.(new Error('WebdriverIO video screenshot capture timed out'))
|
|
191
|
+
}, CAPTURE_TIMEOUT_MS)
|
|
192
|
+
this.#captureTimeout.unref?.()
|
|
193
|
+
try {
|
|
194
|
+
if (!hasSafePromptPolicy(this.#browser)) {
|
|
195
|
+
this.#disabled = true
|
|
196
|
+
clearInterval(this.#interval)
|
|
197
|
+
this.#browser.removeListener?.('result', this.#onResult)
|
|
198
|
+
return complete()
|
|
199
|
+
}
|
|
200
|
+
if (this.#browser.isMultiremote || this.#browser.isMultiRemote) {
|
|
201
|
+
const names = this.#browser.instances
|
|
202
|
+
if (!names.length) return complete(new Error('WebdriverIO returned no browser sessions'))
|
|
203
|
+
const browsers = names.map(name => this.#browser.getInstance(name))
|
|
204
|
+
const frames = new Array(names.length)
|
|
205
|
+
const captureTimes = new Array(names.length)
|
|
206
|
+
let pending = names.length
|
|
207
|
+
let captureError
|
|
208
|
+
pendingCaptures.add(this.#browser)
|
|
209
|
+
for (let index = 0; index < names.length; index++) {
|
|
210
|
+
this.#captureBrowser(browsers[index], (error, frame, capturedAt) => {
|
|
211
|
+
captureError ||= error
|
|
212
|
+
frames[index] = frame
|
|
213
|
+
captureTimes[index] = capturedAt
|
|
214
|
+
if (--pending === 0) {
|
|
215
|
+
pendingCaptures.delete(this.#browser)
|
|
216
|
+
complete(captureError, frames, captureTimes)
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
pendingCaptures.add(this.#browser)
|
|
222
|
+
this.#captureBrowser(this.#browser, (error, frame, capturedAt) => {
|
|
223
|
+
pendingCaptures.delete(this.#browser)
|
|
224
|
+
complete(error, frame, capturedAt)
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
} catch (error) {
|
|
228
|
+
complete(error)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @param {object} browser - A single WebDriver session, including sessions with BiDi enabled
|
|
234
|
+
* @param {(error?: Error, frame?: string, capturedAt?: number) => void} onDone
|
|
235
|
+
*/
|
|
236
|
+
#captureBrowser (browser, onDone) {
|
|
237
|
+
try {
|
|
238
|
+
// Use the session's WebDriver screenshot command. Direct BiDi captures can lose their
|
|
239
|
+
// browsing context during concurrent navigation and never return a frame.
|
|
240
|
+
// Timestamp each session before waiting for the rest of a multiremote batch.
|
|
241
|
+
browser.takeScreenshot().then(result => onDone(undefined, result, performance.now()), onDone)
|
|
242
|
+
} catch (error) {
|
|
243
|
+
onDone(error)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* @param {string|string[]} screenshots - Base64 PNG screenshots, in session order
|
|
249
|
+
* @param {number|number[]} capturedAt - Completion timestamps in the same session order
|
|
250
|
+
*/
|
|
251
|
+
#writeFrames (screenshots, capturedAt) {
|
|
252
|
+
const frames = Array.isArray(screenshots) ? screenshots : [screenshots]
|
|
253
|
+
if (!frames.length) throw new Error('WebdriverIO returned no video frames')
|
|
254
|
+
for (let index = 0; index < frames.length; index++) {
|
|
255
|
+
if (typeof frames[index] !== 'string') throw new Error('WebdriverIO returned an invalid video frame')
|
|
256
|
+
if (Buffer.byteLength(frames[index], 'base64') > MAX_FRAME_BYTES - frameBytes) {
|
|
257
|
+
throw new Error('WebdriverIO video frames exceeded the 200 MiB worker limit')
|
|
258
|
+
}
|
|
259
|
+
const frame = Buffer.from(frames[index], 'base64')
|
|
260
|
+
if (frame.length < 24 || !frame.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
|
|
261
|
+
throw new Error('WebdriverIO returned an invalid PNG video frame')
|
|
262
|
+
}
|
|
263
|
+
const width = frame.readUInt32BE(16)
|
|
264
|
+
const height = frame.readUInt32BE(20)
|
|
265
|
+
if (!width || !height || width * height > MAX_PIXELS) {
|
|
266
|
+
throw new Error('WebdriverIO video frame exceeds the 16 megapixel limit or has invalid dimensions')
|
|
267
|
+
}
|
|
268
|
+
if (!this.#sizes[index]) {
|
|
269
|
+
// Keep a fixed, even output size across viewport changes.
|
|
270
|
+
const scale = Math.min(1, 1280 / width, 720 / height)
|
|
271
|
+
this.#sizes[index] = [
|
|
272
|
+
Math.max(2, Math.floor(width * scale / 2) * 2),
|
|
273
|
+
Math.max(2, Math.floor(height * scale / 2) * 2),
|
|
274
|
+
]
|
|
275
|
+
}
|
|
276
|
+
const timestamps = this.#frames[index] ||= []
|
|
277
|
+
const frameNumber = timestamps.length
|
|
278
|
+
// Reserve before writing so a partial write stays accounted for until cleanup.
|
|
279
|
+
frameBytes += frame.length
|
|
280
|
+
this.#bytes += frame.length
|
|
281
|
+
writeFileSync(join(this.#directory, `${index}-${frameNumber}.png`), frame)
|
|
282
|
+
timestamps.push(Array.isArray(capturedAt) ? capturedAt[index] : capturedAt)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Stops capture, uploads failed attempts, and removes all owned files after upload completion.
|
|
288
|
+
*
|
|
289
|
+
* @param {boolean} failed - Whether this attempt failed
|
|
290
|
+
* @param {(filePath: string, index: number, onDone: (error?: Error) => void) => void} upload
|
|
291
|
+
* @param {(result?: string) => void} onDone
|
|
292
|
+
*/
|
|
293
|
+
finish (failed, upload, onDone) {
|
|
294
|
+
if (this.#stopped) return
|
|
295
|
+
this.#onDone = onDone
|
|
296
|
+
if (failed) {
|
|
297
|
+
if (this.#capturing) this.#finalCapture = true
|
|
298
|
+
else this.capture()
|
|
299
|
+
}
|
|
300
|
+
this.#stopped = true
|
|
301
|
+
clearInterval(this.#interval)
|
|
302
|
+
this.#browser.removeListener?.('result', this.#onResult)
|
|
303
|
+
const finish = () => {
|
|
304
|
+
if (this.#finished) return
|
|
305
|
+
if (!failed || this.#disabled) return this.#cleanup(undefined, onDone)
|
|
306
|
+
if (this.#error || !this.#frames.length) {
|
|
307
|
+
log.error('Error recording WebdriverIO failure video: %s', this.#error?.message || 'No frames captured')
|
|
308
|
+
return this.#cleanup(VIDEO_UPLOAD_RESULT_ERROR, onDone)
|
|
309
|
+
}
|
|
310
|
+
this.#encode(0, upload, false, onDone)
|
|
311
|
+
}
|
|
312
|
+
if (this.#capturing) this.#onCapture = finish
|
|
313
|
+
else finish()
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** @param {() => void} onDone - Releases a retry once this attempt's final capture settles. */
|
|
317
|
+
waitForCapture (onDone) {
|
|
318
|
+
if (!this.#capturing || this.#finished) return onDone()
|
|
319
|
+
const previous = this.#onCapture
|
|
320
|
+
this.#onCapture = () => {
|
|
321
|
+
previous?.()
|
|
322
|
+
onDone()
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Cancels a finishing attempt at the worker's final-flush deadline. */
|
|
327
|
+
cancel () {
|
|
328
|
+
if (this.#finished) return
|
|
329
|
+
this.#cancelEncoding?.()
|
|
330
|
+
this.#cleanup(VIDEO_UPLOAD_RESULT_ERROR, this.#onDone)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Encodes multiremote sessions sequentially to bound encoder resource use.
|
|
335
|
+
*
|
|
336
|
+
* @param {number} index
|
|
337
|
+
* @param {(filePath: string, index: number, onDone: (error?: Error) => void) => void} upload
|
|
338
|
+
* @param {boolean} hasError
|
|
339
|
+
* @param {(result?: string) => void} onDone
|
|
340
|
+
*/
|
|
341
|
+
#encode (index, upload, hasError, onDone) {
|
|
342
|
+
if (index === this.#frames.length) {
|
|
343
|
+
return this.#cleanup(hasError ? VIDEO_UPLOAD_RESULT_ERROR : VIDEO_UPLOAD_RESULT_UPLOADED, onDone)
|
|
344
|
+
}
|
|
345
|
+
const filePath = join(this.#directory, `${index}.webm`)
|
|
346
|
+
let completed = false
|
|
347
|
+
const next = error => {
|
|
348
|
+
if (completed) return
|
|
349
|
+
completed = true
|
|
350
|
+
// Cancellation may try cleanup while encoding or upload still holds files open.
|
|
351
|
+
if (this.#finished) return this.#cleanup(undefined, () => {})
|
|
352
|
+
if (error) log.error('Error uploading WebdriverIO failure video: %s', error.message)
|
|
353
|
+
this.#encode(index + 1, upload, hasError || Boolean(error), onDone)
|
|
354
|
+
}
|
|
355
|
+
const recording = {
|
|
356
|
+
directory: this.#directory,
|
|
357
|
+
index,
|
|
358
|
+
frames: this.#frames[index],
|
|
359
|
+
filePath,
|
|
360
|
+
size: this.#sizes[index],
|
|
361
|
+
}
|
|
362
|
+
this.#cancelEncoding = encodeVideo(recording, error => {
|
|
363
|
+
if (this.#finished || error) return next(error)
|
|
364
|
+
try {
|
|
365
|
+
upload(filePath, index, next)
|
|
366
|
+
} catch (error) {
|
|
367
|
+
next(error)
|
|
368
|
+
}
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* @param {string|undefined} result
|
|
374
|
+
* @param {(result?: string) => void} onDone
|
|
375
|
+
*/
|
|
376
|
+
#cleanup (result, onDone) {
|
|
377
|
+
this.#finished = true
|
|
378
|
+
if (captureOwners.get(this.#browser) === this) captureOwners.delete(this.#browser)
|
|
379
|
+
clearTimeout(this.#captureTimeout)
|
|
380
|
+
this.#completeCapture = undefined
|
|
381
|
+
const onCapture = this.#onCapture
|
|
382
|
+
this.#onCapture = undefined
|
|
383
|
+
onCapture?.()
|
|
384
|
+
try {
|
|
385
|
+
rmSync(this.#directory, { recursive: true, force: true })
|
|
386
|
+
frameBytes -= this.#bytes
|
|
387
|
+
this.#bytes = 0
|
|
388
|
+
} catch (error) {
|
|
389
|
+
log.error('Error removing WebdriverIO video files: %s', error.message)
|
|
390
|
+
}
|
|
391
|
+
onDone(result)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** @param {object} browser - Current WebdriverIO browser or multiremote browser */
|
|
396
|
+
function hasSafePromptPolicy (browser) {
|
|
397
|
+
const sessions = (browser.isMultiremote || browser.isMultiRemote)
|
|
398
|
+
? browser.instances.map(name => browser.getInstance(name))
|
|
399
|
+
: [browser]
|
|
400
|
+
for (const session of sessions) {
|
|
401
|
+
const behavior = session.capabilities?.unhandledPromptBehavior
|
|
402
|
+
if (behavior === 'ignore' || (behavior?.default === 'ignore' &&
|
|
403
|
+
Object.values(behavior).every(value => value === 'ignore'))) continue
|
|
404
|
+
if (!unsafePromptBrowsers.has(browser)) {
|
|
405
|
+
unsafePromptBrowsers.add(browser)
|
|
406
|
+
log.warn('WebdriverIO failure video recording is disabled: every session must use %s to preserve open dialogs.',
|
|
407
|
+
'unhandledPromptBehavior: "ignore"')
|
|
408
|
+
}
|
|
409
|
+
return false
|
|
410
|
+
}
|
|
411
|
+
return true
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Checks FFmpeg once in the worker, before configuring browser sessions or recording attempts. */
|
|
415
|
+
function isFfmpegAvailable () {
|
|
416
|
+
if (ffmpegAvailable !== undefined) return ffmpegAvailable
|
|
417
|
+
ffmpegAvailable = false
|
|
418
|
+
try {
|
|
419
|
+
const result = spawnSync('ffmpeg', ['-hide_banner', '-encoders'], {
|
|
420
|
+
timeout: 5000, killSignal: 'SIGKILL', windowsHide: true, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
421
|
+
})
|
|
422
|
+
ffmpegAvailable = !result.error && result.status === 0 && /^[ \t]*V\S{5}[ \t]+libvpx[ \t]/m.test(result.stdout)
|
|
423
|
+
} catch (error) {
|
|
424
|
+
log.error('Error checking WebdriverIO video FFmpeg availability: %s', error.message)
|
|
425
|
+
}
|
|
426
|
+
if (!ffmpegAvailable) {
|
|
427
|
+
log.warn('WebdriverIO failure video recording requires FFmpeg on PATH with the libvpx encoder.')
|
|
428
|
+
}
|
|
429
|
+
return ffmpegAvailable
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Creates a recorder when a browser is available. The encoder is loaded only for failed attempts.
|
|
434
|
+
*
|
|
435
|
+
* @param {object} browser
|
|
436
|
+
* @returns {WebdriverioVideo|void}
|
|
437
|
+
*/
|
|
438
|
+
function createWebdriverioVideo (browser) {
|
|
439
|
+
if (typeof browser?.takeScreenshot !== 'function') return
|
|
440
|
+
try {
|
|
441
|
+
if (ffmpegAvailable === false || !hasSafePromptPolicy(browser) || !isFfmpegAvailable()) return
|
|
442
|
+
return new WebdriverioVideo(browser)
|
|
443
|
+
} catch (error) {
|
|
444
|
+
log.error('Error starting WebdriverIO video recording: %s', error.message)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
module.exports = createWebdriverioVideo
|
|
449
|
+
module.exports.isFfmpegAvailable = isFfmpegAvailable
|
|
@@ -3655,9 +3655,9 @@
|
|
|
3655
3655
|
],
|
|
3656
3656
|
"DD_TRACE_OBFUSCATION_QUERY_STRING_REGEXP": [
|
|
3657
3657
|
{
|
|
3658
|
-
"implementation": "
|
|
3658
|
+
"implementation": "I",
|
|
3659
3659
|
"type": "string",
|
|
3660
|
-
"default": "(?:p(?:ass)?w(?:or)?d|pass(?:_?phrase)?|secret|(?:
|
|
3660
|
+
"default": "(?:(?:\"|%22)?)(?:(?:old[-_]?|new[-_]?)?p(?:ass)?w(?:or)?d(?:1|2)?|pass(?:[-_]?phrase)?|secret|(?:api[-_]?|private[-_]?|public[-_]?|access[-_]?|secret[-_]?|app(?:lication)?[-_]?)key(?:[-_]?id)?|token|consumer[-_]?(?:id|key|secret)|sign(?:ed|ature)?|auth(?:entication|orization)?)(?:(?:\\s|%20)*(?:=|%3D)[^&]+|(?:\"|%22)(?:\\s|%20)*(?::|%3A)(?:\\s|%20)*(?:\"|%22)(?:%2[^2]|%[^2]|[^\"%])+(?:\"|%22))|(?:bearer(?:\\s|%20)+[a-z0-9._\\-]+|token(?::|%3A)[a-z0-9]{13}|gh[opsu]_[0-9a-zA-Z]{36}|(^|[^\\w%-]|%[0-9a-f]{2})ey[I-L][\\w-]+(?:=|%3D)*\\.ey[I-L][\\w-]+(?:=|%3D)*(?:\\.(?:[\\w.+/=-]|%3D|%2F|%2B)+)?|-{5}BEGIN(?:[a-z\\s]|%20)+PRIVATE(?:\\s|%20)KEY-{5}[^\\-]+-{5}END(?:[a-z\\s]|%20)+PRIVATE(?:\\s|%20)KEY(?:-{5})?(?:\\n|%0A)?|(?:ssh-(?:rsa|dss)|ecdsa-[a-z0-9]+-[a-z0-9]+)(?:\\s|%20|%09)+(?:[a-z0-9/.+]|%2F|%5C|%2B){100,}(?:=|%3D)*(?:(?:\\s|%20|%09)+[a-z0-9._-]+)?)"
|
|
3661
3661
|
}
|
|
3662
3662
|
],
|
|
3663
3663
|
"DD_TRACE_OPENAI_ENABLED": [
|
|
@@ -6,6 +6,7 @@ const WORKER_ERROR_REASON = Object.freeze({
|
|
|
6
6
|
UNSUPPORTED_INSERTION_POINT: 'unsupported_insertion_point',
|
|
7
7
|
CONFLICTING_CAPTURE_OPTIONS: 'conflicting_capture_options',
|
|
8
8
|
UNKNOWN_REMOTE_CONFIG_ACTION: 'unknown_remote_config_action',
|
|
9
|
+
WORKER_ENTRY_POINT_NOT_FOUND: 'worker_entry_point_not_found',
|
|
9
10
|
})
|
|
10
11
|
|
|
11
12
|
module.exports = {
|
|
@@ -22,6 +22,7 @@ const { getLocalStateForCallFrame, evaluateCaptureExpressions } = require('./sna
|
|
|
22
22
|
const send = require('./send')
|
|
23
23
|
const { getStackFromCallFrames } = require('./state')
|
|
24
24
|
const { ackEmitting } = require('./status')
|
|
25
|
+
const WorkerError = require('./worker-error')
|
|
25
26
|
const config = require('./config')
|
|
26
27
|
const log = require('./log')
|
|
27
28
|
|
|
@@ -51,9 +52,10 @@ session.on('Debugger.paused', async ({ params }) => {
|
|
|
51
52
|
|
|
52
53
|
if (params.reason !== 'other') {
|
|
53
54
|
// This error should not be caught, and should exit the worker thread, effectively stopping the debugging session
|
|
54
|
-
throw
|
|
55
|
-
reason:
|
|
56
|
-
|
|
55
|
+
throw new WorkerError(
|
|
56
|
+
`Unexpected Debugger.paused reason: ${params.reason}`,
|
|
57
|
+
WORKER_ERROR_REASON.UNEXPECTED_PAUSE_REASON
|
|
58
|
+
)
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
let maxReferenceDepth = 0
|
|
@@ -4,6 +4,7 @@ const { workerData: { probePort } } = require('node:worker_threads')
|
|
|
4
4
|
const { WORKER_ERROR_REASON } = require('../constants')
|
|
5
5
|
const { addBreakpoint, removeBreakpoint, modifyBreakpoint } = require('./breakpoints')
|
|
6
6
|
const { ackReceived, ackInstalled, ackError } = require('./status')
|
|
7
|
+
const WorkerError = require('./worker-error')
|
|
7
8
|
const log = require('./log')
|
|
8
9
|
|
|
9
10
|
// Example log line probe with captureSnapshot (simplified):
|
|
@@ -74,20 +75,23 @@ async function processMsg (action, probe) {
|
|
|
74
75
|
if (action !== 'unapply') ackReceived(probe)
|
|
75
76
|
|
|
76
77
|
if (probe.type !== 'LOG_PROBE') {
|
|
77
|
-
throw
|
|
78
|
-
`Unsupported probe type: ${probe.type} (id: ${probe.id}, version: ${probe.version})
|
|
79
|
-
|
|
78
|
+
throw new WorkerError(
|
|
79
|
+
`Unsupported probe type: ${probe.type} (id: ${probe.id}, version: ${probe.version})`,
|
|
80
|
+
WORKER_ERROR_REASON.UNSUPPORTED_PROBE_TYPE
|
|
81
|
+
)
|
|
80
82
|
}
|
|
81
83
|
if (!probe.where.sourceFile && !probe.where.lines) {
|
|
82
|
-
throw
|
|
84
|
+
throw new WorkerError(
|
|
83
85
|
// eslint-disable-next-line @stylistic/max-len
|
|
84
|
-
`Unsupported probe insertion point! Only line-based probes are supported (id: ${probe.id}, version: ${probe.version})
|
|
85
|
-
|
|
86
|
+
`Unsupported probe insertion point! Only line-based probes are supported (id: ${probe.id}, version: ${probe.version})`,
|
|
87
|
+
WORKER_ERROR_REASON.UNSUPPORTED_INSERTION_POINT
|
|
88
|
+
)
|
|
86
89
|
}
|
|
87
90
|
if (probe.captureSnapshot && probe.captureExpressions?.length > 0) {
|
|
88
|
-
throw
|
|
89
|
-
`Cannot set both captureSnapshot and captureExpressions (probe: ${probe.id}, version: ${probe.version})
|
|
90
|
-
|
|
91
|
+
throw new WorkerError(
|
|
92
|
+
`Cannot set both captureSnapshot and captureExpressions (probe: ${probe.id}, version: ${probe.version})`,
|
|
93
|
+
WORKER_ERROR_REASON.CONFLICTING_CAPTURE_OPTIONS
|
|
94
|
+
)
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
switch (action) {
|
|
@@ -103,8 +107,9 @@ async function processMsg (action, probe) {
|
|
|
103
107
|
ackInstalled(probe)
|
|
104
108
|
break
|
|
105
109
|
default:
|
|
106
|
-
throw
|
|
107
|
-
`Cannot process probe ${probe.id} (version: ${probe.version}) - unknown remote configuration action: ${action}
|
|
108
|
-
|
|
110
|
+
throw new WorkerError(
|
|
111
|
+
`Cannot process probe ${probe.id} (version: ${probe.version}) - unknown remote configuration action: ${action}`,
|
|
112
|
+
WORKER_ERROR_REASON.UNKNOWN_REMOTE_CONFIG_ACTION
|
|
113
|
+
)
|
|
109
114
|
}
|
|
110
115
|
}
|
|
@@ -17,8 +17,12 @@ const buildTags = require('./tags')
|
|
|
17
17
|
|
|
18
18
|
module.exports = send
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// The backend caps a single debugger event at 1 MiB, but measures the CBOR-encoded event *after* the
|
|
21
|
+
// intake has enriched it with host tags, `ddtags` and other metadata we cannot size from here. Leave
|
|
22
|
+
// the same 4 KiB of headroom as dd-trace-go: crossing the backend cap is worse than pruning, since
|
|
23
|
+
// its truncation destroys the log message before it drops the snapshot.
|
|
24
|
+
const BACKEND_ENRICHMENT_HEADROOM_BYTES = 4 * 1024
|
|
25
|
+
const MAX_LOG_PAYLOAD_SIZE_BYTES = 1024 * 1024 - BACKEND_ENRICHMENT_HEADROOM_BYTES
|
|
22
26
|
|
|
23
27
|
const ddsource = 'dd_debugger'
|
|
24
28
|
const hostname = getHostname()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/** @typedef {typeof import('../constants').WORKER_ERROR_REASON} WorkerErrorReasons */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An error carrying a failure reason for telemetry. Its prototype does not survive the worker boundary, so the main
|
|
7
|
+
* thread must read `reason` instead of checking `instanceof`. `name` is left as `Error` because structured cloning
|
|
8
|
+
* only keeps built-in error names, and telemetry should report the same name however the error leaves the worker.
|
|
9
|
+
*/
|
|
10
|
+
class WorkerError extends Error {
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} message - The error message
|
|
13
|
+
* @param {WorkerErrorReasons[keyof WorkerErrorReasons]} reason - The failure reason reported in telemetry
|
|
14
|
+
*/
|
|
15
|
+
constructor (message, reason) {
|
|
16
|
+
super(message)
|
|
17
|
+
this.reason = reason
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = WorkerError
|
|
@@ -8,6 +8,7 @@ const dc = require('dc-polyfill')
|
|
|
8
8
|
const log = require('../log')
|
|
9
9
|
const { fetchAgentInfo } = require('../agent/info')
|
|
10
10
|
const telemetryMetrics = require('../telemetry/metrics')
|
|
11
|
+
const { logGenericError } = require('../startup-log')
|
|
11
12
|
const getDebuggerConfig = require('./config')
|
|
12
13
|
const {
|
|
13
14
|
DEBUGGER_DIAGNOSTICS_V1,
|
|
@@ -15,6 +16,7 @@ const {
|
|
|
15
16
|
DEBUGGER_INPUT_V2,
|
|
16
17
|
INSPECT_SEGMENT_GLOBAL_PROPERTY,
|
|
17
18
|
METRICS_FLUSH_INTERVAL_MS,
|
|
19
|
+
WORKER_ERROR_REASON,
|
|
18
20
|
} = require('./constants')
|
|
19
21
|
const { GuardrailMetrics, TELEMETRY_NAMESPACE } = require('./guardrail-metrics')
|
|
20
22
|
const { PauseDurationHistogram } = require('./pause-duration-histogram')
|
|
@@ -33,6 +35,8 @@ const { configureProbeSampler, installProbeSampler, uninstallProbeSampler } = re
|
|
|
33
35
|
// be lost when the application exits on its own.
|
|
34
36
|
const TELEMETRY_APP_CLOSING_CHANNEL = 'datadog:telemetry:app-closing'
|
|
35
37
|
|
|
38
|
+
const WORKER_FILENAME = join(__dirname, 'devtools_client', 'index.js')
|
|
39
|
+
|
|
36
40
|
let worker = null
|
|
37
41
|
let configChannel = null
|
|
38
42
|
let ackId = 0
|
|
@@ -139,7 +143,7 @@ function start (config, rcInstance) {
|
|
|
139
143
|
inputPath = _inputPath
|
|
140
144
|
|
|
141
145
|
worker = new Worker(
|
|
142
|
-
|
|
146
|
+
WORKER_FILENAME,
|
|
143
147
|
{
|
|
144
148
|
name: 'dd-debugger',
|
|
145
149
|
execArgv: [], // Avoid worker thread inheriting the `-r` command line argument
|
|
@@ -165,7 +169,7 @@ function start (config, rcInstance) {
|
|
|
165
169
|
)
|
|
166
170
|
})
|
|
167
171
|
|
|
168
|
-
worker.on('error',
|
|
172
|
+
worker.on('error', onWorkerError)
|
|
169
173
|
worker.on('messageerror', (err) => log.error('[debugger] received "messageerror" from worker', err))
|
|
170
174
|
|
|
171
175
|
worker.once('exit', (code) => {
|
|
@@ -186,6 +190,24 @@ function start (config, rcInstance) {
|
|
|
186
190
|
})
|
|
187
191
|
}
|
|
188
192
|
|
|
193
|
+
/**
|
|
194
|
+
* @param {Error & { code?: unknown, requireStack?: unknown }} error - The error emitted by the worker thread
|
|
195
|
+
*/
|
|
196
|
+
function onWorkerError (error) {
|
|
197
|
+
// Node.js reports an empty require stack when the module it cannot find is the worker entry point itself, rather
|
|
198
|
+
// than one of its dependencies. This happens when the dd-trace files are not on disk where dd-trace expects them,
|
|
199
|
+
// e.g. because dd-trace was bundled into the application, or because some of its files were not deployed.
|
|
200
|
+
if (error.code === 'MODULE_NOT_FOUND' && Array.isArray(error.requireStack) && error.requireStack.length === 0) {
|
|
201
|
+
logGenericError(
|
|
202
|
+
`Dynamic Instrumentation is disabled, because its worker thread could not load ${WORKER_FILENAME}. ` +
|
|
203
|
+
'Make sure dd-trace is installed as regular files on disk and is not bundled into the application.'
|
|
204
|
+
)
|
|
205
|
+
logWorkerError(error, WORKER_ERROR_REASON.WORKER_ENTRY_POINT_NOT_FOUND)
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
logWorkerError(error)
|
|
209
|
+
}
|
|
210
|
+
|
|
189
211
|
/**
|
|
190
212
|
* Failure metadata belongs in the telemetry message; exception messages remain in the redacted cause.
|
|
191
213
|
*
|