dd-trace 5.115.0 → 5.117.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/README.electron.md +7 -0
- package/README.md +17 -0
- package/ci/diagnose.js +2100 -0
- package/ci/init.js +23 -2
- package/ci/runbook.md +198 -0
- package/ci/test-optimization-validation/approval-artifacts.js +143 -0
- package/ci/test-optimization-validation/approval.js +299 -0
- package/ci/test-optimization-validation/artifact-id.js +20 -0
- package/ci/test-optimization-validation/bounded-json.js +121 -0
- package/ci/test-optimization-validation/ci-command-candidate.js +83 -0
- package/ci/test-optimization-validation/ci-discovery.js +181 -0
- package/ci/test-optimization-validation/ci-remediation.js +210 -0
- package/ci/test-optimization-validation/cli.js +903 -0
- package/ci/test-optimization-validation/command-blocker.js +81 -0
- package/ci/test-optimization-validation/command-output-policy.js +206 -0
- package/ci/test-optimization-validation/command-runner.js +707 -0
- package/ci/test-optimization-validation/command-suitability.js +471 -0
- package/ci/test-optimization-validation/executable-approval.js +48 -0
- package/ci/test-optimization-validation/executable.js +480 -0
- package/ci/test-optimization-validation/generated-file-policy.js +63 -0
- package/ci/test-optimization-validation/generated-files.js +351 -0
- package/ci/test-optimization-validation/generated-verifier.js +196 -0
- package/ci/test-optimization-validation/init-probe-preload.js +163 -0
- package/ci/test-optimization-validation/init-probe.js +246 -0
- package/ci/test-optimization-validation/late-initialization.js +63 -0
- package/ci/test-optimization-validation/local-command.js +163 -0
- package/ci/test-optimization-validation/manifest-loader.js +133 -0
- package/ci/test-optimization-validation/manifest-scaffold.js +738 -0
- package/ci/test-optimization-validation/manifest-schema.js +862 -0
- package/ci/test-optimization-validation/offline-fixtures.js +327 -0
- package/ci/test-optimization-validation/offline-output.js +406 -0
- package/ci/test-optimization-validation/payload-normalizer.js +72 -0
- package/ci/test-optimization-validation/plan-writer.js +1120 -0
- package/ci/test-optimization-validation/preflight-runner.js +114 -0
- package/ci/test-optimization-validation/redaction.js +331 -0
- package/ci/test-optimization-validation/report-writer.js +1508 -0
- package/ci/test-optimization-validation/safe-files.js +203 -0
- package/ci/test-optimization-validation/scenarios/auto-test-retries.js +159 -0
- package/ci/test-optimization-validation/scenarios/basic-reporting.js +657 -0
- package/ci/test-optimization-validation/scenarios/ci-wiring.js +780 -0
- package/ci/test-optimization-validation/scenarios/early-flake-detection.js +141 -0
- package/ci/test-optimization-validation/scenarios/helpers.js +539 -0
- package/ci/test-optimization-validation/scenarios/test-management.js +218 -0
- package/ci/test-optimization-validation/setup-runner.js +97 -0
- package/ci/test-optimization-validation/static-diagnosis.js +159 -0
- package/ci/test-optimization-validation/test-output.js +129 -0
- package/ci/test-optimization-validation-manifest.schema.json +1 -0
- package/ci/validate-test-optimization.js +3 -0
- package/ext/exporters.js +1 -0
- package/index.d.ts +121 -8
- package/init.js +18 -0
- package/initialize.mjs +1 -1
- package/loader-hook.mjs +28 -18
- package/openfeature.d.ts +1 -0
- package/openfeature.js +4 -0
- package/package.json +11 -8
- package/packages/datadog-instrumentations/src/ai.js +8 -2
- package/packages/datadog-instrumentations/src/child_process.js +1 -1
- package/packages/datadog-instrumentations/src/claude-agent-sdk.js +1 -1
- package/packages/datadog-instrumentations/src/cucumber-worker-threads.js +9 -3
- package/packages/datadog-instrumentations/src/cucumber.js +31 -19
- package/packages/datadog-instrumentations/src/cypress-config.js +398 -52
- package/packages/datadog-instrumentations/src/fastify.js +7 -11
- package/packages/datadog-instrumentations/src/helpers/channel.js +74 -0
- package/packages/datadog-instrumentations/src/helpers/hook.js +27 -6
- package/packages/datadog-instrumentations/src/helpers/optional-peer-bundler.js +8 -8
- package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/playwright.js +28 -2
- package/packages/datadog-instrumentations/src/jest.js +44 -17
- package/packages/datadog-instrumentations/src/mariadb.js +1 -1
- package/packages/datadog-instrumentations/src/mocha/main.js +79 -32
- package/packages/datadog-instrumentations/src/mocha/utils.js +0 -4
- package/packages/datadog-instrumentations/src/mongodb.js +3 -3
- package/packages/datadog-instrumentations/src/mongoose.js +3 -3
- package/packages/datadog-instrumentations/src/mquery.js +2 -2
- package/packages/datadog-instrumentations/src/mysql.js +1 -1
- package/packages/datadog-instrumentations/src/next.js +24 -0
- package/packages/datadog-instrumentations/src/nyc.js +0 -2
- package/packages/datadog-instrumentations/src/oracledb.js +2 -2
- package/packages/datadog-instrumentations/src/pg.js +2 -2
- package/packages/datadog-instrumentations/src/playwright.js +162 -46
- package/packages/datadog-instrumentations/src/process.js +3 -0
- package/packages/datadog-instrumentations/src/router.js +18 -8
- package/packages/datadog-instrumentations/src/selenium.js +52 -26
- package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +3 -3
- package/packages/datadog-instrumentations/src/vitest-main.js +14 -28
- package/packages/datadog-instrumentations/src/vitest-util.js +0 -7
- package/packages/datadog-instrumentations/src/vitest-worker.js +5 -8
- package/packages/datadog-instrumentations/src/ws.js +2 -1
- package/packages/datadog-plugin-aws-durable-execution-sdk-js/src/context.js +2 -2
- package/packages/datadog-plugin-aws-durable-execution-sdk-js/src/util.js +2 -1
- package/packages/datadog-plugin-aws-sdk/src/util.js +2 -2
- package/packages/datadog-plugin-cucumber/src/index.js +3 -2
- package/packages/datadog-plugin-cypress/src/cypress-plugin.js +19 -57
- package/packages/datadog-plugin-cypress/src/index.js +6 -1
- package/packages/datadog-plugin-cypress/src/support.js +92 -26
- package/packages/datadog-plugin-electron/src/ipc.js +1 -1
- package/packages/datadog-plugin-graphql/src/execute.js +87 -45
- package/packages/datadog-plugin-graphql/src/validate.js +2 -2
- package/packages/datadog-plugin-http2/src/client.js +1 -1
- package/packages/datadog-plugin-langchain/src/handlers/embedding.js +2 -1
- package/packages/datadog-plugin-langchain/src/handlers/language_models.js +2 -1
- package/packages/datadog-plugin-langchain/src/tokens.js +2 -2
- package/packages/datadog-plugin-mocha/src/index.js +2 -1
- package/packages/datadog-plugin-playwright/src/index.js +181 -65
- package/packages/datadog-plugin-router/src/index.js +11 -2
- package/packages/datadog-plugin-selenium/src/index.js +5 -36
- package/packages/datadog-plugin-vitest/src/index.js +5 -5
- package/packages/datadog-plugin-ws/src/close.js +2 -1
- package/packages/datadog-plugin-ws/src/producer.js +2 -1
- package/packages/datadog-plugin-ws/src/receiver.js +2 -1
- package/packages/datadog-plugin-ws/src/server.js +2 -1
- package/packages/dd-trace/src/aiguard/sdk.js +1 -1
- package/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js +2 -2
- package/packages/dd-trace/src/appsec/iast/telemetry/span-tags.js +3 -1
- package/packages/dd-trace/src/azure_metadata.js +2 -1
- package/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/index.js +93 -28
- package/packages/dd-trace/src/ci-visibility/early-flake-detection/get-known-tests.js +1 -1
- package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/index.js +160 -0
- package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/msgpack-to-json.js +288 -0
- package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/payload-projection.js +84 -0
- package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/sink.js +369 -0
- package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/writer.js +60 -0
- package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +41 -3
- package/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js +7 -2
- package/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +1 -1
- package/packages/dd-trace/src/ci-visibility/requests/upload-coverage-report.js +6 -2
- package/packages/dd-trace/src/ci-visibility/rum.js +7 -0
- package/packages/dd-trace/src/ci-visibility/test-api-manual/test-api-manual-plugin.js +4 -0
- package/packages/dd-trace/src/ci-visibility/test-management/get-test-management-tests.js +1 -1
- package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache-schema.js +137 -9
- package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache.js +168 -8
- package/packages/dd-trace/src/ci-visibility/test-screenshot.js +90 -0
- package/packages/dd-trace/src/config/generated-config-types.d.ts +16 -0
- package/packages/dd-trace/src/config/helper.js +1 -0
- package/packages/dd-trace/src/config/index.js +10 -0
- package/packages/dd-trace/src/config/supported-configurations.json +66 -0
- package/packages/dd-trace/src/exporter.js +2 -0
- package/packages/dd-trace/src/exporters/common/client-library-headers.js +21 -0
- package/packages/dd-trace/src/exporters/common/request.js +59 -30
- package/packages/dd-trace/src/exporters/common/url.js +15 -1
- package/packages/dd-trace/src/feature-registry.js +10 -3
- package/packages/dd-trace/src/llmobs/experiments/client.js +113 -0
- package/packages/dd-trace/src/llmobs/experiments/dataset.js +154 -0
- package/packages/dd-trace/src/llmobs/experiments/experiment.js +283 -0
- package/packages/dd-trace/src/llmobs/experiments/index.js +152 -0
- package/packages/dd-trace/src/llmobs/experiments/noop.js +30 -0
- package/packages/dd-trace/src/llmobs/experiments/result.js +34 -0
- package/packages/dd-trace/src/llmobs/noop.js +6 -0
- package/packages/dd-trace/src/llmobs/plugins/ai/vercelTelemetry.js +20 -3
- package/packages/dd-trace/src/llmobs/plugins/anthropic/index.js +2 -2
- package/packages/dd-trace/src/llmobs/plugins/langgraph/index.js +1 -1
- package/packages/dd-trace/src/llmobs/plugins/openai/constants.js +8 -0
- package/packages/dd-trace/src/llmobs/plugins/openai/index.js +48 -14
- package/packages/dd-trace/src/llmobs/plugins/openai/utils.js +43 -0
- package/packages/dd-trace/src/llmobs/sdk.js +17 -0
- package/packages/dd-trace/src/llmobs/tagger.js +68 -0
- package/packages/dd-trace/src/llmobs/telemetry.js +2 -1
- package/packages/dd-trace/src/llmobs/util.js +32 -0
- package/packages/dd-trace/src/openfeature/agentless_configuration_source.js +322 -0
- package/packages/dd-trace/src/openfeature/configuration_source.js +90 -0
- package/packages/dd-trace/src/openfeature/flagging_provider.js +14 -23
- package/packages/dd-trace/src/openfeature/index.js +0 -2
- package/packages/dd-trace/src/openfeature/noop.js +1 -28
- package/packages/dd-trace/src/openfeature/register.js +16 -27
- package/packages/dd-trace/src/openfeature/remote_config.js +15 -18
- package/packages/dd-trace/src/openfeature/require-provider.js +18 -0
- package/packages/dd-trace/src/opentelemetry/span-ending-hook.js +10 -0
- package/packages/dd-trace/src/opentelemetry/span.js +5 -0
- package/packages/dd-trace/src/plugins/ci_plugin.js +20 -6
- package/packages/dd-trace/src/plugins/util/git.js +3 -2
- package/packages/dd-trace/src/plugins/util/inferred_proxy.js +29 -5
- package/packages/dd-trace/src/plugins/util/test.js +56 -4
- package/packages/dd-trace/src/profiling/profilers/space.js +1 -1
- package/packages/dd-trace/src/profiling/profilers/wall.js +1 -0
- package/packages/dd-trace/src/proxy.js +84 -7
- package/packages/dd-trace/src/span_processor.js +5 -0
- package/packages/dd-trace/src/standalone/index.js +0 -14
- package/packages/dd-trace/src/telemetry/send-data.js +2 -2
- package/packages/dd-trace/src/util.js +26 -0
- package/vendor/dist/@apm-js-collab/code-transformer/index.js +1 -1
- package/vendor/dist/@datadog/sketches-js/index.js +1 -1
- package/vendor/dist/protobufjs/index.js +1 -1
- package/vendor/dist/protobufjs/minimal/index.js +1 -1
- package/vendor/dist/shell-quote/index.js +1 -1
- package/packages/datadog-instrumentations/src/helpers/require-optional-peer.js +0 -17
|
@@ -7,11 +7,13 @@ const {
|
|
|
7
7
|
INSTRUMENTATION_METHOD_AUTO,
|
|
8
8
|
UNKNOWN_MODEL_PROVIDER,
|
|
9
9
|
} = require('../../constants/tags')
|
|
10
|
-
const { safeJsonParse } = require('../../util')
|
|
10
|
+
const { audioMimeTypeFromFormat, formatAudioPart, safeJsonParse } = require('../../util')
|
|
11
|
+
const { AUDIO_MIME_TYPES } = require('./constants')
|
|
11
12
|
const {
|
|
12
13
|
extractChatTemplateFromInstructions,
|
|
13
14
|
normalizePromptVariables,
|
|
14
15
|
extractTextFromContentItem,
|
|
16
|
+
extractContentParts,
|
|
15
17
|
hasMultimodalInputs,
|
|
16
18
|
} = require('./utils')
|
|
17
19
|
|
|
@@ -29,6 +31,22 @@ function isIterable (obj) {
|
|
|
29
31
|
return typeof obj[Symbol.iterator] === 'function'
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
// Flattens multimodal chat input messages (array `content`) into readable text plus structured
|
|
35
|
+
// `audioParts`, leaving plain-string messages untouched. Model-agnostic: keys off message
|
|
36
|
+
// structure, so it works for any audio-capable chat model (gpt-audio*, gpt-4o-audio-preview, ...).
|
|
37
|
+
function flattenChatInputMessages (messages) {
|
|
38
|
+
if (!Array.isArray(messages)) return messages
|
|
39
|
+
|
|
40
|
+
return messages.map(message => {
|
|
41
|
+
if (!Array.isArray(message?.content)) return message
|
|
42
|
+
|
|
43
|
+
const { content, audioParts } = extractContentParts(message.content)
|
|
44
|
+
const flattenedMessage = { ...message, content }
|
|
45
|
+
if (audioParts.length) flattenedMessage.audioParts = audioParts
|
|
46
|
+
return flattenedMessage
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
class OpenAiLLMObsPlugin extends LLMObsPlugin {
|
|
33
51
|
static id = 'openai'
|
|
34
52
|
static integration = 'openai'
|
|
@@ -193,47 +211,63 @@ class OpenAiLLMObsPlugin extends LLMObsPlugin {
|
|
|
193
211
|
|
|
194
212
|
this._tagger.tagMetadata(span, metadata)
|
|
195
213
|
|
|
214
|
+
const inputMessages = flattenChatInputMessages(messages)
|
|
215
|
+
|
|
196
216
|
if (error) {
|
|
197
|
-
this._tagger.tagLLMIO(span,
|
|
217
|
+
this._tagger.tagLLMIO(span, inputMessages, [{ content: '' }])
|
|
198
218
|
return
|
|
199
219
|
}
|
|
200
220
|
|
|
201
221
|
const outputMessages = []
|
|
202
222
|
const { choices } = response
|
|
203
223
|
if (!isIterable(choices)) {
|
|
204
|
-
this._tagger.tagLLMIO(span,
|
|
224
|
+
this._tagger.tagLLMIO(span, inputMessages, [{ content: '' }])
|
|
205
225
|
return
|
|
206
226
|
}
|
|
207
227
|
|
|
228
|
+
// Output audio (non-streamed) is returned in the requested format; chat-completions
|
|
229
|
+
const outputAudioFormat = inputs.audio?.format
|
|
230
|
+
|
|
208
231
|
for (const choice of choices) {
|
|
209
232
|
const message = choice.message || choice.delta
|
|
210
|
-
|
|
233
|
+
let content = message.content || ''
|
|
211
234
|
const role = message.role
|
|
212
235
|
|
|
236
|
+
const audio = message.audio
|
|
237
|
+
let audioParts
|
|
238
|
+
if (audio) {
|
|
239
|
+
if (audio.data) {
|
|
240
|
+
audioParts = [formatAudioPart(audio.data, audioMimeTypeFromFormat(outputAudioFormat, AUDIO_MIME_TYPES))]
|
|
241
|
+
}
|
|
242
|
+
// gpt-audio* / gpt-4o-audio-preview return null content; surface the transcript as text.
|
|
243
|
+
if (!content) content = audio.transcript || ''
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const outputMessage = { content, role }
|
|
247
|
+
if (audioParts) outputMessage.audioParts = audioParts
|
|
248
|
+
|
|
213
249
|
if (message.function_call) {
|
|
214
|
-
|
|
250
|
+
outputMessage.toolCalls = [{
|
|
215
251
|
name: message.function_call.name,
|
|
216
252
|
arguments: safeJsonParse(message.function_call.arguments),
|
|
217
|
-
}
|
|
218
|
-
outputMessages.push({ content, role, toolCalls: [functionCallInfo] })
|
|
253
|
+
}]
|
|
219
254
|
} else if (message.tool_calls) {
|
|
220
255
|
const toolCallsInfo = []
|
|
221
256
|
for (const toolCall of message.tool_calls) {
|
|
222
|
-
|
|
257
|
+
toolCallsInfo.push({
|
|
223
258
|
arguments: safeJsonParse(toolCall.function.arguments),
|
|
224
259
|
name: toolCall.function.name,
|
|
225
260
|
toolId: toolCall.id,
|
|
226
261
|
type: toolCall.type,
|
|
227
|
-
}
|
|
228
|
-
toolCallsInfo.push(toolCallInfo)
|
|
262
|
+
})
|
|
229
263
|
}
|
|
230
|
-
|
|
231
|
-
} else {
|
|
232
|
-
outputMessages.push({ content, role })
|
|
264
|
+
outputMessage.toolCalls = toolCallsInfo
|
|
233
265
|
}
|
|
266
|
+
|
|
267
|
+
outputMessages.push(outputMessage)
|
|
234
268
|
}
|
|
235
269
|
|
|
236
|
-
this._tagger.tagLLMIO(span,
|
|
270
|
+
this._tagger.tagLLMIO(span, inputMessages, outputMessages)
|
|
237
271
|
}
|
|
238
272
|
|
|
239
273
|
#tagResponse (span, inputs, response, error) {
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const { audioMimeTypeFromFormat, formatAudioPart } = require('../../util')
|
|
3
4
|
const {
|
|
4
5
|
INPUT_TYPE_IMAGE,
|
|
5
6
|
INPUT_TYPE_FILE,
|
|
6
7
|
IMAGE_FALLBACK,
|
|
7
8
|
FILE_FALLBACK,
|
|
9
|
+
AUDIO_FALLBACK,
|
|
10
|
+
AUDIO_MIME_TYPES,
|
|
8
11
|
} = require('./constants')
|
|
9
12
|
|
|
10
13
|
const REGEX_SPECIAL_CHARS = /[.*+?^${}()|[\]\\]/g
|
|
@@ -118,9 +121,49 @@ function hasMultimodalInputs (variables) {
|
|
|
118
121
|
)
|
|
119
122
|
}
|
|
120
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Flattens an array of OpenAI chat message content parts into readable text plus structured audio.
|
|
126
|
+
*
|
|
127
|
+
* Text parts are concatenated (newline-joined),
|
|
128
|
+
* images collapse to an `[image]` marker, and `input_audio` parts with data are captured as audio
|
|
129
|
+
* parts (rendered as a player). The `[audio]` marker is only emitted as a fallback when an audio
|
|
130
|
+
* part carries no data.
|
|
131
|
+
*
|
|
132
|
+
* @param {Array<object>} parts - Array of content parts from a chat message `content`
|
|
133
|
+
* @returns {{ content: string, audioParts: Array<{ mimeType: string, content: string }> }}
|
|
134
|
+
*/
|
|
135
|
+
function extractContentParts (parts) {
|
|
136
|
+
const extracted = []
|
|
137
|
+
const audioParts = []
|
|
138
|
+
|
|
139
|
+
for (const part of parts) {
|
|
140
|
+
const partType = part?.type ?? ''
|
|
141
|
+
if (partType === 'text') {
|
|
142
|
+
extracted.push(part.text ?? '')
|
|
143
|
+
} else if (partType === 'image_url') {
|
|
144
|
+
extracted.push(IMAGE_FALLBACK)
|
|
145
|
+
} else if (partType === 'input_audio') {
|
|
146
|
+
const inputAudio = part.input_audio ?? {}
|
|
147
|
+
const data = inputAudio.data
|
|
148
|
+
if (data) {
|
|
149
|
+
// Audio is captured as a structured audio part (rendered as a player), so no text marker
|
|
150
|
+
// is needed. Only fall back to "[audio]" when there's no audio to capture.
|
|
151
|
+
audioParts.push(formatAudioPart(data, audioMimeTypeFromFormat(inputAudio.format, AUDIO_MIME_TYPES)))
|
|
152
|
+
} else {
|
|
153
|
+
extracted.push(AUDIO_FALLBACK)
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
extracted.push(`[${partType}]`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { content: extracted.join('\n'), audioParts }
|
|
161
|
+
}
|
|
162
|
+
|
|
121
163
|
module.exports = {
|
|
122
164
|
extractChatTemplateFromInstructions,
|
|
123
165
|
normalizePromptVariables,
|
|
124
166
|
extractTextFromContentItem,
|
|
167
|
+
extractContentParts,
|
|
125
168
|
hasMultimodalInputs,
|
|
126
169
|
}
|
|
@@ -19,6 +19,7 @@ const {
|
|
|
19
19
|
const { storage } = require('./storage')
|
|
20
20
|
const telemetry = require('./telemetry')
|
|
21
21
|
const LLMObsTagger = require('./tagger')
|
|
22
|
+
const { createExperiments } = require('./experiments')
|
|
22
23
|
|
|
23
24
|
// communicating with writer
|
|
24
25
|
const evalMetricAppendCh = channel('llmobs:eval-metric:append')
|
|
@@ -33,6 +34,12 @@ class LLMObs extends NoopLLMObs {
|
|
|
33
34
|
*/
|
|
34
35
|
#hasUserSpanProcessor = false
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Lazily-created experiments facade (see ./experiments).
|
|
39
|
+
* @type {import('./experiments').Experiments | undefined}
|
|
40
|
+
*/
|
|
41
|
+
#experiments
|
|
42
|
+
|
|
36
43
|
/**
|
|
37
44
|
* @param {import('../tracer')} tracer - Tracer instance
|
|
38
45
|
* @param {import('./index')} llmobsModule - LLMObs module instance
|
|
@@ -52,6 +59,16 @@ class LLMObs extends NoopLLMObs {
|
|
|
52
59
|
return this._config.llmobs.DD_LLMOBS_ENABLED ?? false
|
|
53
60
|
}
|
|
54
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Datasets & Experiments API. Requires LLM Observability to be enabled and
|
|
64
|
+
* DD_API_KEY / DD_APP_KEY to be set; otherwise the returned facade throws with
|
|
65
|
+
* a clear message on use.
|
|
66
|
+
*/
|
|
67
|
+
get experiments () {
|
|
68
|
+
this.#experiments ??= createExperiments(this._config)
|
|
69
|
+
return this.#experiments
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
enable (options = {}) {
|
|
56
73
|
logger.warn(
|
|
57
74
|
'Enabling LLM Observability via `llmobs.enable()` is deprecated and will be removed in dd-trace@7.0.0. ' +
|
|
@@ -618,6 +618,65 @@ class LLMObsTagger {
|
|
|
618
618
|
return filteredToolResults
|
|
619
619
|
}
|
|
620
620
|
|
|
621
|
+
// Validates audio segments on a message and emits the snake_case wire shape
|
|
622
|
+
// `{ mime_type, content | attachment_key }`. Mirrors dd-trace-py's
|
|
623
|
+
// `_extract_audio_part`: a part requires `mimeType` and exactly one of
|
|
624
|
+
// `content` (inline base64) or `attachmentKey` (backend-offloaded).
|
|
625
|
+
#filterAudioParts (audioParts) {
|
|
626
|
+
if (!Array.isArray(audioParts)) {
|
|
627
|
+
audioParts = [audioParts]
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const filteredAudioParts = []
|
|
631
|
+
for (const audioPart of audioParts) {
|
|
632
|
+
if (audioPart == null || typeof audioPart !== 'object') {
|
|
633
|
+
this.#handleFailure('Audio part must be an object.', 'invalid_io_messages')
|
|
634
|
+
continue
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const { mimeType, content, attachmentKey } = audioPart
|
|
638
|
+
|
|
639
|
+
if (typeof mimeType !== 'string' || !mimeType) {
|
|
640
|
+
this.#handleFailure('Audio part mimeType must be a non-empty string.', 'invalid_io_messages')
|
|
641
|
+
continue
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (content == null && attachmentKey == null) {
|
|
645
|
+
this.#handleFailure("Audio part must have either 'content' or 'attachmentKey'.", 'invalid_io_messages')
|
|
646
|
+
continue
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (content != null && attachmentKey != null) {
|
|
650
|
+
this.#handleFailure(
|
|
651
|
+
"Audio part must have only one of 'content' or 'attachmentKey', not both.", 'invalid_io_messages'
|
|
652
|
+
)
|
|
653
|
+
continue
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const audioPartObj = { mime_type: mimeType }
|
|
657
|
+
|
|
658
|
+
// Exactly one of content / attachmentKey is set here (guarded above). Validate its type
|
|
659
|
+
// explicitly so the failure carries the `invalid_io_messages` tag for telemetry, instead
|
|
660
|
+
// of routing through `#tagConditionalString` which omits it.
|
|
661
|
+
if (content == null) {
|
|
662
|
+
if (typeof attachmentKey !== 'string') {
|
|
663
|
+
this.#handleFailure('Audio part attachmentKey must be a string.', 'invalid_io_messages')
|
|
664
|
+
continue
|
|
665
|
+
}
|
|
666
|
+
audioPartObj.attachment_key = attachmentKey
|
|
667
|
+
} else {
|
|
668
|
+
if (typeof content !== 'string') {
|
|
669
|
+
this.#handleFailure('Audio part content must be a base64-encoded string.', 'invalid_io_messages')
|
|
670
|
+
continue
|
|
671
|
+
}
|
|
672
|
+
audioPartObj.content = content
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
filteredAudioParts.push(audioPartObj)
|
|
676
|
+
}
|
|
677
|
+
return filteredAudioParts
|
|
678
|
+
}
|
|
679
|
+
|
|
621
680
|
#tagMessages (span, data, key) {
|
|
622
681
|
if (!data) {
|
|
623
682
|
return
|
|
@@ -644,6 +703,7 @@ class LLMObsTagger {
|
|
|
644
703
|
toolCalls,
|
|
645
704
|
toolResults,
|
|
646
705
|
toolId,
|
|
706
|
+
audioParts,
|
|
647
707
|
} = message
|
|
648
708
|
const messageObj = {}
|
|
649
709
|
|
|
@@ -677,6 +737,14 @@ class LLMObsTagger {
|
|
|
677
737
|
}
|
|
678
738
|
}
|
|
679
739
|
|
|
740
|
+
if (audioParts != null) {
|
|
741
|
+
const filteredAudioParts = this.#filterAudioParts(audioParts)
|
|
742
|
+
|
|
743
|
+
if (filteredAudioParts.length) {
|
|
744
|
+
messageObj.audio_parts = filteredAudioParts
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
680
748
|
if (toolId) {
|
|
681
749
|
if (role === 'tool') {
|
|
682
750
|
condition = this.#tagConditionalString(toolId, 'Tool ID', messageObj, 'tool_id') && condition
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const ERROR_TYPE = require('../constants')
|
|
4
4
|
|
|
5
5
|
const telemetryMetrics = require('../telemetry/metrics')
|
|
6
|
+
const { getSegment } = require('../util')
|
|
6
7
|
const {
|
|
7
8
|
SPAN_KIND,
|
|
8
9
|
MODEL_PROVIDER,
|
|
@@ -22,7 +23,7 @@ function extractIntegrationFromTags (tags) {
|
|
|
22
23
|
if (!Array.isArray(tags)) return null
|
|
23
24
|
const integrationTag = tags.find(tag => tag.startsWith('integration:'))
|
|
24
25
|
if (!integrationTag) return null
|
|
25
|
-
return integrationTag
|
|
26
|
+
return getSegment(integrationTag, ':', 1) || null
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
function extractTagsFromSpanEvent (event) {
|
|
@@ -362,9 +362,41 @@ function findGenAIAncestorSpanId (span) {
|
|
|
362
362
|
return null
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
+
// Maps an audio `format` (e.g. "wav", "mp3") to a MIME type. Defaults to `audio/wav` when the
|
|
366
|
+
// format is missing. Provider-specific overrides (e.g. OpenAI's mp3 -> audio/mpeg) are passed in
|
|
367
|
+
// via `mimeTypeLookup` so this stays provider-agnostic. A non-string `format` is treated as missing
|
|
368
|
+
// so a malformed auto-instrumented payload can't throw and disable the plugin.
|
|
369
|
+
/**
|
|
370
|
+
* @param {string} fmt
|
|
371
|
+
* @param {Record<string, string>} [mimeTypeLookup]
|
|
372
|
+
* @returns {string}
|
|
373
|
+
*/
|
|
374
|
+
function audioMimeTypeFromFormat (fmt, mimeTypeLookup = {}) {
|
|
375
|
+
fmt = typeof fmt === 'string' ? fmt.trim().toLowerCase() : ''
|
|
376
|
+
if (!fmt) return 'audio/wav'
|
|
377
|
+
return mimeTypeLookup[fmt] ?? `audio/${fmt}`
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Builds an audio part from raw audio bytes (base64-encoded) or an existing base64 string. Only
|
|
381
|
+
// Buffer/Uint8Array inputs are base64-encoded; any other shape is passed through so a malformed
|
|
382
|
+
// auto-instrumented payload can't throw (the tagger soft-skips a non-string `content`).
|
|
383
|
+
/**
|
|
384
|
+
* @param {Buffer | Uint8Array | string} data
|
|
385
|
+
* @param {string} mimeType
|
|
386
|
+
* @returns {{ mimeType: string, content: string }}
|
|
387
|
+
*/
|
|
388
|
+
function formatAudioPart (data, mimeType) {
|
|
389
|
+
const content = Buffer.isBuffer(data) || ArrayBuffer.isView(data)
|
|
390
|
+
? Buffer.from(data).toString('base64')
|
|
391
|
+
: data
|
|
392
|
+
return { mimeType, content }
|
|
393
|
+
}
|
|
394
|
+
|
|
365
395
|
module.exports = {
|
|
396
|
+
audioMimeTypeFromFormat,
|
|
366
397
|
encodeUnicode,
|
|
367
398
|
findGenAIAncestorSpanId,
|
|
399
|
+
formatAudioPart,
|
|
368
400
|
validateCostTags,
|
|
369
401
|
validateKind,
|
|
370
402
|
getFunctionArguments,
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/* eslint-disable no-await-in-loop -- Polls and retries must remain sequential. */
|
|
4
|
+
|
|
5
|
+
const { setTimeout: sleep } = require('node:timers/promises')
|
|
6
|
+
|
|
7
|
+
const request = require('../exporters/common/request')
|
|
8
|
+
const { getClientLibraryHeaders } = require('../exporters/common/client-library-headers')
|
|
9
|
+
const log = require('../log')
|
|
10
|
+
|
|
11
|
+
const MAX_ATTEMPTS = 3
|
|
12
|
+
const FIRST_RETRY_MIN_MS = 2000
|
|
13
|
+
const FIRST_RETRY_MAX_MS = 10_000
|
|
14
|
+
const SECOND_RETRY_MIN_MS = 5000
|
|
15
|
+
const SECOND_RETRY_MAX_MS = 30_000
|
|
16
|
+
const RETRY_JITTER = 0.2
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} AgentlessSourceConfig
|
|
20
|
+
* @property {URL} endpoint
|
|
21
|
+
* @property {number} pollIntervalMs
|
|
22
|
+
* @property {number} requestTimeoutMs
|
|
23
|
+
* @property {string} apiKey
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {import('@datadog/openfeature-node-server').UniversalFlagConfigurationV1} UniversalFlagConfiguration
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} PollResponse
|
|
32
|
+
* @property {Error | null | undefined} error
|
|
33
|
+
* @property {string | undefined} body
|
|
34
|
+
* @property {number | undefined} statusCode
|
|
35
|
+
* @property {import('node:http').IncomingHttpHeaders | undefined} headers
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
class AgentlessConfigurationSource {
|
|
39
|
+
/** @type {AbortController | undefined} */
|
|
40
|
+
#abortController
|
|
41
|
+
|
|
42
|
+
/** @type {(configuration: UniversalFlagConfiguration) => void} */
|
|
43
|
+
#applyConfiguration
|
|
44
|
+
|
|
45
|
+
#applicationFailureLogged = false
|
|
46
|
+
|
|
47
|
+
/** @type {AgentlessSourceConfig} */
|
|
48
|
+
#config
|
|
49
|
+
|
|
50
|
+
/** @type {string | undefined} */
|
|
51
|
+
#etag
|
|
52
|
+
|
|
53
|
+
/** @type {Set<string>} */
|
|
54
|
+
#failureWarnings = new Set()
|
|
55
|
+
|
|
56
|
+
#malformedPayloadLogged = false
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {AgentlessSourceConfig} config
|
|
60
|
+
* @param {(configuration: UniversalFlagConfiguration) => void} applyConfiguration
|
|
61
|
+
*/
|
|
62
|
+
constructor (config, applyConfiguration) {
|
|
63
|
+
this.#config = config
|
|
64
|
+
this.#applyConfiguration = applyConfiguration
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @returns {void}
|
|
69
|
+
*/
|
|
70
|
+
start () {
|
|
71
|
+
if (this.#abortController) return
|
|
72
|
+
|
|
73
|
+
const abortController = new AbortController()
|
|
74
|
+
this.#abortController = abortController
|
|
75
|
+
this.#poll(abortController)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @returns {void}
|
|
80
|
+
*/
|
|
81
|
+
stop () {
|
|
82
|
+
this.#abortController?.abort()
|
|
83
|
+
this.#abortController = undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {AbortController} abortController
|
|
88
|
+
* @returns {Promise<void>}
|
|
89
|
+
*/
|
|
90
|
+
async #poll (abortController) {
|
|
91
|
+
try {
|
|
92
|
+
do {
|
|
93
|
+
await this.#pollOnce(abortController)
|
|
94
|
+
} while (
|
|
95
|
+
this.#abortController === abortController &&
|
|
96
|
+
await wait(this.#config.pollIntervalMs, abortController.signal)
|
|
97
|
+
)
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (this.#abortController === abortController) {
|
|
100
|
+
this.#warnFailure(undefined, error, 1)
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
if (this.#abortController === abortController) {
|
|
104
|
+
this.#abortController = undefined
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {AbortController} abortController
|
|
111
|
+
* @returns {Promise<void>}
|
|
112
|
+
*/
|
|
113
|
+
async #pollOnce (abortController) {
|
|
114
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
115
|
+
const response = await this.#request(abortController.signal)
|
|
116
|
+
if (this.#abortController !== abortController) return
|
|
117
|
+
|
|
118
|
+
const retryable = response.statusCode === undefined || isRetryableStatus(response.statusCode)
|
|
119
|
+
if (!retryable) {
|
|
120
|
+
this.#apply(response)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (attempt === MAX_ATTEMPTS) {
|
|
125
|
+
this.#warnFailure(response.statusCode, response.error, attempt)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const delay = retryDelay(this.#config.pollIntervalMs, attempt, Math.random())
|
|
130
|
+
if (!await wait(delay, abortController.signal)) return
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {AbortSignal} signal
|
|
136
|
+
* @returns {Promise<PollResponse>}
|
|
137
|
+
*/
|
|
138
|
+
#request (signal) {
|
|
139
|
+
const headers = getClientLibraryHeaders()
|
|
140
|
+
headers['Accept-Encoding'] = 'gzip'
|
|
141
|
+
headers['DD-API-KEY'] = this.#config.apiKey
|
|
142
|
+
if (this.#etag) headers['If-None-Match'] = this.#etag
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {(response: PollResponse) => void} resolve
|
|
146
|
+
*/
|
|
147
|
+
const execute = (resolve) => {
|
|
148
|
+
/**
|
|
149
|
+
* @param {Error | null} error
|
|
150
|
+
* @param {string | import('node:http').IncomingMessage | null | undefined} body
|
|
151
|
+
* @param {number | undefined} statusCode
|
|
152
|
+
* @param {import('node:http').IncomingHttpHeaders | undefined} responseHeaders
|
|
153
|
+
*/
|
|
154
|
+
const onResponse = (error, body, statusCode, responseHeaders) => {
|
|
155
|
+
resolve({
|
|
156
|
+
error,
|
|
157
|
+
body: typeof body === 'string' ? body : undefined,
|
|
158
|
+
statusCode,
|
|
159
|
+
headers: responseHeaders,
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
request('', {
|
|
164
|
+
url: this.#config.endpoint,
|
|
165
|
+
method: 'GET',
|
|
166
|
+
headers,
|
|
167
|
+
retry: false,
|
|
168
|
+
signal,
|
|
169
|
+
timeout: this.#config.requestTimeoutMs,
|
|
170
|
+
}, onResponse)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return new Promise(execute)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {PollResponse} response
|
|
178
|
+
* @returns {void}
|
|
179
|
+
*/
|
|
180
|
+
#apply (response) {
|
|
181
|
+
const statusCode = response.statusCode
|
|
182
|
+
if (statusCode === 304) return
|
|
183
|
+
|
|
184
|
+
if (statusCode === 401 || statusCode === 403) {
|
|
185
|
+
this.#warnFailure(statusCode, undefined, 1)
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (statusCode !== 200) return
|
|
190
|
+
|
|
191
|
+
let configuration
|
|
192
|
+
try {
|
|
193
|
+
configuration = parseConfiguration(response.body)
|
|
194
|
+
} catch {
|
|
195
|
+
if (!this.#malformedPayloadLogged) {
|
|
196
|
+
this.#malformedPayloadLogged = true
|
|
197
|
+
log.error('Feature Flagging agentless endpoint returned malformed UFC payload')
|
|
198
|
+
}
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
this.#applyConfiguration(configuration)
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (!this.#applicationFailureLogged) {
|
|
206
|
+
this.#applicationFailureLogged = true
|
|
207
|
+
log.warn('Feature Flagging agentless UFC payload could not be applied: %s', errorMessage(error))
|
|
208
|
+
}
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const etag = response.headers?.etag
|
|
213
|
+
const value = Array.isArray(etag) ? etag[0] : etag
|
|
214
|
+
this.#etag = value?.trim() || undefined
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* @param {number | undefined} statusCode
|
|
219
|
+
* @param {unknown} error
|
|
220
|
+
* @param {number} attempts
|
|
221
|
+
* @returns {void}
|
|
222
|
+
*/
|
|
223
|
+
#warnFailure (statusCode, error, attempts) {
|
|
224
|
+
const category = statusCode === 401 || statusCode === 403
|
|
225
|
+
? 'authentication'
|
|
226
|
+
: statusCode ? 'http' : 'request'
|
|
227
|
+
if (this.#failureWarnings.has(category)) return
|
|
228
|
+
this.#failureWarnings.add(category)
|
|
229
|
+
|
|
230
|
+
if (statusCode === 401 || statusCode === 403) {
|
|
231
|
+
log.warn(
|
|
232
|
+
'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid',
|
|
233
|
+
statusCode
|
|
234
|
+
)
|
|
235
|
+
} else if (statusCode) {
|
|
236
|
+
log.warn('Feature Flagging agentless endpoint returned HTTP %d after %d attempts', statusCode, attempts)
|
|
237
|
+
} else if (attempts > 1) {
|
|
238
|
+
log.warn('Feature Flagging agentless request failed after %d attempts: %s', attempts, errorMessage(error))
|
|
239
|
+
} else {
|
|
240
|
+
log.warn('Feature Flagging agentless request failed: %s', errorMessage(error))
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {number} delay
|
|
247
|
+
* @param {AbortSignal} signal
|
|
248
|
+
* @returns {Promise<boolean>}
|
|
249
|
+
*/
|
|
250
|
+
async function wait (delay, signal) {
|
|
251
|
+
try {
|
|
252
|
+
await sleep(delay, undefined, { ref: false, signal })
|
|
253
|
+
return true
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (error?.name === 'AbortError') return false
|
|
256
|
+
throw error
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* @param {string | undefined} body
|
|
262
|
+
* @returns {UniversalFlagConfiguration}
|
|
263
|
+
*/
|
|
264
|
+
function parseConfiguration (body) {
|
|
265
|
+
const { data } = JSON.parse(body)
|
|
266
|
+
if (data?.type !== 'universal-flag-configuration') {
|
|
267
|
+
throw new Error('Expected a JSON:API Universal Flag Configuration resource')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const { attributes } = data
|
|
271
|
+
if (typeof attributes?.format !== 'string' ||
|
|
272
|
+
typeof attributes.createdAt !== 'string' ||
|
|
273
|
+
typeof attributes.environment?.name !== 'string' ||
|
|
274
|
+
!attributes.flags ||
|
|
275
|
+
typeof attributes.flags !== 'object' ||
|
|
276
|
+
Array.isArray(attributes.flags)) {
|
|
277
|
+
throw new Error('Expected a Universal Flag Configuration v1 object')
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return attributes
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* @param {unknown} error
|
|
285
|
+
* @returns {string}
|
|
286
|
+
*/
|
|
287
|
+
function errorMessage (error) {
|
|
288
|
+
return error instanceof Error ? error.message : String(error ?? 'request was not sent')
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* @param {number | undefined} status
|
|
293
|
+
* @returns {boolean}
|
|
294
|
+
*/
|
|
295
|
+
function isRetryableStatus (status) {
|
|
296
|
+
return status === 408 || status === 429 || (status >= 500 && status <= 599)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* @param {number} pollIntervalMs
|
|
301
|
+
* @param {number} attempt
|
|
302
|
+
* @param {number} random
|
|
303
|
+
* @returns {number}
|
|
304
|
+
*/
|
|
305
|
+
function retryDelay (pollIntervalMs, attempt, random) {
|
|
306
|
+
const base = attempt === 1
|
|
307
|
+
? clamp(pollIntervalMs / 6, FIRST_RETRY_MIN_MS, FIRST_RETRY_MAX_MS)
|
|
308
|
+
: clamp(pollIntervalMs / 3, SECOND_RETRY_MIN_MS, SECOND_RETRY_MAX_MS)
|
|
309
|
+
return Math.max(1, Math.round(base * (1 - RETRY_JITTER + random * RETRY_JITTER * 2)))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* @param {number} value
|
|
314
|
+
* @param {number} minimum
|
|
315
|
+
* @param {number} maximum
|
|
316
|
+
* @returns {number}
|
|
317
|
+
*/
|
|
318
|
+
function clamp (value, minimum, maximum) {
|
|
319
|
+
return Math.max(minimum, Math.min(maximum, value))
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
module.exports = AgentlessConfigurationSource
|