dd-trace 5.92.0 → 5.94.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 (31) hide show
  1. package/package.json +15 -11
  2. package/packages/datadog-instrumentations/src/helpers/bundler-register.js +23 -0
  3. package/packages/datadog-instrumentations/src/jest.js +118 -32
  4. package/packages/datadog-instrumentations/src/mocha/main.js +6 -0
  5. package/packages/datadog-instrumentations/src/mocha/utils.js +89 -5
  6. package/packages/datadog-instrumentations/src/playwright.js +10 -0
  7. package/packages/datadog-instrumentations/src/vitest.js +119 -0
  8. package/packages/datadog-plugin-aws-sdk/src/base.js +5 -0
  9. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +12 -0
  10. package/packages/datadog-plugin-jest/src/index.js +6 -0
  11. package/packages/datadog-plugin-mocha/src/index.js +11 -0
  12. package/packages/datadog-plugin-playwright/src/index.js +9 -0
  13. package/packages/datadog-plugin-vitest/src/index.js +9 -0
  14. package/packages/datadog-webpack/index.js +187 -0
  15. package/packages/datadog-webpack/src/loader.js +27 -0
  16. package/packages/datadog-webpack/src/log.js +32 -0
  17. package/packages/dd-trace/src/ci-visibility/early-flake-detection/get-known-tests.js +103 -32
  18. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/writer.js +10 -21
  19. package/packages/dd-trace/src/config/supported-configurations.json +2 -2
  20. package/packages/dd-trace/src/crashtracking/index.js +7 -1
  21. package/packages/dd-trace/src/exporters/common/docker.js +1 -0
  22. package/packages/dd-trace/src/exporters/common/request.js +26 -17
  23. package/packages/dd-trace/src/opentracing/span.js +5 -0
  24. package/packages/dd-trace/src/plugin_manager.js +10 -7
  25. package/packages/dd-trace/src/plugins/util/test.js +76 -0
  26. package/packages/dd-trace/src/priority_sampler.js +6 -3
  27. package/packages/dd-trace/src/profiling/profiler.js +78 -47
  28. package/packages/dd-trace/src/profiling/profilers/wall.js +35 -28
  29. package/packages/dd-trace/src/proxy.js +4 -3
  30. package/packages/dd-trace/src/tracer_metadata.js +10 -1
  31. package/webpack.js +3 -0
@@ -17,6 +17,36 @@ const {
17
17
 
18
18
  const { getNumFromKnownTests } = require('../../plugins/util/test')
19
19
 
20
+ const MAX_KNOWN_TESTS_PAGES = 10_000
21
+
22
+ /**
23
+ * Deep-merges page tests into aggregate.
24
+ * Structure: { module: { suite: [testName, ...] } }
25
+ */
26
+ function mergeKnownTests (aggregate, page) {
27
+ if (!page) return aggregate
28
+ if (!aggregate) return page
29
+
30
+ for (const [moduleName, suites] of Object.entries(page)) {
31
+ if (!suites) continue
32
+
33
+ if (!aggregate[moduleName]) {
34
+ aggregate[moduleName] = suites
35
+ continue
36
+ }
37
+
38
+ for (const [suiteName, tests] of Object.entries(suites)) {
39
+ if (!tests || tests.length === 0) continue
40
+
41
+ aggregate[moduleName][suiteName] = aggregate[moduleName][suiteName]
42
+ ? [...aggregate[moduleName][suiteName], ...tests]
43
+ : tests
44
+ }
45
+ }
46
+
47
+ return aggregate
48
+ }
49
+
20
50
  function getKnownTests ({
21
51
  url,
22
52
  isEvpProxy,
@@ -59,53 +89,94 @@ function getKnownTests ({
59
89
  options.headers['dd-api-key'] = apiKey
60
90
  }
61
91
 
62
- const data = JSON.stringify({
63
- data: {
64
- id: id().toString(10),
65
- type: 'ci_app_libraries_tests_request',
66
- attributes: {
67
- configurations: {
68
- 'os.platform': osPlatform,
69
- 'os.version': osVersion,
70
- 'os.architecture': osArchitecture,
71
- 'runtime.name': runtimeName,
72
- 'runtime.version': runtimeVersion,
73
- custom,
74
- },
75
- service,
76
- env,
77
- repository_url: repositoryUrl,
78
- sha,
79
- },
80
- },
81
- })
92
+ const configurations = {
93
+ 'os.platform': osPlatform,
94
+ 'os.version': osVersion,
95
+ 'os.architecture': osArchitecture,
96
+ 'runtime.name': runtimeName,
97
+ 'runtime.version': runtimeVersion,
98
+ custom,
99
+ }
82
100
 
83
101
  incrementCountMetric(TELEMETRY_KNOWN_TESTS)
84
102
 
85
103
  const startTime = Date.now()
104
+ let aggregateTests = null
105
+ let totalResponseBytes = 0
106
+ let pageNumber = 0
107
+
108
+ function fetchPage (pageState) {
109
+ pageNumber++
110
+
111
+ if (pageNumber > MAX_KNOWN_TESTS_PAGES) {
112
+ log.error('Known tests pagination exceeded maximum of %d pages. Aborting.', MAX_KNOWN_TESTS_PAGES)
113
+ distributionMetric(TELEMETRY_KNOWN_TESTS_MS, {}, Date.now() - startTime)
114
+ return done(new Error(`Known tests pagination exceeded maximum of ${MAX_KNOWN_TESTS_PAGES} pages`))
115
+ }
116
+
117
+ const pageInfo = pageState ? { page_state: pageState } : {}
118
+
119
+ const data = JSON.stringify({
120
+ data: {
121
+ id: id().toString(10),
122
+ type: 'ci_app_libraries_tests_request',
123
+ attributes: {
124
+ configurations,
125
+ service,
126
+ env,
127
+ repository_url: repositoryUrl,
128
+ sha,
129
+ page_info: pageInfo,
130
+ },
131
+ },
132
+ })
133
+
134
+ request(data, options, (err, res, statusCode) => {
135
+ if (err) {
136
+ distributionMetric(TELEMETRY_KNOWN_TESTS_MS, {}, Date.now() - startTime)
137
+ incrementCountMetric(TELEMETRY_KNOWN_TESTS_ERRORS, { statusCode })
138
+ return done(err)
139
+ }
86
140
 
87
- request(data, options, (err, res, statusCode) => {
88
- distributionMetric(TELEMETRY_KNOWN_TESTS_MS, {}, Date.now() - startTime)
89
- if (err) {
90
- incrementCountMetric(TELEMETRY_KNOWN_TESTS_ERRORS, { statusCode })
91
- done(err)
92
- } else {
93
141
  try {
94
- const { data: { attributes: { tests: knownTests } } } = JSON.parse(res)
142
+ totalResponseBytes += res.length
143
+
144
+ const { data: { attributes } } = JSON.parse(res)
145
+ const { tests: pageTests, page_info: responsePageInfo } = attributes
95
146
 
96
- const numTests = getNumFromKnownTests(knownTests)
147
+ aggregateTests = mergeKnownTests(aggregateTests, pageTests)
148
+
149
+ // Check if there are more pages
150
+ if (responsePageInfo && responsePageInfo.has_next) {
151
+ if (!responsePageInfo.cursor) {
152
+ log.error(
153
+ 'Known tests response has has_next=true but no cursor on page %d. Aborting pagination.', pageNumber
154
+ )
155
+ distributionMetric(TELEMETRY_KNOWN_TESTS_MS, {}, Date.now() - startTime)
156
+ return done(new Error('Known tests pagination: has_next=true but no cursor'))
157
+ }
158
+ return fetchPage(responsePageInfo.cursor)
159
+ }
160
+
161
+ // Done — no more pages
162
+ distributionMetric(TELEMETRY_KNOWN_TESTS_MS, {}, Date.now() - startTime)
163
+
164
+ const numTests = getNumFromKnownTests(aggregateTests)
97
165
 
98
166
  distributionMetric(TELEMETRY_KNOWN_TESTS_RESPONSE_TESTS, {}, numTests)
99
- distributionMetric(TELEMETRY_KNOWN_TESTS_RESPONSE_BYTES, {}, res.length)
167
+ distributionMetric(TELEMETRY_KNOWN_TESTS_RESPONSE_BYTES, {}, totalResponseBytes)
100
168
 
101
169
  log.debug('Number of received known tests:', numTests)
102
170
 
103
- done(null, knownTests)
171
+ done(null, aggregateTests)
104
172
  } catch (err) {
173
+ distributionMetric(TELEMETRY_KNOWN_TESTS_MS, {}, Date.now() - startTime)
105
174
  done(err)
106
175
  }
107
- }
108
- })
176
+ })
177
+ }
178
+
179
+ fetchPage(null)
109
180
  }
110
181
 
111
182
  module.exports = { getKnownTests }
@@ -2,10 +2,6 @@
2
2
  const { JSONEncoder } = require('../../encode/json-encoder')
3
3
  const { getEnvironmentVariable } = require('../../../config/helper')
4
4
  const log = require('../../../log')
5
- const {
6
- VITEST_WORKER_TRACE_PAYLOAD_CODE,
7
- VITEST_WORKER_LOGS_PAYLOAD_CODE,
8
- } = require('../../../plugins/util/test')
9
5
 
10
6
  class Writer {
11
7
  constructor (interprocessCode) {
@@ -29,12 +25,6 @@ class Writer {
29
25
  }
30
26
 
31
27
  _sendPayload (data, onDone = () => {}) {
32
- // ## Jest
33
- // Only available when `child_process` is used for the jest worker.
34
- // If worker_threads is used, this will not work
35
- // TODO: make `jest` instrumentation compatible with worker_threads
36
- // https://github.com/facebook/jest/blob/bb39cb2c617a3334bf18daeca66bd87b7ccab28b/packages/jest-worker/README.md#experimental-worker
37
-
38
28
  // ## Cucumber
39
29
  // This reports to the test's main process the same way test data is reported by Cucumber
40
30
  // See cucumber code:
@@ -47,19 +37,17 @@ class Writer {
47
37
  ? { __tinypool_worker_message__: true, interprocessCode: this._interprocessCode, data }
48
38
  : [this._interprocessCode, data]
49
39
 
50
- const isVitestTestWorker =
51
- this._interprocessCode === VITEST_WORKER_TRACE_PAYLOAD_CODE ||
52
- this._interprocessCode === VITEST_WORKER_LOGS_PAYLOAD_CODE
53
-
40
+ // child_process workers (jest default, cucumber)
54
41
  if (process.send) {
55
42
  process.send(payload, () => {
56
43
  onDone()
57
44
  })
58
- } else if (isVitestTestWorker) { // TODO: worker_threads are only supported in vitest right now
59
- const { isMainThread, parentPort } = require('worker_threads')
60
- if (isMainThread) {
61
- return onDone()
62
- }
45
+ return
46
+ }
47
+
48
+ // worker_threads (jest --workerThreads, vitest)
49
+ const { isMainThread, parentPort } = require('node:worker_threads')
50
+ if (!isMainThread && parentPort) {
63
51
  try {
64
52
  parentPort.postMessage(payload)
65
53
  } catch (error) {
@@ -67,9 +55,10 @@ class Writer {
67
55
  } finally {
68
56
  onDone()
69
57
  }
70
- } else {
71
- onDone()
58
+ return
72
59
  }
60
+
61
+ onDone()
73
62
  }
74
63
  }
75
64
 
@@ -744,9 +744,9 @@
744
744
  ],
745
745
  "DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED": [
746
746
  {
747
- "implementation": "A",
747
+ "implementation": "B",
748
748
  "type": "boolean",
749
- "default": "false",
749
+ "default": "true",
750
750
  "configurationNames": [
751
751
  "propagateProcessTags.enabled"
752
752
  ]
@@ -1,9 +1,15 @@
1
1
  'use strict'
2
2
 
3
+ const { existsSync } = require('node:fs')
3
4
  const { isMainThread } = require('worker_threads')
4
5
  const log = require('../log')
5
6
 
6
- if (isMainThread) {
7
+ // libdatadog v29 crashtracker segfaults during init on ARM64 musl (Alpine).
8
+ // The segfault bypasses JS try/catch so we must avoid loading it entirely.
9
+ // See: https://github.com/DataDog/libdatadog-nodejs/issues/114
10
+ const isArm64Musl = process.arch === 'arm64' && existsSync('/etc/alpine-release')
11
+
12
+ if (isMainThread && !isArm64Musl) {
7
13
  try {
8
14
  module.exports = require('./crashtracker')
9
15
  } catch (e) {
@@ -35,6 +35,7 @@ if (inodePath) {
35
35
  const entityId = containerId ? `ci-${containerId}` : inode && `in-${inode}`
36
36
 
37
37
  module.exports = {
38
+ containerId,
38
39
  entityId,
39
40
 
40
41
  inject (carrier) {
@@ -14,9 +14,9 @@ const { urlToHttpOptions } = require('./url-to-http-options-polyfill')
14
14
  const docker = require('./docker')
15
15
  const { httpAgent, httpsAgent } = require('./agents')
16
16
 
17
- const maxActiveRequests = 8
17
+ const maxActiveBufferSize = 1024 * 1024 * 64
18
18
 
19
- let activeRequests = 0
19
+ let activeBufferSize = 0
20
20
 
21
21
  function parseUrl (urlObjOrString) {
22
22
  if (urlObjOrString !== null && typeof urlObjOrString === 'object') return urlToHttpOptions(urlObjOrString)
@@ -50,7 +50,22 @@ function request (data, options, callback) {
50
50
  }
51
51
  }
52
52
 
53
- const isReadable = data instanceof Readable
53
+ if (data instanceof Readable) {
54
+ const chunks = []
55
+
56
+ data
57
+ .on('data', (data) => {
58
+ chunks.push(data)
59
+ })
60
+ .on('end', () => {
61
+ request(Buffer.concat(chunks), options, callback)
62
+ })
63
+ .on('error', (err) => {
64
+ callback(err)
65
+ })
66
+
67
+ return
68
+ }
54
69
 
55
70
  // The timeout should be kept low to avoid excessive queueing.
56
71
  const timeout = options.timeout || 2000
@@ -58,12 +73,10 @@ function request (data, options, callback) {
58
73
  const client = isSecure ? https : http
59
74
  let dataArray = data
60
75
 
61
- if (!isReadable) {
62
- if (!Array.isArray(data)) {
63
- dataArray = [data]
64
- }
65
- options.headers['Content-Length'] = byteLength(dataArray)
76
+ if (!Array.isArray(data)) {
77
+ dataArray = [data]
66
78
  }
79
+ options.headers['Content-Length'] = byteLength(dataArray)
67
80
 
68
81
  docker.inject(options.headers)
69
82
 
@@ -126,14 +139,14 @@ function request (data, options, callback) {
126
139
  return callback(null)
127
140
  }
128
141
 
129
- activeRequests++
142
+ activeBufferSize += options.headers['Content-Length'] ?? 0
130
143
 
131
144
  storage('legacy').run({ noop: true }, () => {
132
145
  let finished = false
133
146
  const finalize = () => {
134
147
  if (finished) return
135
148
  finished = true
136
- activeRequests--
149
+ activeBufferSize -= options.headers['Content-Length'] ?? 0
137
150
  }
138
151
 
139
152
  const req = client.request(options, (res) => onResponse(res, finalize))
@@ -158,12 +171,8 @@ function request (data, options, callback) {
158
171
  }
159
172
  })
160
173
 
161
- if (isReadable) {
162
- data.pipe(req) // TODO: Validate whether this is actually retriable.
163
- } else {
164
- for (const buffer of dataArray) req.write(buffer)
165
- req.end()
166
- }
174
+ for (const buffer of dataArray) req.write(buffer)
175
+ req.end()
167
176
  })
168
177
  }
169
178
 
@@ -183,7 +192,7 @@ function byteLength (data) {
183
192
 
184
193
  Object.defineProperty(request, 'writable', {
185
194
  get () {
186
- return activeRequests < maxActiveRequests
195
+ return activeBufferSize < maxActiveBufferSize
187
196
  },
188
197
  })
189
198
 
@@ -35,6 +35,7 @@ const integrationCounters = {
35
35
 
36
36
  const startCh = channel('dd-trace:span:start')
37
37
  const finishCh = channel('dd-trace:span:finish')
38
+ const tagsUpdateCh = channel('dd-trace:span:tags:update')
38
39
 
39
40
  function getIntegrationCounter (event, integration) {
40
41
  const counters = integrationCounters[event]
@@ -399,6 +400,10 @@ class DatadogSpan {
399
400
  tagger.add(this._spanContext._tags, keyValuePairs)
400
401
 
401
402
  this._prioritySampler.sample(this, false)
403
+
404
+ if (tagsUpdateCh.hasSubscribers) {
405
+ tagsUpdateCh.publish(this)
406
+ }
402
407
  }
403
408
  }
404
409
 
@@ -18,13 +18,6 @@ const TEST_OPTIMIZATION_PLUGINS = new Set([
18
18
 
19
19
  const loadChannel = channel('dd-trace:instrumentation:load')
20
20
 
21
- // instrument everything that needs Plugin System V2 instrumentation
22
- require('../../datadog-instrumentations')
23
- if (getEnvironmentVariable('AWS_LAMBDA_FUNCTION_NAME') !== undefined) {
24
- // instrument lambda environment
25
- require('./lambda')
26
- }
27
-
28
21
  const DD_TRACE_DISABLED_PLUGINS = getValueFromEnvSources('DD_TRACE_DISABLED_PLUGINS')
29
22
 
30
23
  const disabledPlugins = new Set(
@@ -35,10 +28,20 @@ const disabledPlugins = new Set(
35
28
 
36
29
  const pluginClasses = {}
37
30
 
31
+ // Subscribe before requiring instrumentations so that loadChannel events fired
32
+ // during instrumentation initialization (e.g. re-requires in bundler contexts)
33
+ // are captured and populate pluginClasses correctly.
38
34
  loadChannel.subscribe(({ name }) => {
39
35
  maybeEnable(plugins[name])
40
36
  })
41
37
 
38
+ // instrument everything that needs Plugin System V2 instrumentation
39
+ require('../../datadog-instrumentations')
40
+ if (getEnvironmentVariable('AWS_LAMBDA_FUNCTION_NAME') !== undefined) {
41
+ // instrument lambda environment
42
+ require('./lambda')
43
+ }
44
+
42
45
  function maybeEnable (Plugin) {
43
46
  if (!Plugin || typeof Plugin !== 'function') return
44
47
  if (!pluginClasses[Plugin.id]) {
@@ -88,8 +88,24 @@ const TEST_EARLY_FLAKE_ABORT_REASON = 'test.early_flake.abort_reason'
88
88
  const TEST_RETRY_REASON = 'test.retry_reason'
89
89
  const TEST_HAS_FAILED_ALL_RETRIES = 'test.has_failed_all_retries'
90
90
  const TEST_IS_MODIFIED = 'test.is_modified'
91
+ const TEST_HAS_DYNAMIC_NAME = '_dd.has_dynamic_name'
91
92
  const CI_APP_ORIGIN = 'ciapp-test'
92
93
 
94
+ // Matches patterns that are almost certainly runtime-generated values in test names:
95
+ // - Unix timestamps in ms (13 digits, years ~2020-2090) or s (10 digits)
96
+ // - UUIDs (8-4-4-4-12 hex)
97
+ // - ISO 8601 dates (2024-03-23) or date-times (2024-03-23T14:30)
98
+ // - Random ports on localhost, 127.0.0.1, or 0.0.0.0
99
+ // - Math.random() float values (10+ decimal digits after 0.)
100
+ const DYNAMIC_NAME_RE = new RegExp(
101
+ String.raw`\b1[6-9]\d{8,11}\b|` +
102
+ String.raw`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|` +
103
+ String.raw`\b\d{4}-\d{2}-\d{2}|` +
104
+ String.raw`(?:localhost|127\.0\.0\.1|0\.0\.0\.0):\d{4,5}\b|` +
105
+ String.raw`\b0\.\d{10,}`,
106
+ 'i'
107
+ )
108
+
93
109
  const JEST_TEST_RUNNER = 'test.jest.test_runner'
94
110
  const JEST_DISPLAY_NAME = 'test.jest.display_name'
95
111
 
@@ -260,6 +276,7 @@ module.exports = {
260
276
  TEST_RETRY_REASON,
261
277
  TEST_HAS_FAILED_ALL_RETRIES,
262
278
  TEST_IS_MODIFIED,
279
+ TEST_HAS_DYNAMIC_NAME,
263
280
  getTestEnvironmentMetadata,
264
281
  getTestParametersString,
265
282
  finishAllTraceSpans,
@@ -337,6 +354,9 @@ module.exports = {
337
354
  POSSIBLE_BASE_BRANCHES,
338
355
  GIT_COMMIT_SHA,
339
356
  GIT_REPOSITORY_URL,
357
+ DYNAMIC_NAME_RE,
358
+ collectDynamicNamesFromTraces,
359
+ logDynamicNamesWarning,
340
360
  }
341
361
 
342
362
  // Returns pkg manager and its version, separated by '-', e.g. npm-8.15.0 or yarn-1.22.19
@@ -1178,6 +1198,62 @@ function getModifiedFilesFromDiff (diff) {
1178
1198
  return result
1179
1199
  }
1180
1200
 
1201
+ /**
1202
+ * Scans serialized worker trace payloads for tests tagged with TEST_HAS_DYNAMIC_NAME
1203
+ * and populates the provided Set. Silently ignores parse errors.
1204
+ *
1205
+ * @param {string} data - JSON-serialized traces from a worker
1206
+ * @param {Set<string>} newTestsWithDynamicNames - Set to populate with "suite › name" strings
1207
+ */
1208
+ function collectDynamicNamesFromTraces (data, newTestsWithDynamicNames) {
1209
+ try {
1210
+ const traces = JSON.parse(data)
1211
+ for (const trace of traces) {
1212
+ for (const span of trace) {
1213
+ if (span.meta?.[TEST_HAS_DYNAMIC_NAME] === 'true') {
1214
+ const suite = span.meta[TEST_SUITE]
1215
+ const name = span.meta[TEST_NAME]
1216
+ if (suite && name) {
1217
+ newTestsWithDynamicNames.add(`${suite} › ${name}`)
1218
+ }
1219
+ }
1220
+ }
1221
+ }
1222
+ } catch {
1223
+ // ignore parse errors
1224
+ }
1225
+ }
1226
+
1227
+ /**
1228
+ * Logs a "Datadog Test Optimization" warning about new tests with dynamic names.
1229
+ * Clears the Set after logging. No-op if the Set is empty.
1230
+ *
1231
+ * @param {Set<string>} newTestsWithDynamicNames
1232
+ */
1233
+ function logDynamicNamesWarning (newTestsWithDynamicNames) {
1234
+ if (newTestsWithDynamicNames.size === 0) return
1235
+
1236
+ const MAX_SHOWN = 10
1237
+ const names = [...newTestsWithDynamicNames]
1238
+ const shown = names.slice(0, MAX_SHOWN)
1239
+ const more = names.length - shown.length
1240
+ const moreSuffix = more > 0 ? `\n ... and ${more} more` : ''
1241
+ const nameList = shown.map(n => ` • ${n}`).join('\n') + moreSuffix
1242
+
1243
+ const line = '-'.repeat(50)
1244
+ // eslint-disable-next-line no-console -- Intentional user-facing session summary
1245
+ console.warn(
1246
+ `\n${line}\nDatadog Test Optimization\n${line}\n` +
1247
+ `${newTestsWithDynamicNames.size} test(s) detected as new but their names contain ` +
1248
+ 'dynamic data (timestamps, UUIDs, etc.).\n' +
1249
+ 'Tests with changing names are always treated as new on every run, ' +
1250
+ 'causing unnecessary Early Flake Detection retries and preventing correct new test detection.\n' +
1251
+ 'Consider using stable, deterministic test names.\n\n' +
1252
+ `${nameList}\n`
1253
+ )
1254
+ newTestsWithDynamicNames.clear()
1255
+ }
1256
+
1181
1257
  function isModifiedTest (testPath, testStartLine, testEndLine, modifiedFiles, testFramework) {
1182
1258
  if (modifiedFiles === undefined) {
1183
1259
  return false
@@ -37,13 +37,16 @@ const {
37
37
  const DEFAULT_KEY = 'service:,env:'
38
38
 
39
39
  /**
40
- * Formats a sampling rate as a string with up to 6 significant digits and no trailing zeros.
40
+ * Formats a sampling rate as a string with up to 6 decimal digits and no trailing zeros.
41
41
  *
42
42
  * @param {number} rate
43
- * @returns {string}
44
43
  */
45
44
  function formatKnuthRate (rate) {
46
- return Number(rate.toPrecision(6)).toString()
45
+ const string = Number(rate).toFixed(6)
46
+ for (let i = string.length - 1; i > 0; i--) {
47
+ if (string[i] === '0') continue
48
+ return string.slice(0, i + (string[i] === '.' ? 0 : 1))
49
+ }
47
50
  }
48
51
 
49
52
  const defaultSampler = new Sampler(AUTO_KEEP)