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.
Files changed (45) hide show
  1. package/index.d.ts +91 -3
  2. package/package.json +6 -7
  3. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js +57 -4
  4. package/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +1 -0
  5. package/packages/datadog-instrumentations/src/jest.js +119 -1
  6. package/packages/datadog-instrumentations/src/mocha/utils.js +124 -36
  7. package/packages/datadog-instrumentations/src/mocha/webdriverio-protocol.js +61 -34
  8. package/packages/datadog-instrumentations/src/mocha/worker.js +6 -3
  9. package/packages/datadog-instrumentations/src/playwright.js +3 -0
  10. package/packages/datadog-instrumentations/src/vitest-main.js +0 -2
  11. package/packages/datadog-instrumentations/src/webdriverio.js +115 -15
  12. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +17 -0
  13. package/packages/datadog-plugin-mocha/src/index.js +216 -29
  14. package/packages/datadog-plugin-mocha/src/webdriverio-video.js +449 -0
  15. package/packages/dd-trace/src/config/supported-configurations.json +2 -2
  16. package/packages/dd-trace/src/debugger/constants.js +1 -0
  17. package/packages/dd-trace/src/debugger/devtools_client/index.js +5 -3
  18. package/packages/dd-trace/src/debugger/devtools_client/remote_config.js +17 -12
  19. package/packages/dd-trace/src/debugger/devtools_client/send.js +6 -2
  20. package/packages/dd-trace/src/debugger/devtools_client/worker-error.js +21 -0
  21. package/packages/dd-trace/src/debugger/index.js +24 -2
  22. package/packages/dd-trace/src/debugger/probe_sampler.js +108 -11
  23. package/packages/dd-trace/src/exporters/common/request.js +1 -0
  24. package/packages/dd-trace/src/exporters/common/url.js +1 -1
  25. package/packages/dd-trace/src/id.js +1 -4
  26. package/packages/dd-trace/src/llmobs/agent-manifest.js +296 -0
  27. package/packages/dd-trace/src/llmobs/constants/tags.js +1 -0
  28. package/packages/dd-trace/src/llmobs/sdk.js +23 -6
  29. package/packages/dd-trace/src/llmobs/span_processor.js +17 -6
  30. package/packages/dd-trace/src/llmobs/tagger.js +54 -1
  31. package/packages/dd-trace/src/llmobs/writers/base.js +3 -1
  32. package/packages/dd-trace/src/llmobs/writers/util.js +3 -1
  33. package/packages/dd-trace/src/openfeature/agentless_configuration_source.js +30 -1
  34. package/packages/dd-trace/src/openfeature/configuration_source.js +17 -2
  35. package/packages/dd-trace/src/openfeature/debug-logging-hook.js +25 -0
  36. package/packages/dd-trace/src/openfeature/flagging_provider.js +29 -0
  37. package/packages/dd-trace/src/openfeature/remote_config.js +9 -0
  38. package/packages/dd-trace/src/openfeature/writers/util.js +43 -4
  39. package/packages/dd-trace/src/opentelemetry/resource-attributes.js +1 -1
  40. package/packages/dd-trace/src/otel-thread-ctx.js +2 -2
  41. package/packages/dd-trace/src/plugins/util/url.js +6 -1
  42. package/packages/dd-trace/src/profiling/profilers/wall.js +4 -10
  43. package/packages/dd-trace/src/proxy.js +2 -0
  44. package/packages/dd-trace/src/serverless.js +5 -0
  45. 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": "F",
3658
+ "implementation": "I",
3659
3659
  "type": "string",
3660
- "default": "(?:p(?:ass)?w(?:or)?d|pass(?:_?phrase)?|secret|(?:api_?|private_?|public_?|access_?|secret_?)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}|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|ssh-rsa(?:\\s|%20)*(?:[a-z0-9\\/\\.+]|%2F|%5C|%2B){100,}"
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 Object.assign(new Error(`Unexpected Debugger.paused reason: ${params.reason}`), {
55
- reason: WORKER_ERROR_REASON.UNEXPECTED_PAUSE_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 Object.assign(new Error(
78
- `Unsupported probe type: ${probe.type} (id: ${probe.id}, version: ${probe.version})`
79
- ), { reason: WORKER_ERROR_REASON.UNSUPPORTED_PROBE_TYPE })
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 Object.assign(new Error(
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
- ), { reason: WORKER_ERROR_REASON.UNSUPPORTED_INSERTION_POINT })
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 Object.assign(new Error(
89
- `Cannot set both captureSnapshot and captureExpressions (probe: ${probe.id}, version: ${probe.version})`
90
- ), { reason: WORKER_ERROR_REASON.CONFLICTING_CAPTURE_OPTIONS })
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 Object.assign(new Error(
107
- `Cannot process probe ${probe.id} (version: ${probe.version}) - unknown remote configuration action: ${action}`
108
- ), { reason: WORKER_ERROR_REASON.UNKNOWN_REMOTE_CONFIG_ACTION })
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
- const MAX_LOG_PAYLOAD_SIZE_MB = 1
21
- const MAX_LOG_PAYLOAD_SIZE_BYTES = MAX_LOG_PAYLOAD_SIZE_MB * 1024 * 1024
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
- join(__dirname, 'devtools_client', 'index.js'),
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', (err) => logWorkerError(err))
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
  *