newrelic 12.24.0 → 12.25.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/NEWS.md CHANGED
@@ -1,3 +1,22 @@
1
+ ### v12.25.0 (2025-07-09)
2
+
3
+ #### Features
4
+
5
+ * Instrument AWS Bedrock Converse API ([#3191](https://github.com/newrelic/node-newrelic/pull/3191)) ([16ac684](https://github.com/newrelic/node-newrelic/commit/16ac6845a82506bbdd0e51f3f9a6d05ac8016246))
6
+
7
+ #### Bug fixes
8
+
9
+ * `eventSegment.addAttribute` will not crash if null ([#3215](https://github.com/newrelic/node-newrelic/pull/3215)) ([8955f37](https://github.com/newrelic/node-newrelic/commit/8955f3737e686b4bf6beab2d4e53a1f5eb221166))
10
+ * `openai` Responses API support changed to >=v4.87.0 ([#3219](https://github.com/newrelic/node-newrelic/pull/3219)) ([19c0c70](https://github.com/newrelic/node-newrelic/commit/19c0c7011c8c877098c5a57063354f01f5f83295))
11
+
12
+ #### Documentation
13
+
14
+ * Updated compatibility report ([#3220](https://github.com/newrelic/node-newrelic/pull/3220)) ([b4350c7](https://github.com/newrelic/node-newrelic/commit/b4350c7e3a718e47d066c5c2c1f4f5f20f43e52c))
15
+
16
+ #### Miscellaneous chores
17
+
18
+ * Converted .proto to .json for bundling ([#3212](https://github.com/newrelic/node-newrelic/pull/3212)) ([1e208d4](https://github.com/newrelic/node-newrelic/commit/1e208d43acc1811674126b6aa987c895178f4fc3))
19
+
1
20
  ### v12.24.0 (2025-07-07)
2
21
 
3
22
  #### Features
@@ -22,7 +22,7 @@ const connectionStates = require('./connection/states')
22
22
 
23
23
  const PROTO_OPTIONS = { keepCase: true, longs: String, enums: String, defaults: true, oneofs: true }
24
24
 
25
- const PROTO_DEFINITION_PATH = __dirname + '../../../lib/grpc/endpoints/infinite-tracing/v1.proto'
25
+ const PROTO_DEFINITION = require('./endpoints/infinite-tracing/v1.json')
26
26
 
27
27
  const DEFAULT_RECONNECT_DELAY_MS = 15 * 1000
28
28
 
@@ -369,7 +369,7 @@ class GrpcConnection extends EventEmitter {
369
369
  const endpoint = this._endpoint
370
370
  logger.trace('Creating gRPC client for: ', endpoint)
371
371
 
372
- const packageDefinition = protoLoader.loadSync(PROTO_DEFINITION_PATH, PROTO_OPTIONS)
372
+ const packageDefinition = protoLoader.fromJSON(PROTO_DEFINITION, PROTO_OPTIONS)
373
373
 
374
374
  const protoDescriptor = grpc.loadPackageDefinition(packageDefinition)
375
375
 
@@ -0,0 +1,10 @@
1
+ The `.proto` files in this directory are provided by New Relic internal
2
+ specifications.
3
+
4
+ The `.json` files in this directory are JSON descriptor files derived from
5
+ the equivalent `.proto` files. This is accomplished by utilizing the CLI
6
+ tool provided by `protobufjs`. As an example:
7
+
8
+ ```sh
9
+ npx --package=protobufjs-cli -c 'pbjs --keep-case v1.proto' > v1.json
10
+ ```
@@ -0,0 +1,106 @@
1
+ {
2
+ "nested": {
3
+ "com": {
4
+ "nested": {
5
+ "newrelic": {
6
+ "nested": {
7
+ "trace": {
8
+ "nested": {
9
+ "v1": {
10
+ "nested": {
11
+ "IngestService": {
12
+ "methods": {
13
+ "RecordSpan": {
14
+ "requestType": "Span",
15
+ "requestStream": true,
16
+ "responseType": "RecordStatus",
17
+ "responseStream": true
18
+ },
19
+ "RecordSpanBatch": {
20
+ "requestType": "SpanBatch",
21
+ "requestStream": true,
22
+ "responseType": "RecordStatus",
23
+ "responseStream": true
24
+ }
25
+ }
26
+ },
27
+ "SpanBatch": {
28
+ "fields": {
29
+ "spans": {
30
+ "rule": "repeated",
31
+ "type": "Span",
32
+ "id": 1
33
+ }
34
+ }
35
+ },
36
+ "Span": {
37
+ "fields": {
38
+ "trace_id": {
39
+ "type": "string",
40
+ "id": 1
41
+ },
42
+ "intrinsics": {
43
+ "keyType": "string",
44
+ "type": "AttributeValue",
45
+ "id": 2
46
+ },
47
+ "user_attributes": {
48
+ "keyType": "string",
49
+ "type": "AttributeValue",
50
+ "id": 3
51
+ },
52
+ "agent_attributes": {
53
+ "keyType": "string",
54
+ "type": "AttributeValue",
55
+ "id": 4
56
+ }
57
+ }
58
+ },
59
+ "AttributeValue": {
60
+ "oneofs": {
61
+ "value": {
62
+ "oneof": [
63
+ "string_value",
64
+ "bool_value",
65
+ "int_value",
66
+ "double_value"
67
+ ]
68
+ }
69
+ },
70
+ "fields": {
71
+ "string_value": {
72
+ "type": "string",
73
+ "id": 1
74
+ },
75
+ "bool_value": {
76
+ "type": "bool",
77
+ "id": 2
78
+ },
79
+ "int_value": {
80
+ "type": "int64",
81
+ "id": 3
82
+ },
83
+ "double_value": {
84
+ "type": "double",
85
+ "id": 4
86
+ }
87
+ }
88
+ },
89
+ "RecordStatus": {
90
+ "fields": {
91
+ "messages_seen": {
92
+ "type": "uint64",
93
+ "id": 1
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+ }
106
+ }
@@ -19,9 +19,27 @@ const { AI } = require('../../../metrics/names')
19
19
  const { RecorderSpec } = require('../../../shim/specs')
20
20
  const InstrumentationDescriptor = require('../../../instrumentation-descriptor')
21
21
  const { extractLlmContext } = require('../../../util/llm-utils')
22
+ const ConverseStreamHandler = require('../../../llm-events/aws-bedrock/converse-stream-handler')
22
23
 
23
24
  let TRACKING_METRIC
24
25
 
26
+ const INSTRUMENTED_COMMANDS = new Set([
27
+ 'InvokeModelCommand',
28
+ 'InvokeModelWithResponseStreamCommand',
29
+ 'ConverseCommand',
30
+ 'ConverseStreamCommand'
31
+ ])
32
+
33
+ const STREAMING_COMMANDS = new Set([
34
+ 'InvokeModelWithResponseStreamCommand',
35
+ 'ConverseStreamCommand'
36
+ ])
37
+
38
+ const CONVERSE_COMMANDS = new Set([
39
+ 'ConverseCommand',
40
+ 'ConverseStreamCommand'
41
+ ])
42
+
25
43
  /**
26
44
  * Helper to determine if we should instrument the bedrock middleware call
27
45
  *
@@ -42,7 +60,7 @@ function shouldSkipInstrumentation(config) {
42
60
  */
43
61
  function isStreamingEnabled({ commandName, config }) {
44
62
  return (
45
- commandName === 'InvokeModelWithResponseStreamCommand' &&
63
+ STREAMING_COMMANDS.has(commandName) &&
46
64
  config.ai_monitoring?.streaming?.enabled
47
65
  )
48
66
  }
@@ -242,6 +260,7 @@ function createBedrockResponse({ bedrockCommand, response, err }) {
242
260
  function getBedrockSpec({ commandName }, shim, _original, _name, args) {
243
261
  const { agent } = shim
244
262
  const { input } = args[0]
263
+ const isConverse = CONVERSE_COMMANDS.has(commandName)
245
264
  const bedrockCommand = new BedrockCommand(input)
246
265
  const { modelType } = bedrockCommand
247
266
 
@@ -261,17 +280,33 @@ function getBedrockSpec({ commandName }, shim, _original, _name, args) {
261
280
 
262
281
  if (err && !response) {
263
282
  handleResponse(passThroughParams)
264
- } else if (response.output.body instanceof Uint8Array) {
283
+ } else if (response.output.body instanceof Uint8Array || 'output' in response.output) {
265
284
  // non-streamed response
266
285
  handleResponse(passThroughParams)
267
286
  } else if (isStreamingEnabled({ commandName, config: agent.config })) {
268
287
  // stream response
269
- const handler = new StreamHandler({
270
- stream: response.output.body,
271
- onComplete: handleResponse,
272
- passThroughParams
273
- })
274
- response.output.body = handler.generator(handleResponse)
288
+ if (isConverse) {
289
+ const handler = new ConverseStreamHandler({
290
+ stream: response.output.stream,
291
+ onComplete: handleResponse,
292
+ passThroughParams
293
+ })
294
+ response.output = {
295
+ ...response.output ?? {},
296
+ output: {
297
+ message: {
298
+ content: handler.generator(handleResponse)
299
+ }
300
+ }
301
+ }
302
+ } else {
303
+ const handler = new StreamHandler({
304
+ stream: response.output.body,
305
+ onComplete: handleResponse,
306
+ passThroughParams
307
+ })
308
+ response.output.body = handler.generator(handleResponse)
309
+ }
275
310
  } else if (!isStreamingEnabled({ commandName, config: agent.config })) {
276
311
  shim.logger.warn(
277
312
  'ai_monitoring.streaming.enabled is set to `false`, stream will not be instrumented.'
@@ -285,6 +320,11 @@ function getBedrockSpec({ commandName }, shim, _original, _name, args) {
285
320
 
286
321
  function handleResponse({ shim, err, response, segment, transaction, bedrockCommand, modelType }) {
287
322
  const { agent } = shim
323
+ // For the ConverseStreamHandler only, response.output does not
324
+ // contain stopReason yet, so we set it here.
325
+ if (response?.output && this?.stopReason) {
326
+ response.output.stopReason = this.stopReason
327
+ }
288
328
  const bedrockResponse = createBedrockResponse({ bedrockCommand, response, err })
289
329
 
290
330
  addLlmMeta({ agent, segment, transaction })
@@ -319,13 +359,12 @@ function handleResponse({ shim, err, response, segment, transaction, bedrockComm
319
359
  * @param {object} config AWS configuration object
320
360
  * @param {function} next the next middleware function in stack
321
361
  * @param {object} context AWS client context info
362
+ *
363
+ * @returns {function} the next middleware function in stack
322
364
  */
323
365
  function bedrockMiddleware(shim, config, next, context) {
324
366
  const { commandName } = context
325
- if (
326
- commandName === 'InvokeModelCommand' ||
327
- commandName === 'InvokeModelWithResponseStreamCommand'
328
- ) {
367
+ if (INSTRUMENTED_COMMANDS.has(commandName)) {
329
368
  return shim.record(next, getBedrockSpec.bind(null, { commandName }))
330
369
  }
331
370
 
@@ -394,10 +394,10 @@ module.exports = function initialize(agent, openai, moduleName, shim) {
394
394
  }
395
395
  )
396
396
 
397
- // New API introduced in openai@5.0.0
397
+ // New API introduced in openai@4.87.0
398
398
  // Also instruments chat completion creation
399
399
  // and creates the LLM events
400
- if (semver.gte(shim.pkgVersion, '5.0.0')) {
400
+ if (semver.gte(shim.pkgVersion, '4.87.0')) {
401
401
  shim.record(openaiClient.Responses.prototype, 'create', function wrapCreate(shim, create, name, args) {
402
402
  const [request] = args
403
403
 
@@ -5,25 +5,43 @@
5
5
 
6
6
  'use strict'
7
7
 
8
- const { stringifyClaudeChunkedMessage } = require('./utils')
8
+ const { stringifyClaudeChunkedMessage, stringifyConverseChunkedMessage } = require('./utils')
9
9
 
10
10
  /**
11
- * Parses an AWS invoke command instance into a re-usable entity.
11
+ * Parses an AWS Bedrock command instance into a re-usable entity,
12
+ * unifying logic for both InvokeModel and Converse commands.
12
13
  */
13
14
  class BedrockCommand {
14
15
  #input
15
- #body
16
16
  #modelId
17
+ #body
18
+ #messages
19
+ #isConverseCommand
17
20
 
18
21
  /**
19
- * @param {object} input The `input` property from an InvokeModelCommand or
20
- * InvokeModelWithResponseStreamCommand instance that is used for the
21
- * conversation.
22
+ * @param {object} input The `input` property from an InvokeModelCommand,
23
+ * InvokeModelWithResponseStreamCommand, ConverseCommand, or
24
+ * ConverseStreamCommand instance.
22
25
  */
23
26
  constructor(input) {
24
27
  this.#input = input
25
- this.#body = JSON.parse(this.#input.body)
26
28
  this.#modelId = this.#input.modelId?.toLowerCase() ?? ''
29
+
30
+ if (Object.hasOwn(input, 'body') === true) {
31
+ this.#body = JSON.parse(this.#input.body)
32
+ this.#isConverseCommand = false
33
+ } else if (Object.hasOwn(input, 'messages') === true) {
34
+ this.#messages = input.messages
35
+ this.#isConverseCommand = true
36
+ }
37
+ }
38
+
39
+ /**
40
+ *
41
+ * @returns {boolean} True if the command is from the Converse API.
42
+ */
43
+ get isConverse() {
44
+ return this.#isConverseCommand
27
45
  }
28
46
 
29
47
  /**
@@ -32,17 +50,24 @@ class BedrockCommand {
32
50
  * @returns {number|undefined}
33
51
  */
34
52
  get maxTokens() {
35
- let result
36
- if (this.isClaude() === true) {
37
- result = this.#body.max_tokens_to_sample
38
- } else if (this.isClaude3() === true || this.isCohere() === true) {
39
- result = this.#body.max_tokens
40
- } else if (this.isLlama() === true) {
41
- result = this.#body.max_gen_length
42
- } else if (this.isTitan() === true) {
43
- result = this.#body.textGenerationConfig?.maxTokenCount
53
+ if (this.#isConverseCommand) {
54
+ // Logic for Converse
55
+ return this.#input?.inferenceConfig?.maxTokens
56
+ } else {
57
+ // Logic for InvokeModel
58
+ if (this.isClaude() === true) {
59
+ return this.#body.max_tokens_to_sample
60
+ }
61
+ if (this.isClaude3() === true || this.isCohere() === true) {
62
+ return this.#body.max_tokens
63
+ }
64
+ if (this.isLlama() === true) {
65
+ return this.#body.max_gen_length
66
+ }
67
+ if (this.isTitan() === true) {
68
+ return this.#body.textGenerationConfig?.maxTokenCount
69
+ }
44
70
  }
45
- return result
46
71
  }
47
72
 
48
73
  /**
@@ -60,40 +85,53 @@ class BedrockCommand {
60
85
  * @returns {string} One of `embedding` or `completion`.
61
86
  */
62
87
  get modelType() {
63
- // At the moment, this is a simple check. If Amazon ever introduces a
64
- // complex identifier, we can implement a more complicated check.
88
+ // This logic is common to both command types
65
89
  return this.#modelId.toLowerCase().includes('embed') ? 'embedding' : 'completion'
66
90
  }
67
91
 
68
92
  /**
69
93
  * The question posed to the LLM.
70
94
  *
71
- * @returns {object[]} The array of context messages passed to the LLM (or a single user prompt for legacy "non-chat" models)
95
+ * @returns {string|string[]|object[]}
72
96
  */
73
97
  get prompt() {
74
- if (this.isTitan() === true || this.isTitanEmbed() === true) {
75
- return [
76
- {
77
- role: 'user',
78
- content: this.#body.inputText
98
+ if (this.#isConverseCommand) {
99
+ // Logic for Converse
100
+ const result = []
101
+ for (const message of this.#messages) {
102
+ // The `message.content` field is an array of ContentBlock objects.
103
+ // For text messages, the structure is: content: [{ text: '...' }]
104
+ if (Array.isArray(message?.content) === true) {
105
+ result.push({
106
+ role: message.role,
107
+ content: stringifyConverseChunkedMessage(message.content)
108
+ })
79
109
  }
80
- ]
81
- } else if (this.isCohereEmbed() === true) {
82
- return [
83
- {
84
- role: 'user',
85
- content: this.#body.texts.join(' ')
86
- }
87
- ]
88
- } else if (
89
- this.isClaudeTextCompletionApi() === true ||
90
- this.isCohere() === true ||
91
- this.isLlama() === true
110
+ }
111
+ return result
112
+ }
113
+
114
+ // Logic for InvokeModel
115
+ if (this.isTitan() || this.isTitanEmbed()) {
116
+ return [{ role: 'user', content: this.#body.inputText }]
117
+ }
118
+
119
+ if (this.isCohereEmbed()) {
120
+ return [{ role: 'user', content: this.#body.texts.join(' ') }]
121
+ }
122
+
123
+ if (
124
+ this.isClaudeTextCompletionApi(this.#body) ||
125
+ this.isCohere() ||
126
+ this.isLlama()
92
127
  ) {
93
128
  return [{ role: 'user', content: this.#body.prompt }]
94
- } else if (this.isClaudeMessagesApi() === true) {
129
+ }
130
+
131
+ if (this.isClaudeMessagesApi(this.#body)) {
95
132
  return normalizeClaude3Messages(this.#body?.messages)
96
133
  }
134
+
97
135
  return []
98
136
  }
99
137
 
@@ -101,20 +139,25 @@ class BedrockCommand {
101
139
  * @returns {number|undefined}
102
140
  */
103
141
  get temperature() {
104
- let result
142
+ if (this.#isConverseCommand) {
143
+ // Logic for Converse
144
+ return this.#input?.inferenceConfig?.temperature
145
+ }
146
+ // Logic for InvokeModel
105
147
  if (this.isTitan() === true) {
106
- result = this.#body.textGenerationConfig?.temperature
107
- } else if (
148
+ return this.#body.textGenerationConfig?.temperature
149
+ }
150
+ if (
108
151
  this.isClaude() === true ||
109
- this.isClaude3() === true ||
110
- this.isCohere() === true ||
111
- this.isLlama() === true
152
+ this.isClaude3() === true ||
153
+ this.isCohere() === true ||
154
+ this.isLlama() === true
112
155
  ) {
113
- result = this.#body.temperature
156
+ return this.#body.temperature
114
157
  }
115
- return result
116
158
  }
117
159
 
160
+ // Helper methods that depend on modelId (common to both types)
118
161
  isClaude() {
119
162
  return this.#modelId.split('.').slice(-2).join('.').startsWith('anthropic.claude-v')
120
163
  }
@@ -143,21 +186,22 @@ class BedrockCommand {
143
186
  return this.#modelId.startsWith('amazon.titan-embed')
144
187
  }
145
188
 
146
- isClaudeMessagesApi() {
147
- return (this.isClaude3() === true || this.isClaude() === true) && 'messages' in this.#body
189
+ isClaudeMessagesApi(body) {
190
+ return (this.isClaude3() === true || this.isClaude() === true) && 'messages' in body
148
191
  }
149
192
 
150
- isClaudeTextCompletionApi() {
151
- return this.isClaude() === true && 'prompt' in this.#body
193
+ isClaudeTextCompletionApi(body) {
194
+ return this.isClaude() === true && 'prompt' in body
152
195
  }
153
196
  }
154
197
 
155
198
  /**
156
- * Claude v3 requests in Bedrock can have two different "chat" flavors. This function normalizes them into a consistent
199
+ * Claude v3 requests in Bedrock can have two different "chat" flavors.
200
+ * This function normalizes them into a consistent
157
201
  * format per the AIM agent spec
158
202
  *
159
- * @param messages - The raw array of messages passed to the invoke API
160
- * @returns {number|undefined} - The normalized messages
203
+ * @param {Array<object>} messages - The raw array of messages passed to the invoke API
204
+ * @returns {Array<object>} - The normalized messages
161
205
  */
162
206
  function normalizeClaude3Messages(messages) {
163
207
  const result = []
@@ -5,7 +5,7 @@
5
5
 
6
6
  'use strict'
7
7
 
8
- const { stringifyClaudeChunkedMessage } = require('./utils')
8
+ const { stringifyClaudeChunkedMessage, stringifyConverseChunkedMessage } = require('./utils')
9
9
 
10
10
  /**
11
11
  * @typedef {object} AwsBedrockMiddlewareResponse
@@ -18,60 +18,49 @@ const { stringifyClaudeChunkedMessage } = require('./utils')
18
18
  */
19
19
 
20
20
  /**
21
- * Represents a response from the Bedrock API. Given that the API response
22
- * has as many different shapes as the number of models it supports, and the
23
- * fact that responses require byte array processing, this object provides
24
- * an abstraction that normalizes responses into a known interface and
25
- * simplifies accessing desired fields.
21
+ * Represents a response from the Bedrock API, handling both standard and Converse API responses.
22
+ * This object provides an abstraction that normalizes responses into a known interface and
23
+ * simplifies accessing desired fields, accommodating the varied shapes of Bedrock API responses.
26
24
  */
27
25
  class BedrockResponse {
26
+ #originalResponse
28
27
  #innerResponse
29
28
  #innerOutput
30
29
  #parsedBody
30
+ #output
31
31
  #command
32
32
  #completions = []
33
33
  #id
34
+ #isConverse
34
35
 
35
36
  /**
36
37
  * @param {object} params
37
38
  * @param {AwsBedrockMiddlewareResponse} params.response
38
39
  * @param {BedrockCommand} params.bedrockCommand
39
- * @param params.isError
40
+ * @param {boolean} params.isError
40
41
  */
41
42
  constructor({ response, bedrockCommand, isError = false }) {
43
+ this.#originalResponse = response
42
44
  this.#innerResponse = isError ? response.$response : response.response
43
45
  this.#command = bedrockCommand
44
46
  this.isError = isError
47
+ this.#isConverse = this.#command.isConverse ?? false
45
48
 
46
49
  if (this.isError) {
47
50
  return
48
51
  }
49
52
 
50
- this.#innerOutput = response.output
51
-
52
- const json = new TextDecoder().decode(this.#innerOutput.body)
53
- this.#parsedBody = JSON.parse(json)
54
-
55
- const cmd = this.#command
56
- const body = this.#parsedBody
57
- if (cmd.isClaude() === true) {
58
- // TODO: can we make this thing give more than one completion?
59
- body.completion && this.#completions.push(body.completion)
60
- } else if (cmd.isClaude3() === true) {
61
- if (body?.type === 'message_stop') {
62
- // Streamed response
63
- this.#completions = body.completions
64
- } else {
65
- this.#completions = [stringifyClaudeChunkedMessage(body?.content)]
66
- }
67
- this.#id = body.id
68
- } else if (cmd.isCohere() === true) {
69
- this.#completions = body.generations?.map((g) => g.text) ?? []
70
- this.#id = body.id
71
- } else if (cmd.isLlama() === true) {
72
- body.generation && this.#completions.push(body.generation)
73
- } else if (cmd.isTitan() === true) {
74
- this.#completions = body.results?.map((r) => r.outputText) ?? []
53
+ if (this.#isConverse) {
54
+ this.#innerOutput = response.output.output
55
+ this.#output = this.#innerOutput.message
56
+ } else {
57
+ this.#innerOutput = response.output
58
+ const json = new TextDecoder().decode(this.#innerOutput.body)
59
+ this.#parsedBody = JSON.parse(json)
60
+
61
+ const cmd = this.#command
62
+ const body = this.#parsedBody
63
+ this.#extractCompletionsAndId(cmd, body)
75
64
  }
76
65
  }
77
66
 
@@ -82,32 +71,40 @@ class BedrockResponse {
82
71
  * prompt.
83
72
  */
84
73
  get completions() {
85
- return this.#completions
74
+ if (this.#isConverse === false) {
75
+ return this.#completions
76
+ }
77
+ const content = this.#output?.content
78
+ if (!content) return []
79
+ return [typeof content === 'string' ? content : stringifyConverseChunkedMessage(content)]
86
80
  }
87
81
 
88
82
  /**
89
83
  * The reason the model has given for finishing the response.
90
84
  *
91
- * @returns {string|*}
85
+ * @returns {string|undefined}
92
86
  */
93
87
  get finishReason() {
94
- let result
95
-
96
88
  if (this.isError) {
97
- return result
89
+ return undefined
90
+ }
91
+ if (this.#isConverse) {
92
+ return this.#originalResponse.output.stopReason
98
93
  }
99
-
100
94
  const cmd = this.#command
101
95
  if (cmd.isClaude() === true || cmd.isClaude3() === true) {
102
- result = this.#parsedBody.stop_reason
103
- } else if (cmd.isCohere() === true) {
104
- result = this.#parsedBody.generations?.find((r) => r.finish_reason !== null)?.finish_reason
105
- } else if (cmd.isLlama() === true) {
106
- result = this.#parsedBody.stop_reason
107
- } else if (cmd.isTitan() === true) {
108
- result = this.#parsedBody.results?.find((r) => r.completionReason !== null)?.completionReason
96
+ return this.#parsedBody.stop_reason
97
+ }
98
+ if (cmd.isCohere() === true) {
99
+ return this.#parsedBody.generations?.find((r) => r.finish_reason !== null)?.finish_reason
100
+ }
101
+ if (cmd.isLlama() === true) {
102
+ return this.#parsedBody.stop_reason
109
103
  }
110
- return result
104
+ if (cmd.isTitan() === true) {
105
+ return this.#parsedBody.results?.find((r) => r.completionReason !== null)?.completionReason
106
+ }
107
+ return undefined
111
108
  }
112
109
 
113
110
  /**
@@ -125,7 +122,8 @@ class BedrockResponse {
125
122
  * @returns {string|undefined}
126
123
  */
127
124
  get id() {
128
- return this.#id
125
+ // For Converse API, the requestId serves as the id.
126
+ return this.#isConverse ? this.requestId : this.#id
129
127
  }
130
128
 
131
129
  /**
@@ -146,12 +144,30 @@ class BedrockResponse {
146
144
  return this.#innerResponse.statusCode
147
145
  }
148
146
 
149
- #tokenCount(headerName) {
150
- const headerVal = this.headers?.[headerName]
151
- if (headerVal != null) {
152
- return parseInt(headerVal, 10)
147
+ /**
148
+ * Extracts and sets #completions and #id from the InvokeModel response body.
149
+ * @param {BedrockCommand} cmd
150
+ * @param {*} body
151
+ */
152
+ #extractCompletionsAndId(cmd, body) {
153
+ if (cmd.isClaude() === true) {
154
+ body.completion && this.#completions.push(body.completion)
155
+ } else if (cmd.isClaude3() === true) {
156
+ if (body?.type === 'message_stop') {
157
+ // Streamed response
158
+ this.#completions = body.completions
159
+ } else {
160
+ this.#completions = [stringifyClaudeChunkedMessage(body?.content)]
161
+ }
162
+ this.#id = body.id
163
+ } else if (cmd.isCohere() === true) {
164
+ this.#completions = body.generations?.map((g) => g.text) ?? []
165
+ this.#id = body.id
166
+ } else if (cmd.isLlama() === true) {
167
+ body.generation && this.#completions.push(body.generation)
168
+ } else if (cmd.isTitan() === true) {
169
+ this.#completions = body.results?.map((r) => r.outputText) ?? []
153
170
  }
154
- return undefined
155
171
  }
156
172
  }
157
173
 
@@ -57,7 +57,7 @@ class LlmChatCompletionMessage extends LlmEvent {
57
57
 
58
58
  #setId(index) {
59
59
  const cmd = this.bedrockCommand
60
- if (cmd.isTitan() === true || cmd.isClaude() === true) {
60
+ if (cmd.isConverse || cmd.isTitan() === true || cmd.isClaude() === true) {
61
61
  this.id = `${this.id}-${index}`
62
62
  } else if (cmd.isCohere() === true) {
63
63
  this.id = `${this.bedrockResponse.id || this.id}-${index}`
@@ -27,16 +27,14 @@ class LlmChatCompletionSummary extends LlmEvent {
27
27
  const { segment, isError } = params
28
28
  this.error = isError
29
29
  this.duration = segment.getDurationInMillis()
30
- this['request.max_tokens'] = this.bedrockCommand.maxTokens
31
-
32
- const nm = 'response.number_of_messages'
33
- const cfr = 'response.choices.finish_reason'
34
- const rt = 'request.temperature'
35
30
 
36
31
  const cmd = this.bedrockCommand
37
- this[cfr] = this.bedrockResponse.finishReason
38
- this[rt] = cmd.temperature
39
- this[nm] = (this.bedrockCommand.prompt.length) + this.bedrockResponse.completions.length
32
+ const res = this.bedrockResponse
33
+
34
+ this['request.max_tokens'] = cmd.maxTokens
35
+ this['response.choices.finish_reason'] = res.finishReason
36
+ this['request.temperature'] = cmd.temperature
37
+ this['response.number_of_messages'] = (cmd.prompt.length ?? 0) + (res.completions.length ?? 0)
40
38
  }
41
39
  }
42
40
 
@@ -0,0 +1,155 @@
1
+ /*
2
+ * Copyright 2025 New Relic Corporation. All rights reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ 'use strict'
7
+
8
+ /**
9
+ * A stream handler processes a streamed response from the Bedrock API by
10
+ * intercepting each stream event, passing that event unmodified up to the
11
+ * consuming client, and then collecting the information into an "API response"
12
+ * object that satisfies the requirements of {@link BedrockResponse}. Once the
13
+ * stream has reached its end, the handler applies some finalizations to the
14
+ * compiled response object, updates the pass through parameters with the new
15
+ * object, and then invokes the final response handler passed in through
16
+ * `onComplete`.
17
+ */
18
+ class ConverseStreamHandler {
19
+ // We have to make most of the properties of this object public so that
20
+ // the at-construction-time attached generator function can access them
21
+ // through the `this` reference. See https://jrfom.com/posts/2023/10/31/js-classes/
22
+ // for details on why this is necessary.
23
+
24
+ /**
25
+ * The parameters to pass through to the `onComplete` function once the
26
+ * stream has been processed. The `response` property on this object will
27
+ * be overwritten with the response object that has been compiled by the
28
+ * stream handler.
29
+ */
30
+ passThroughParams
31
+
32
+ /**
33
+ * The original async iterable returned from the AWS SDK.
34
+ */
35
+ stream
36
+
37
+ /**
38
+ * The New Relic agent's internal trace segment. The `.touch` method will be
39
+ * used to mark the end time of the stream processing.
40
+ */
41
+ segment
42
+
43
+ /**
44
+ * For streaming messages, we want to capture the effective message chunks like we would see in the non-streaming API
45
+ */
46
+ observedChunks = []
47
+
48
+ stopReason
49
+
50
+ /**
51
+ * Represents an API response object as {@link BedrockResponse} expects.
52
+ * It will be updated by the stream handler with information received
53
+ * during processing of the stream. Upon stream completion, this object
54
+ * will replace the `response` property of `passThroughParams`.
55
+ *
56
+ * @type {object}
57
+ */
58
+ response = {
59
+ response: {
60
+ headers: {},
61
+ statusCode: 200
62
+ },
63
+ output: {
64
+ output: {
65
+ message: {
66
+
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * The function that will be invoked once the stream processing has finished.
74
+ * It will receive `this.passThroughParams` as the only parameter.
75
+ */
76
+ onComplete
77
+
78
+ constructor({ stream, passThroughParams, onComplete }) {
79
+ this.passThroughParams = passThroughParams
80
+ this.stream = stream
81
+ this.onComplete = onComplete
82
+ this.segment = passThroughParams.segment
83
+ this.generator = handleConverse
84
+ }
85
+
86
+ /**
87
+ * Encodes the output body into a Uint8Array, updates the pass through
88
+ * parameters with the compiled response object, and invokes the response
89
+ * handler. The trace segment is also updated to mark the end of the stream
90
+ * processing and account for the time it took to process the stream within
91
+ * the trace.
92
+ */
93
+ finish() {
94
+ this.passThroughParams.response = this.response
95
+ this.onComplete(this.passThroughParams)
96
+
97
+ this.segment.touch()
98
+ }
99
+
100
+ /**
101
+ * If the given event is the last event in the stream, update the response
102
+ * headers with the metrics data from the event.
103
+ *
104
+ * @param {object} parsedEvent
105
+ */
106
+ updateHeaders(parsedEvent) {
107
+ this.response.response.headers = {
108
+ 'x-amzn-requestid': this.passThroughParams.response.response.headers['x-amzn-requestid']
109
+ }
110
+ delete parsedEvent['amazon-bedrock-invocationMetrics']
111
+ }
112
+ }
113
+
114
+ // eslint-disable-next-line sonarjs/cognitive-complexity
115
+ async function * handleConverse() {
116
+ let activeChunk = null
117
+
118
+ try {
119
+ for await (const event of this.stream) {
120
+ yield event
121
+ this.updateHeaders(event)
122
+ if (event.messageStart?.role) {
123
+ this.role = 'assistant'
124
+ } else if (event.contentBlockStart?.start) {
125
+ // Handles a Content block start event. Tool use only.
126
+ const blockStartData = event.contentBlockStart.start
127
+ if (blockStartData.toolUse) {
128
+ activeChunk = { toolUse: { name: blockStartData.toolUse.name } }
129
+ }
130
+ } else if (event.contentBlockStop) {
131
+ if (activeChunk !== null) {
132
+ this.observedChunks.push(activeChunk)
133
+ activeChunk = null
134
+ }
135
+ } else if (event.contentBlockDelta?.delta) {
136
+ if (event.contentBlockDelta.delta.text) {
137
+ // It seems like the first streamed chunk does not always start with a contentBlockStart message
138
+ // If the stream starts with a delta, assume the current chunk is text
139
+ if (activeChunk === null) {
140
+ activeChunk = { text: '' }
141
+ }
142
+ activeChunk.text += event.contentBlockDelta.delta.text
143
+ }
144
+ // There are also deltas for tool use (stringified inputs) but we don't currently record them so we just ignore for now
145
+ } else if (event.messageStop) {
146
+ this.stopReason = event.messageStop?.stopReason
147
+ }
148
+ }
149
+ } finally {
150
+ this.response.output.output.message.content = this.observedChunks
151
+ this.finish()
152
+ }
153
+ }
154
+
155
+ module.exports = ConverseStreamHandler
@@ -13,5 +13,6 @@ module.exports = {
13
13
  LlmEmbedding: require('./embedding'),
14
14
  LlmEvent: require('./event'),
15
15
  LlmError: require('./error'),
16
- StreamHandler: require('./stream-handler')
16
+ StreamHandler: require('./stream-handler'),
17
+ ConverseStreamHandler: require('./converse-stream-handler')
17
18
  }
@@ -31,6 +31,43 @@ function stringifyClaudeChunkedMessage(chunks) {
31
31
  return stringifiedChunks.join('\n\n')
32
32
  }
33
33
 
34
+ /**
35
+ *
36
+ * @param {object[]} chunks - The "chunks" that make up a single conceptual message. In a multi-modal scenario, a single message
37
+ * might have a number of different-typed chunks interspersed
38
+ * @returns {string} - A stringified version of the message. We make a best-effort effort attempt to represent non-text chunks. In the future
39
+ * we may want to extend the agent to support these non-text chunks in a richer way. Placeholders are represented in an XML-like format but
40
+ * are NOT intended to be parsed as valid XML
41
+ */
42
+ function stringifyConverseChunkedMessage(chunks) {
43
+ const stringifiedChunks = chunks.map((chunk) => {
44
+ if ('text' in chunk) {
45
+ return chunk.text
46
+ } else if ('image' in chunk) {
47
+ return '<image>'
48
+ } else if ('document' in chunk) {
49
+ return `<document>${chunk.document.name ?? ''}</document>`
50
+ } else if ('toolUse' in chunk) {
51
+ return `<tool_use>${chunk.toolUse?.name ?? ''}</tool_use>`
52
+ } else if ('json' in chunk) {
53
+ return `<json>${JSON.stringify(chunk.json)}</json>`
54
+ } else if ('toolResult' in chunk) {
55
+ // Tool results can have their own chunks. For now we intentionally don't recurse in for non-text tool results
56
+ return `<tool_result>${
57
+ stringifyConverseChunkedMessage(
58
+ (chunk.toolResult.content ?? [])
59
+ .filter(subChunk => !('toolUse' in subChunk || 'toolResult' in subChunk))
60
+ )
61
+ }</tool_result>`
62
+ } else if ('guardContent' in chunk) {
63
+ return `<guard_content>${chunk.guardContent?.text ?? ''}</guard_content>`
64
+ }
65
+ return '<unknown_chunk>'
66
+ })
67
+ return stringifiedChunks.join('\n\n')
68
+ }
69
+
34
70
  module.exports = {
35
- stringifyClaudeChunkedMessage
71
+ stringifyClaudeChunkedMessage,
72
+ stringifyConverseChunkedMessage
36
73
  }
package/lib/shim/shim.js CHANGED
@@ -2030,7 +2030,7 @@ function wrapStreamEmit({ stream, shim, segment, specEvent, shouldCreateSegment,
2030
2030
  const newContext = context.enterSegment({ segment: eventSegment })
2031
2031
  eventBoundEmit = shim.bindContext({ nodule: emit, full: true, context: newContext })
2032
2032
  }
2033
- eventSegment.addAttribute('count', ++emitCount)
2033
+ if (eventSegment) eventSegment.addAttribute('count', ++emitCount)
2034
2034
  emitToCall = eventBoundEmit
2035
2035
  }
2036
2036
  if (evnt === 'end' || evnt === 'error') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newrelic",
3
- "version": "12.24.0",
3
+ "version": "12.25.0",
4
4
  "author": "New Relic Node.js agent team <nodejs@newrelic.com>",
5
5
  "license": "Apache-2.0",
6
6
  "contributors": [