dd-trace 6.21.0 → 6.22.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 (204) hide show
  1. package/ci/diagnose.js +9 -14
  2. package/ci/init.js +1 -2
  3. package/ci/test-optimization-validation/ci-discovery.js +9 -4
  4. package/ci/test-optimization-validation/executable.js +1 -5
  5. package/ci/test-optimization-validation/framework-adapters/cypress.js +1 -4
  6. package/ci/test-optimization-validation/framework-adapters/vitest.js +1 -1
  7. package/ci/test-optimization-validation/generated-files.js +6 -4
  8. package/ci/test-optimization-validation/generated-test-contract.js +7 -3
  9. package/ci/test-optimization-validation/manifest-scaffold.js +7 -2
  10. package/ci/test-optimization-validation/manifest-schema.js +6 -7
  11. package/ci/test-optimization-validation/report-writer.js +6 -2
  12. package/ci/test-optimization-validation/runner-command.js +2 -4
  13. package/ci/test-optimization-validation/runner-contract.js +1 -2
  14. package/ci/test-optimization-validation/scenarios/auto-test-retries.js +1 -15
  15. package/ci/vitest-no-worker-init-setup.mjs +10 -7
  16. package/index.d.ts +25 -14
  17. package/initialize.mjs +1 -3
  18. package/openfeature/rules-based.d.ts +11 -0
  19. package/openfeature/rules-based.js +7 -0
  20. package/package.json +12 -10
  21. package/packages/datadog-core/src/storage.js +16 -0
  22. package/packages/datadog-esbuild/index.js +18 -12
  23. package/packages/datadog-instrumentations/src/aerospike.js +3 -3
  24. package/packages/datadog-instrumentations/src/ai.js +3 -3
  25. package/packages/datadog-instrumentations/src/aws-durable-execution-sdk-js.js +0 -6
  26. package/packages/datadog-instrumentations/src/aws-sdk.js +21 -13
  27. package/packages/datadog-instrumentations/src/cassandra-driver.js +25 -20
  28. package/packages/datadog-instrumentations/src/claude-agent-sdk.js +9 -23
  29. package/packages/datadog-instrumentations/src/cucumber.js +48 -33
  30. package/packages/datadog-instrumentations/src/elasticsearch.js +1 -5
  31. package/packages/datadog-instrumentations/src/electron.js +6 -6
  32. package/packages/datadog-instrumentations/src/fastify.js +13 -4
  33. package/packages/datadog-instrumentations/src/fs.js +2 -2
  34. package/packages/datadog-instrumentations/src/google-cloud-pubsub.js +9 -5
  35. package/packages/datadog-instrumentations/src/grpc/client.js +2 -0
  36. package/packages/datadog-instrumentations/src/helpers/bundler-register.js +2 -2
  37. package/packages/datadog-instrumentations/src/helpers/check-require-cache.js +1 -2
  38. package/packages/datadog-instrumentations/src/helpers/instrument.js +1 -1
  39. package/packages/datadog-instrumentations/src/helpers/register.js +36 -1
  40. package/packages/datadog-instrumentations/src/helpers/rewriter/index.js +2 -0
  41. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentation-registry.js +87 -6
  42. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/graphql-jit.js +5 -6
  43. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/postgres.js +48 -31
  44. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js +7 -7
  45. package/packages/datadog-instrumentations/src/helpers/rewriter/transforms/postgres.js +42 -4
  46. package/packages/datadog-instrumentations/src/helpers/rewriter/transforms.js +7 -10
  47. package/packages/datadog-instrumentations/src/ioredis.js +7 -5
  48. package/packages/datadog-instrumentations/src/iovalkey.js +7 -5
  49. package/packages/datadog-instrumentations/src/jest.js +3 -10
  50. package/packages/datadog-instrumentations/src/knex.js +9 -5
  51. package/packages/datadog-instrumentations/src/mariadb-bundle.js +3 -3
  52. package/packages/datadog-instrumentations/src/mocha/common.js +20 -8
  53. package/packages/datadog-instrumentations/src/mocha/main.js +140 -43
  54. package/packages/datadog-instrumentations/src/mocha/utils.js +3 -2
  55. package/packages/datadog-instrumentations/src/mocha/worker.js +1 -2
  56. package/packages/datadog-instrumentations/src/mongodb-core.js +3 -3
  57. package/packages/datadog-instrumentations/src/mongoose.js +3 -3
  58. package/packages/datadog-instrumentations/src/nova-sonic/audio.js +173 -0
  59. package/packages/datadog-instrumentations/src/nova-sonic/index.js +209 -0
  60. package/packages/datadog-instrumentations/src/nova-sonic/session.js +483 -0
  61. package/packages/datadog-instrumentations/src/openai-realtime/index.js +18 -6
  62. package/packages/datadog-instrumentations/src/openai-realtime/session.js +1 -1
  63. package/packages/datadog-instrumentations/src/openai.js +7 -0
  64. package/packages/datadog-instrumentations/src/pino.js +5 -6
  65. package/packages/datadog-instrumentations/src/redis.js +27 -13
  66. package/packages/datadog-instrumentations/src/router.js +30 -0
  67. package/packages/datadog-instrumentations/src/rum-browser-scripts.js +2 -6
  68. package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +7 -5
  69. package/packages/datadog-instrumentations/src/vitest-main.js +9 -15
  70. package/packages/datadog-instrumentations/src/vitest-util.js +2 -2
  71. package/packages/datadog-instrumentations/src/vitest-worker.js +1 -2
  72. package/packages/datadog-instrumentations/src/vitest.js +2 -2
  73. package/packages/datadog-instrumentations/src/webdriverio.js +5 -8
  74. package/packages/datadog-instrumentations/src/ws.js +1 -4
  75. package/packages/datadog-plugin-amqplib/src/client.js +1 -2
  76. package/packages/datadog-plugin-avsc/src/schema_iterator.js +1 -5
  77. package/packages/datadog-plugin-aws-durable-execution-sdk-js/src/handler.js +1 -2
  78. package/packages/datadog-plugin-aws-sdk/src/base.js +5 -8
  79. package/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime/index.js +18 -0
  80. package/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime/sonic.js +45 -0
  81. package/packages/datadog-plugin-aws-sdk/src/services/kinesis.js +9 -3
  82. package/packages/datadog-plugin-aws-sdk/src/services/sqs.js +2 -5
  83. package/packages/datadog-plugin-azure-durable-functions/src/index.js +5 -4
  84. package/packages/datadog-plugin-azure-functions/src/index.js +5 -3
  85. package/packages/datadog-plugin-claude-agent-sdk/src/index.js +0 -2
  86. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +1 -5
  87. package/packages/datadog-plugin-cypress/src/index.js +6 -6
  88. package/packages/datadog-plugin-cypress/src/source-map-utils.js +1 -2
  89. package/packages/datadog-plugin-cypress/src/support.js +1 -4
  90. package/packages/datadog-plugin-express/src/code_origin.js +1 -2
  91. package/packages/datadog-plugin-google-cloud-pubsub/src/consumer.js +2 -2
  92. package/packages/datadog-plugin-google-cloud-pubsub/src/producer.js +1 -2
  93. package/packages/datadog-plugin-graphql/src/execute.js +5 -5
  94. package/packages/datadog-plugin-grpc/src/util.js +1 -2
  95. package/packages/datadog-plugin-kafkajs/src/producer.js +1 -2
  96. package/packages/datadog-plugin-mocha/src/index.js +7 -10
  97. package/packages/datadog-plugin-mocha/src/webdriverio-video.js +8 -4
  98. package/packages/datadog-plugin-mongodb-core/src/query.js +8 -5
  99. package/packages/datadog-plugin-next/src/index.js +1 -2
  100. package/packages/datadog-plugin-openai/src/tracing.js +5 -2
  101. package/packages/datadog-plugin-openai-agents/src/integration.js +6 -2
  102. package/packages/datadog-plugin-openai-agents/src/processor.js +2 -4
  103. package/packages/datadog-plugin-postgres/src/index.js +24 -0
  104. package/packages/datadog-plugin-protobufjs/src/schema_iterator.js +1 -5
  105. package/packages/datadog-plugin-rhea/src/consumer.js +1 -4
  106. package/packages/datadog-plugin-vitest/src/index.js +5 -2
  107. package/packages/datadog-shimmer/src/shimmer.js +5 -4
  108. package/packages/datadog-webpack/index.js +55 -43
  109. package/packages/datadog-webpack/src/loader.js +56 -8
  110. package/packages/dd-trace/src/aiguard/integrations/vercel-ai.js +2 -4
  111. package/packages/dd-trace/src/aiguard/messages/utils.js +1 -2
  112. package/packages/dd-trace/src/aiguard/redaction.js +8 -2
  113. package/packages/dd-trace/src/appsec/api_security/normalized-route.js +3 -2
  114. package/packages/dd-trace/src/appsec/api_security/sampler.js +2 -6
  115. package/packages/dd-trace/src/appsec/downstream_requests.js +1 -9
  116. package/packages/dd-trace/src/appsec/handlers/http-request.js +3 -9
  117. package/packages/dd-trace/src/appsec/handlers/http-response.js +7 -2
  118. package/packages/dd-trace/src/appsec/iast/analyzers/path-traversal-analyzer.js +4 -3
  119. package/packages/dd-trace/src/appsec/iast/analyzers/set-cookies-header-interceptor.js +1 -4
  120. package/packages/dd-trace/src/appsec/iast/taint-tracking/operations-taint-object.js +7 -1
  121. package/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js +1 -3
  122. package/packages/dd-trace/src/appsec/iast/taint-tracking/rewriter-esm.mjs +1 -3
  123. package/packages/dd-trace/src/appsec/iast/telemetry/iast-metric.js +1 -6
  124. package/packages/dd-trace/src/appsec/lambda.js +1 -3
  125. package/packages/dd-trace/src/appsec/rasp/fs-plugin.js +37 -27
  126. package/packages/dd-trace/src/appsec/rasp/lfi.js +9 -5
  127. package/packages/dd-trace/src/appsec/reporter.js +21 -1
  128. package/packages/dd-trace/src/appsec/stack_trace.js +1 -2
  129. package/packages/dd-trace/src/ci-visibility/dynamic-atr-retries.js +3 -6
  130. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/msgpack-to-json.js +1 -1
  131. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +13 -7
  132. package/packages/dd-trace/src/ci-visibility/exporters/settings-cache-key.js +1 -2
  133. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/index.js +2 -8
  134. package/packages/dd-trace/src/ci-visibility/resolve-runfile-path.js +54 -0
  135. package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache-schema.js +5 -8
  136. package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache.js +2 -53
  137. package/packages/dd-trace/src/config/config-types.d.ts +2 -2
  138. package/packages/dd-trace/src/config/defaults.js +5 -5
  139. package/packages/dd-trace/src/config/generated-config-types.d.ts +8 -0
  140. package/packages/dd-trace/src/config/helper.js +34 -3
  141. package/packages/dd-trace/src/config/index.js +10 -9
  142. package/packages/dd-trace/src/config/normalize-service.js +1 -1
  143. package/packages/dd-trace/src/config/parsers.js +2 -2
  144. package/packages/dd-trace/src/config/supported-configurations.json +35 -0
  145. package/packages/dd-trace/src/datastreams/processor.js +3 -2
  146. package/packages/dd-trace/src/datastreams/schemas/schema_builder.js +1 -4
  147. package/packages/dd-trace/src/debugger/devtools_client/send.js +1 -0
  148. package/packages/dd-trace/src/debugger/devtools_client/status.js +1 -0
  149. package/packages/dd-trace/src/debugger/guardrail-metrics.js +2 -2
  150. package/packages/dd-trace/src/dogstatsd.js +1 -1
  151. package/packages/dd-trace/src/encode/tags-processors.js +2 -2
  152. package/packages/dd-trace/src/exporters/common/request.js +1 -5
  153. package/packages/dd-trace/src/id.js +14 -1
  154. package/packages/dd-trace/src/lambda/handler.js +179 -19
  155. package/packages/dd-trace/src/lambda/index.js +15 -1
  156. package/packages/dd-trace/src/lambda/runtime/patch.js +34 -7
  157. package/packages/dd-trace/src/llmobs/audio-utils.js +3 -2
  158. package/packages/dd-trace/src/llmobs/constants/tags.js +12 -0
  159. package/packages/dd-trace/src/llmobs/experiments/experiment.js +1 -1
  160. package/packages/dd-trace/src/llmobs/plugins/anthropic/index.js +15 -0
  161. package/packages/dd-trace/src/llmobs/plugins/genai/util.js +1 -2
  162. package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/chat_model.js +1 -3
  163. package/packages/dd-trace/src/llmobs/plugins/langgraph/index.js +1 -1
  164. package/packages/dd-trace/src/llmobs/plugins/nova-sonic.js +92 -0
  165. package/packages/dd-trace/src/llmobs/plugins/openai/index.js +56 -2
  166. package/packages/dd-trace/src/llmobs/plugins/openai/utils.js +38 -0
  167. package/packages/dd-trace/src/llmobs/prompts/manager.js +5 -7
  168. package/packages/dd-trace/src/llmobs/prompts/prompt.js +1 -1
  169. package/packages/dd-trace/src/llmobs/span_processor.js +1 -2
  170. package/packages/dd-trace/src/llmobs/tagger.js +2 -2
  171. package/packages/dd-trace/src/llmobs/util.js +1 -2
  172. package/packages/dd-trace/src/llmobs/writers/base.js +4 -5
  173. package/packages/dd-trace/src/log/log.js +1 -5
  174. package/packages/dd-trace/src/openfeature/evaluation-telemetry-opt-out-hook.js +51 -0
  175. package/packages/dd-trace/src/openfeature/flagging_provider.js +48 -21
  176. package/packages/dd-trace/src/opentelemetry/logs/otlp_transformer.js +1 -1
  177. package/packages/dd-trace/src/opentelemetry/metrics/otlp_transformer.js +1 -1
  178. package/packages/dd-trace/src/opentelemetry/metrics/periodic_metric_reader.js +1 -2
  179. package/packages/dd-trace/src/opentelemetry/otlp/common.proto +1 -1
  180. package/packages/dd-trace/src/opentelemetry/trace/otlp_transformer.js +6 -23
  181. package/packages/dd-trace/src/opentracing/propagation/text_map.js +53 -13
  182. package/packages/dd-trace/src/otel-sampling.js +4 -5
  183. package/packages/dd-trace/src/otel-thread-ctx.js +79 -13
  184. package/packages/dd-trace/src/plugins/database.js +7 -16
  185. package/packages/dd-trace/src/plugins/util/git.js +9 -25
  186. package/packages/dd-trace/src/plugins/util/inferred_proxy.js +1 -5
  187. package/packages/dd-trace/src/plugins/util/test.js +48 -4
  188. package/packages/dd-trace/src/priority_sampler.js +1 -2
  189. package/packages/dd-trace/src/profiling/profiler.js +4 -6
  190. package/packages/dd-trace/src/proxy.js +1 -2
  191. package/packages/dd-trace/src/ritm.js +4 -2
  192. package/packages/dd-trace/src/serverless.js +5 -0
  193. package/packages/dd-trace/src/span_processor.js +13 -17
  194. package/packages/dd-trace/src/telemetry/endpoints.js +1 -3
  195. package/packages/dd-trace/src/util.js +29 -0
  196. package/packages/dd-trace/src/web-tags-cache.js +6 -2
  197. package/vendor/dist/@datadog/openfeature-node-server/index.js +1 -1
  198. package/vendor/dist/@datadog/openfeature-node-server/rules-based/LICENSE +201 -0
  199. package/vendor/dist/@datadog/openfeature-node-server/rules-based/index.d.ts +925 -0
  200. package/vendor/dist/@datadog/openfeature-node-server/rules-based/index.js +1 -0
  201. package/vendor/dist/@datadog/sketches-js/index.js +1 -1
  202. package/vendor/dist/protobufjs/index.js +1 -1
  203. package/vendor/dist/protobufjs/minimal/index.js +1 -1
  204. package/vendor/dist/shell-quote/index.js +1 -1
@@ -0,0 +1,209 @@
1
+ 'use strict'
2
+
3
+ const dc = require('dc-polyfill')
4
+
5
+ const log = require('../../../dd-trace/src/log')
6
+ const { storage } = require('../../../datadog-core')
7
+ const SonicSession = require('./session')
8
+
9
+ const captureChannel = dc.channel('dd-trace:aws:bedrockruntime:sonic:capture-context')
10
+ const spanChannel = dc.tracingChannel('apm:aws:bedrockruntime:sonic:span')
11
+ const MODEL = 'amazon.nova-2-sonic-v1:0'
12
+ const FOUNDATION_MODEL_ARN = /^arn:aws(?:-[a-z0-9]+)*:bedrock:[a-z0-9-]{1,20}::foundation-model\/([^/]+)$/
13
+ const sessionStorage = storage('nova-sonic')
14
+ const instrumentedClients = new WeakSet()
15
+
16
+ function noop () {}
17
+
18
+ /** @param {unknown} identifier */
19
+ function resolveModel (identifier) {
20
+ if (identifier === MODEL) return identifier
21
+ if (typeof identifier !== 'string' || identifier.length > 2048) return
22
+ // Only foundation-model ARNs directly identify a model. Profile IDs need a service lookup,
23
+ // and the bidirectional API currently rejects them. Never infer a model from an ARN suffix.
24
+ const model = FOUNDATION_MODEL_ARN.exec(identifier)?.[1]
25
+ if (model === MODEL) return model
26
+ }
27
+
28
+ /**
29
+ * @param {object} descriptor
30
+ * @param {(fn: () => void) => void} runInContext
31
+ * @param {string} model
32
+ */
33
+ function emitTurn (descriptor, runInContext, model) {
34
+ const { turn } = descriptor
35
+ const common = { descriptor, model }
36
+ runInContext(() => {
37
+ spanChannel.traceSync(() => {
38
+ if (turn.inputStart !== undefined && turn.inputEnd !== undefined && turn.inputEnd >= turn.inputStart) {
39
+ spanChannel.traceSync(noop, {
40
+ ...common, name: 'user speech', kind: 'workflow', startTime: turn.inputStart, finishTime: turn.inputEnd,
41
+ })
42
+ }
43
+ spanChannel.traceSync(noop, {
44
+ ...common,
45
+ name: 'nova sonic response',
46
+ kind: 'llm',
47
+ startTime: descriptor.llmStart,
48
+ finishTime: descriptor.responseEnd,
49
+ })
50
+ const output = turn.output
51
+ if (output.timingValid && output.start !== undefined && output.end > output.start) {
52
+ spanChannel.traceSync(noop, {
53
+ ...common, name: 'agent speech', kind: 'workflow', startTime: output.start, finishTime: output.end,
54
+ })
55
+ }
56
+ }, {
57
+ ...common,
58
+ name: 'nova sonic audio turn',
59
+ kind: 'workflow',
60
+ startTime: descriptor.startTime,
61
+ finishTime: descriptor.finishTime,
62
+ })
63
+ })
64
+ }
65
+
66
+ /**
67
+ * These iterators are application/factory-created objects with no matchable source function.
68
+ * Wrap the concrete iterator, preserving next/return/throw and the SDK's own backpressure/cleanup.
69
+ * No producer is started here; each next delegates exactly once to the original iterator.
70
+ * @param {object} source SDK async iterable.
71
+ * @param {SonicSession} session
72
+ * @param {boolean} outbound
73
+ * @param {(error?: Error) => void} finish
74
+ */
75
+ function observeIterable (source, session, outbound, finish) {
76
+ if (typeof source?.[Symbol.asyncIterator] !== 'function') return source
77
+ const rejected = error => {
78
+ finish(error)
79
+ throw error
80
+ }
81
+ return {
82
+ [Symbol.asyncIterator] () {
83
+ let iterator
84
+ try {
85
+ iterator = source[Symbol.asyncIterator]()
86
+ } catch (error) {
87
+ finish(error)
88
+ throw error
89
+ }
90
+ const observed = { [Symbol.asyncIterator] () { return this } }
91
+ for (const method of ['next', 'return', 'throw']) {
92
+ if (typeof iterator[method] !== 'function') continue
93
+ const fulfilled = result => {
94
+ if (result.done || method === 'return') {
95
+ if (!outbound) finish()
96
+ } else {
97
+ session.observe(result.value, outbound)
98
+ }
99
+ return result
100
+ }
101
+ observed[method] = function (...args) {
102
+ try {
103
+ const result = iterator[method](...args)
104
+ return typeof result?.then === 'function' ? result.then(fulfilled, rejected) : fulfilled(result)
105
+ } catch (error) {
106
+ return rejected(error)
107
+ }
108
+ }
109
+ }
110
+ return observed
111
+ },
112
+ }
113
+ }
114
+
115
+ /** @param {object} client */
116
+ function instrumentInput (client) {
117
+ if (instrumentedClients.has(client)) return true
118
+ try {
119
+ // Install before even an uncaptured send can cache the SDK handler. Cached handlers must
120
+ // look up the invocation's session at execution time, not retain the first session.
121
+ client.middlewareStack.add((next, context) => args => {
122
+ const capture = sessionStorage.getStore()
123
+ if (capture?.client !== client || context.commandName !== 'InvokeModelWithBidirectionalStreamCommand') {
124
+ return next(args)
125
+ }
126
+ const { session, finish } = capture
127
+ const input = { ...args.input, body: observeIterable(args.input.body, session, true, finish) }
128
+ // Smithy passes the shared Command as args. Copy it before asynchronous middleware can
129
+ // read input, so overlapping sends never modify the command or observe another session.
130
+ return next({ ...args, input })
131
+ }, { name: 'datadogSonicInput', step: 'initialize', priority: 'high' })
132
+ instrumentedClients.add(client)
133
+ return true
134
+ } catch {
135
+ log.debug('Cannot observe Nova Sonic input')
136
+ return false
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Called from the existing Smithy send seam. The bidi operation bypasses the ordinary AWS response
142
+ * accumulator: retaining every chunk there would retain a whole conversation twice.
143
+ * @param {Function} send
144
+ * @param {object} client
145
+ * @param {object} command
146
+ * @param {Array<object | Function>} args
147
+ */
148
+ function sendSonic (send, client, command, args) {
149
+ const instrumented = instrumentInput(client)
150
+ const passthrough = () => sessionStorage.run(undefined, () => send.call(client, command, ...args))
151
+ if (!instrumented || !captureChannel.hasSubscribers) return passthrough()
152
+ const model = resolveModel(command.input?.modelId)
153
+ if (!model) return passthrough()
154
+ const context = {}
155
+ captureChannel.publish(context)
156
+ // Only the LLMObs plugin enables capture; the tracing plugin alone does not buffer conversations.
157
+ if (!context.enabled || !context.runInContext) return passthrough()
158
+ const session = new SonicSession(descriptor => emitTurn(descriptor, context.runInContext, model))
159
+ const signal = args[0] !== null && typeof args[0] === 'object' ? args[0]?.abortSignal : undefined
160
+ // Smithy's legacy AbortSignal only exposes onabort; leave that slot to the SDK's transport.
161
+ const hasListeners = typeof signal?.addEventListener === 'function' &&
162
+ typeof signal?.removeEventListener === 'function'
163
+ const finish = error => {
164
+ if (hasListeners) signal.removeEventListener('abort', onAbort)
165
+ // Aborting an already delivered HTTP/2 response can surface as clean iterator EOF.
166
+ if (!error && signal?.aborted) {
167
+ error = new Error('Nova Sonic request aborted')
168
+ error.name = 'AbortError'
169
+ }
170
+ session.finish(error)
171
+ }
172
+ const onAbort = () => finish()
173
+ if (signal?.aborted) onAbort()
174
+ else if (hasListeners) signal.addEventListener('abort', onAbort, { once: true })
175
+
176
+ const completed = result => {
177
+ try {
178
+ if (typeof result?.body?.[Symbol.asyncIterator] === 'function') {
179
+ result.body = observeIterable(result.body, session, false, finish)
180
+ } else {
181
+ finish()
182
+ }
183
+ } catch {
184
+ finish()
185
+ log.debug('Cannot observe Nova Sonic output')
186
+ }
187
+ return result
188
+ }
189
+ const failed = error => {
190
+ finish(error)
191
+ throw error
192
+ }
193
+ const callback = args.at(-1)
194
+ if (typeof callback === 'function') {
195
+ args[args.length - 1] = function (error, result) {
196
+ if (error) finish(error)
197
+ else completed(result)
198
+ return callback.apply(this, arguments)
199
+ }
200
+ }
201
+ try {
202
+ const result = sessionStorage.run({ client, session, finish }, () => send.call(client, command, ...args))
203
+ return typeof callback === 'function' ? result : result.then(completed, failed)
204
+ } catch (error) {
205
+ return failed(error)
206
+ }
207
+ }
208
+
209
+ module.exports = sendSonic
@@ -0,0 +1,483 @@
1
+ 'use strict'
2
+
3
+ const { randomUUID } = require('node:crypto')
4
+
5
+ const log = require('../../../dd-trace/src/log')
6
+ const { InputAudio, OutputAudio } = require('./audio')
7
+
8
+ const MAX_TEXT = 65_536
9
+ // Replayed history can exceed 16 messages after only eight exchanges. Allow longer resumed
10
+ // conversations while bounding tiny/empty message overhead independently of the total text budget.
11
+ const MAX_HISTORY_MESSAGES = 128
12
+ const MAX_BLOCKS = 256
13
+ const MAX_TOOLS = 16
14
+ const MAX_TOOL_TEXT = 4096
15
+
16
+ class Turn {
17
+ windows = []
18
+ userText = ''
19
+ finalText = ''
20
+ speculativeText = ''
21
+ tools = []
22
+ toolResults = []
23
+ inputPcm
24
+ inputRate = 0
25
+ inputStart
26
+ inputEnd
27
+ inputAudioObserved = false
28
+ outputAudioObserved = false
29
+ audioStarted = false
30
+ output = new OutputAudio()
31
+ started
32
+ generationEnd
33
+ textEnd
34
+ completionId
35
+ metrics = { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
36
+ missingAudioMetrics = new Set()
37
+ emitted = false
38
+ }
39
+
40
+ /** @param {Turn} turn */
41
+ function hasInput (turn) {
42
+ return turn.windows.length || turn.userText || turn.toolResults.length
43
+ }
44
+
45
+ /** @param {Turn} turn */
46
+ function completionTime (turn) {
47
+ // Text can finish before audio starts. Keep its boundary as a fallback, without completing
48
+ // an unfinished or interrupted audio response when its final transcript arrives.
49
+ return turn.generationEnd ?? (!turn.audioStarted && turn.output.interrupted === undefined ? turn.textEnd : undefined)
50
+ }
51
+
52
+ /** @param {unknown} value */
53
+ function object (value) {
54
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
55
+ }
56
+
57
+ /**
58
+ * @param {string} text
59
+ */
60
+ function parse (text) {
61
+ try {
62
+ return JSON.parse(text)
63
+ } catch {
64
+ return {}
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Nova's completion ID can cover the entire connection. Local response ownership is driven by
70
+ * pending input and new generation, with FINAL text staying on the preceding response.
71
+ */
72
+ class SonicSession {
73
+ #emit
74
+ #audio = new InputAudio()
75
+ #pending = new Turn()
76
+ #current
77
+ #outbound = new Map()
78
+ #blocks = new Map()
79
+ #completed = new Set()
80
+ #prompt
81
+ #configuration = {}
82
+ #outputConfiguration = {}
83
+ #history = []
84
+ #closed = false
85
+ #turnIndex = 0
86
+ #sessionId = randomUUID()
87
+ #providerSessionId
88
+ #totals = { input_tokens: 0, output_tokens: 0 }
89
+ #audioTotals = { input: 0, output: 0 }
90
+
91
+ /** @param {(descriptor: object) => void} emit */
92
+ constructor (emit) {
93
+ this.#emit = emit
94
+ }
95
+
96
+ /**
97
+ * Observe decoded SDK union members without consuming either iterable ahead of its owner.
98
+ * @param {{ chunk?: { bytes?: Uint8Array } }} event
99
+ * @param {boolean} outbound
100
+ * @param {number} [now]
101
+ */
102
+ observe (event, outbound, now = Date.now()) {
103
+ if (this.#closed) return
104
+ try {
105
+ const bytes = event?.chunk?.bytes
106
+ if (!ArrayBuffer.isView(bytes)) return
107
+ const payload = parse(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('utf8'))
108
+ if (!object(payload?.event)) return
109
+ for (const [name, data] of Object.entries(payload.event)) {
110
+ if (!object(data)) continue
111
+ if (outbound) {
112
+ this.#sent(name, data, now)
113
+ } else {
114
+ this.#received(name, data, now)
115
+ }
116
+ if (this.#closed) break
117
+ }
118
+ } catch {
119
+ // Malformed telemetry never changes transport behavior or logs captured conversation data.
120
+ log.debug('Cannot process Nova Sonic telemetry')
121
+ }
122
+ }
123
+
124
+ /**
125
+ * @param {string} name
126
+ * @param {object} data
127
+ * @param {number} now
128
+ */
129
+ #sent (name, data, now) {
130
+ const key = data.contentName ?? ''
131
+ switch (name) {
132
+ case 'sessionStart':
133
+ for (const name of ['inferenceConfiguration', 'turnDetectionConfiguration']) {
134
+ const config = this.#configuration[name] = {}
135
+ if (!object(data[name])) continue
136
+ for (const key of ['maxTokens', 'topP', 'temperature', 'endpointingSensitivity']) {
137
+ const value = data[name][key]
138
+ if ((typeof value === 'number' || typeof value === 'string') && String(value).length < 128) {
139
+ config[key] = value
140
+ }
141
+ }
142
+ }
143
+ break
144
+ case 'promptStart':
145
+ if (this.#prompt !== undefined && this.#prompt !== data.promptName) {
146
+ this.finish(undefined, now)
147
+ return
148
+ }
149
+ this.#prompt = data.promptName
150
+ this.#outputConfiguration = data.audioOutputConfiguration ?? {}
151
+ break
152
+ case 'contentStart':
153
+ this.#outbound.set(key, { ...data, text: '' })
154
+ if (data.type === 'AUDIO' && data.role === 'USER') this.#audio.configure(data.audioInputConfiguration)
155
+ this.#bound(this.#outbound)
156
+ break
157
+ case 'audioInput': {
158
+ const block = this.#outbound.get(key)
159
+ if (block?.type === 'AUDIO' && block.role === 'USER') {
160
+ if (typeof data.content === 'string' && data.content.length) this.#pending.inputAudioObserved = true
161
+ this.#audio.append(data.content, now)
162
+ }
163
+ break
164
+ }
165
+ case 'textInput': {
166
+ const block = this.#outbound.get(key)
167
+ if (block && typeof data.content === 'string') block.text = (block.text + data.content).slice(0, MAX_TEXT)
168
+ break
169
+ }
170
+ case 'toolResult': {
171
+ const block = this.#outbound.get(key)
172
+ const target = this.#current ?? this.#pending
173
+ if (target.toolResults.length < MAX_TOOLS && typeof data.content === 'string') {
174
+ target.toolResults.push({
175
+ role: 'tool',
176
+ content: '',
177
+ toolResults: [{
178
+ result: data.content.slice(0, MAX_TOOL_TEXT),
179
+ toolId: block?.toolResultInputConfiguration?.toolUseId ?? '',
180
+ type: 'tool_result',
181
+ }],
182
+ })
183
+ }
184
+ break
185
+ }
186
+ case 'contentEnd': {
187
+ const block = this.#outbound.get(key)
188
+ this.#outbound.delete(key)
189
+ if (block?.type !== 'TEXT') break
190
+ if (block.role === 'USER' && block.interactive) {
191
+ this.#pending.userText = (this.#pending.userText + block.text).slice(0, MAX_TEXT)
192
+ } else if (this.#history.length < MAX_HISTORY_MESSAGES) {
193
+ const remaining = MAX_TEXT - this.#history.reduce((n, message) => n + message.content.length, 0)
194
+ this.#history.push({
195
+ role: String(block.role ?? 'USER').toLowerCase(), content: block.text.slice(0, remaining),
196
+ })
197
+ }
198
+ break
199
+ }
200
+ }
201
+ }
202
+
203
+ /**
204
+ * @param {Map<string, object>} map
205
+ */
206
+ #bound (map) {
207
+ while (map.size > MAX_BLOCKS) map.delete(map.keys().next().value)
208
+ }
209
+
210
+ #hasPendingInput () {
211
+ if (hasInput(this.#pending)) return true
212
+ // ASR text may precede contentEnd, even when speech boundary events are absent.
213
+ for (const block of this.#blocks.values()) {
214
+ if (block.turn === this.#pending && block.data.role === 'USER' && block.text) return true
215
+ }
216
+ return false
217
+ }
218
+
219
+ /**
220
+ * @param {object} data
221
+ * @param {number} now
222
+ */
223
+ #startResponse (data, now) {
224
+ if (!this.#current || this.#hasPendingInput()) {
225
+ if (this.#current) {
226
+ // A new response can arrive before the previous response's text blocks close.
227
+ for (const block of this.#blocks.values()) {
228
+ if (block.turn === this.#current) this.#text(block)
229
+ }
230
+ this.#emitTurn(this.#current, now)
231
+ }
232
+ const turn = this.#pending
233
+ this.#pending = new Turn()
234
+ turn.started = now
235
+ turn.completionId = data.completionId
236
+ this.#snapshotInput(turn)
237
+ this.#current = turn
238
+ for (const [key, block] of this.#blocks) {
239
+ if (block.turn !== turn) this.#blocks.delete(key)
240
+ }
241
+ }
242
+ return this.#current
243
+ }
244
+
245
+ /** @param {Turn} turn */
246
+ #snapshotInput (turn) {
247
+ if (turn.windows.length && !turn.inputPcm) {
248
+ turn.inputRate = this.#audio.rate
249
+ turn.inputPcm = this.#audio.clip(turn.windows[0].start_ms, turn.windows.at(-1).end_ms)
250
+ }
251
+ }
252
+
253
+ /**
254
+ * @param {string} name
255
+ * @param {object} data
256
+ * @param {number} now
257
+ */
258
+ #received (name, data, now) {
259
+ // Once any turn has been emitted, its conversation identity must never change.
260
+ if (!this.#providerSessionId && typeof data.sessionId === 'string' && data.sessionId) {
261
+ this.#providerSessionId = data.sessionId
262
+ if (!this.#turnIndex) this.#sessionId = data.sessionId
263
+ }
264
+ switch (name) {
265
+ case 'userSpeechStart': {
266
+ const offset = data.inputAudioOffsetMs
267
+ if (!Number.isFinite(offset) || offset < 0 || this.#pending.windows.length >= MAX_BLOCKS) return
268
+ this.#pending.windows.push({ start_ms: offset })
269
+ this.#pending.inputAudioObserved = true
270
+ this.#pending.inputEnd = undefined
271
+ this.#pending.inputStart ??= this.#audio.anchor === undefined
272
+ ? now
273
+ : Math.min(now, this.#audio.anchor + offset)
274
+ break
275
+ }
276
+ case 'userSpeechEnd': {
277
+ const window = this.#pending.windows.at(-1)
278
+ const end = data.inputAudioOffsetMs
279
+ if (!window || !Number.isFinite(end) || end <= window.start_ms) return
280
+ window.end_ms = end
281
+ if (Number.isFinite(data.inputAudioDetectionOffsetMs)) window.detection_ms = data.inputAudioDetectionOffsetMs
282
+ this.#pending.inputEnd = now
283
+ break
284
+ }
285
+ case 'contentStart': {
286
+ const key = data.contentId ?? ''
287
+ if (this.#blocks.has(key) || this.#completed.has(key)) return
288
+ const fields = object(data.additionalModelFields)
289
+ ? data.additionalModelFields
290
+ : parse(data.additionalModelFields)
291
+ const stage = object(fields) ? fields.generationStage : undefined
292
+ let turn
293
+ if (data.role === 'ASSISTANT' || data.type === 'TOOL') {
294
+ const newTextResponse = this.#current && !this.#current.audioStarted &&
295
+ completionTime(this.#current) !== undefined && this.#hasPendingInput()
296
+ turn = stage === 'FINAL' && this.#current && !newTextResponse
297
+ ? this.#current
298
+ : this.#startResponse(data, now)
299
+ } else {
300
+ turn = this.#hasPendingInput() || !this.#current || completionTime(this.#current) !== undefined
301
+ ? this.#pending
302
+ : this.#current
303
+ }
304
+ if (data.role === 'ASSISTANT' && data.type === 'AUDIO') turn.audioStarted = true
305
+ this.#blocks.set(key, { data, stage, turn, text: '', control: false })
306
+ this.#bound(this.#blocks)
307
+ break
308
+ }
309
+ case 'textOutput':
310
+ case 'audioOutput':
311
+ case 'toolUse':
312
+ case 'contentEnd':
313
+ this.#content(name, data, now)
314
+ break
315
+ case 'usageEvent':
316
+ this.#usage(data)
317
+ break
318
+ case 'completionEnd':
319
+ this.finish(undefined, now)
320
+ break
321
+ }
322
+ }
323
+
324
+ /**
325
+ * @param {string} name
326
+ * @param {object} data
327
+ * @param {number} now
328
+ */
329
+ #content (name, data, now) {
330
+ const key = data.contentId ?? ''
331
+ const block = this.#blocks.get(key)
332
+ if (!block) return
333
+ const { turn } = block
334
+ if (name === 'textOutput' && typeof data.content === 'string') {
335
+ block.text = (block.text + data.content).slice(0, MAX_TEXT)
336
+ if (block.data.role === 'ASSISTANT' && parse(block.text)?.interrupted === true) {
337
+ // Keep literal JSON until contentEnd confirms protocol control. Retain its
338
+ // receipt time so confirmation does not move the projected playback cut.
339
+ block.interruptionTime ??= now
340
+ } else {
341
+ block.interruptionTime = undefined
342
+ }
343
+ } else if (name === 'audioOutput' && block.data.role === 'ASSISTANT') {
344
+ if (typeof data.content === 'string' && data.content.length) turn.outputAudioObserved = true
345
+ turn.output.append(data.content, block.data.audioOutputConfiguration ?? this.#outputConfiguration, now)
346
+ } else if (name === 'toolUse' && turn.tools.length < MAX_TOOLS) {
347
+ const args = typeof data.content === 'string' && data.content.length <= MAX_TOOL_TEXT ? parse(data.content) : {}
348
+ turn.tools.push({
349
+ name: data.toolName ?? '',
350
+ arguments: object(args) ? args : {},
351
+ toolId: data.toolUseId ?? '',
352
+ type: 'function',
353
+ })
354
+ } else if (name === 'contentEnd') {
355
+ if (block.data.role === 'ASSISTANT' && data.stopReason === 'INTERRUPTED') {
356
+ block.control = block.interruptionTime !== undefined
357
+ turn.output.interrupt(block.interruptionTime ?? now)
358
+ }
359
+ this.#text(block)
360
+ if (block.data.type === 'AUDIO' && data.stopReason === 'END_TURN') turn.generationEnd = now
361
+ if (block.data.role === 'ASSISTANT' && block.data.type === 'TEXT' &&
362
+ !block.control && data.stopReason === 'END_TURN') turn.textEnd = now
363
+ this.#blocks.delete(key)
364
+ this.#completed.add(key)
365
+ if (this.#completed.size > MAX_BLOCKS) this.#completed.delete(this.#completed.values().next().value)
366
+ }
367
+ }
368
+
369
+ /** @param {{ control: boolean, data: object, stage?: string, text: string, turn: object }} block */
370
+ #text (block) {
371
+ if (block.control) return
372
+ const field = block.data.role === 'USER'
373
+ ? 'userText'
374
+ : block.stage === 'FINAL' ? 'finalText' : 'speculativeText'
375
+ block.turn[field] = (block.turn[field] + block.text).slice(0, MAX_TEXT)
376
+ }
377
+
378
+ /** @param {object} data */
379
+ #usage (data) {
380
+ for (const direction of ['input', 'output']) {
381
+ const key = `${direction}_tokens`
382
+ const total = data[direction === 'input' ? 'totalInputTokens' : 'totalOutputTokens']
383
+ if (!Number.isSafeInteger(total) || total < this.#totals[key]) continue
384
+ const delta = total - this.#totals[key]
385
+ this.#totals[key] = total
386
+ const target = !this.#current || (direction === 'input' && this.#hasPendingInput())
387
+ ? this.#pending
388
+ : this.#current
389
+ target.metrics[key] += delta
390
+ target.metrics.total_tokens += delta
391
+
392
+ // Speech usage is a subset of these totals, never another charge to total_tokens.
393
+ const audioKey = `${direction}_audio_tokens`
394
+ const audioTotal = data.details?.total?.[direction]?.speechTokens
395
+ const previous = this.#audioTotals[direction]
396
+ const valid = Number.isSafeInteger(audioTotal) && audioTotal >= 0 && audioTotal <= total &&
397
+ (previous === undefined || audioTotal >= previous)
398
+ const audioDelta = valid && previous !== undefined ? audioTotal - previous : undefined
399
+ this.#audioTotals[direction] = valid ? audioTotal : delta ? undefined : previous
400
+ if (audioDelta === undefined || audioDelta > delta) {
401
+ if (delta || audioDelta > delta) {
402
+ target.missingAudioMetrics.add(audioKey)
403
+ delete target.metrics[audioKey]
404
+ }
405
+ } else if (!target.missingAudioMetrics.has(audioKey)) {
406
+ target.metrics[audioKey] = (target.metrics[audioKey] ?? 0) + audioDelta
407
+ }
408
+ }
409
+ }
410
+
411
+ /**
412
+ * @param {Turn} turn
413
+ * @param {number} now
414
+ * @param {Error} [error]
415
+ */
416
+ #emitTurn (turn, now, error) {
417
+ if (turn.emitted) return
418
+ turn.emitted = true
419
+ this.#turnIndex++
420
+ try {
421
+ this.#snapshotInput(turn)
422
+ const start = turn.inputStart ?? turn.started ?? now
423
+ const llmStart = turn.inputEnd ?? turn.started ?? start
424
+ const completed = completionTime(turn)
425
+ const responseEnd = Math.max(llmStart, completed ?? turn.output.interrupted ?? now)
426
+ this.#emit({
427
+ turn,
428
+ error,
429
+ sessionId: this.#sessionId,
430
+ history: this.#history,
431
+ startTime: start,
432
+ finishTime: Math.max(responseEnd, turn.output.end ?? 0, turn.inputEnd ?? 0),
433
+ llmStart,
434
+ responseEnd,
435
+ metadata: {
436
+ turn_index: this.#turnIndex,
437
+ completion_id: turn.completionId,
438
+ speech_windows: turn.windows,
439
+ ttfa_boundary: 'speech_end_event_receipt',
440
+ output_timing: 'projected_playback',
441
+ interrupted: turn.output.interrupted !== undefined,
442
+ partial: completed === undefined,
443
+ transcript_stage: turn.finalText ? 'FINAL' : 'SPECULATIVE',
444
+ usage_attribution: 'event_receipt',
445
+ generated_output_bytes: turn.output.generatedBytes,
446
+ session_configuration: this.#configuration,
447
+ },
448
+ })
449
+ } catch {
450
+ log.debug('Cannot emit Nova Sonic turn')
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Input EOF is deliberately not a finish. Only output completion, cancellation or failure flushes.
456
+ * @param {Error} [error]
457
+ * @param {number} [now]
458
+ */
459
+ finish (error, now = Date.now()) {
460
+ if (this.#closed) return
461
+ this.#closed = true
462
+ for (const block of this.#blocks.values()) this.#text(block)
463
+ if (this.#current) {
464
+ // A failure before the next response starts belongs to its pending utterance,
465
+ // not to the response that has already completed.
466
+ const laterTurnFailed = completionTime(this.#current) !== undefined && this.#hasPendingInput()
467
+ this.#emitTurn(this.#current, now, laterTurnFailed ? undefined : error)
468
+ }
469
+ if (hasInput(this.#pending) || this.#pending.metrics.total_tokens || (error && !this.#current)) {
470
+ this.#emitTurn(this.#pending, now, error)
471
+ }
472
+ this.#current = undefined
473
+ this.#pending = new Turn()
474
+ this.#blocks.clear()
475
+ this.#outbound.clear()
476
+ this.#completed.clear()
477
+ this.#history = []
478
+ this.#audio.clear()
479
+ this.#emit = undefined
480
+ }
481
+ }
482
+
483
+ module.exports = SonicSession