newrelic 12.11.1 → 12.11.2

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,25 @@
1
+ ### v12.11.2 (2025-01-23)
2
+
3
+ #### Features
4
+
5
+ * Added support for Claude 3+ Chat API in Bedrock ([#2870](https://github.com/newrelic/node-newrelic/pull/2870)) ([6a83abf](https://github.com/newrelic/node-newrelic/commit/6a83abf8b1a0cb0f993e9d9285566a09677d7d2e))
6
+
7
+ #### Bug fixes
8
+
9
+ * Added defensive code in shim to prevent crashing when checking parent segment ([#2898](https://github.com/newrelic/node-newrelic/pull/2898)) ([751eb96](https://github.com/newrelic/node-newrelic/commit/751eb9608d7401d1123a0e810eac9dc554127e4e))
10
+
11
+ #### Documentation
12
+
13
+ * Updated compatibility report ([#2888](https://github.com/newrelic/node-newrelic/pull/2888)) ([dce9deb](https://github.com/newrelic/node-newrelic/commit/dce9debeda6c5ed30c0ed6dbc00f73cf11c8f40f))
14
+
15
+ #### Miscellaneous chores
16
+
17
+ * Replaced backtracking regex with new algorithm ([#2887](https://github.com/newrelic/node-newrelic/pull/2887)) ([46462d0](https://github.com/newrelic/node-newrelic/commit/46462d00b68c4a4cedd60d46d531b2f31800df98))
18
+
19
+ #### Tests
20
+
21
+ * Improved test coverage of normalizing claude 3 messages ([#2893](https://github.com/newrelic/node-newrelic/pull/2893)) ([cc65763](https://github.com/newrelic/node-newrelic/commit/cc657633b81daf4b372e5715e696ed3c23ecb678))
22
+
1
23
  ### v12.11.1 (2025-01-16)
2
24
 
3
25
  #### Bug fixes
@@ -95,10 +95,10 @@ function _headerToCamelCase(header) {
95
95
  const newHeader = header.charAt(0).toLowerCase() + header.slice(1)
96
96
 
97
97
  // Converts headers in the form 'header-name' to be in the form 'headerName'
98
- // eslint-disable-next-line sonarjs/slow-regex
99
- return newHeader.replace(/[\W_]+(\w)/g, function capitalize(m, $1) {
100
- return $1.toUpperCase()
101
- })
98
+ return newHeader.split(/[\W_]/).map((ele, i) => {
99
+ if (i === 0) return ele
100
+ return ele.slice(0, 1).toUpperCase() + ele.slice(1)
101
+ }).join('')
102
102
  }
103
103
 
104
104
  function _collectHeaders(headers, nameMap, prefix, transaction) {
@@ -118,18 +118,24 @@ function recordChatCompletionMessages({
118
118
  isError: err !== null
119
119
  })
120
120
 
121
- const msg = new LlmChatCompletionMessage({
122
- agent,
123
- segment,
124
- bedrockCommand,
125
- bedrockResponse,
126
- transaction,
127
- index: 0,
128
- completionId: summary.id
121
+ // Record context message(s)
122
+ const promptContextMessages = bedrockCommand.prompt
123
+ promptContextMessages.forEach((contextMessage, promptIndex) => {
124
+ const msg = new LlmChatCompletionMessage({
125
+ agent,
126
+ segment,
127
+ transaction,
128
+ bedrockCommand,
129
+ content: contextMessage.content,
130
+ role: contextMessage.role,
131
+ bedrockResponse,
132
+ index: promptIndex,
133
+ completionId: summary.id
134
+ })
135
+ recordEvent({ agent, type: 'LlmChatCompletionMessage', msg })
129
136
  })
130
- recordEvent({ agent, type: 'LlmChatCompletionMessage', msg })
131
137
 
132
- bedrockResponse.completions.forEach((content, index) => {
138
+ bedrockResponse.completions.forEach((content, completionIndex) => {
133
139
  const chatCompletionMessage = new LlmChatCompletionMessage({
134
140
  agent,
135
141
  segment,
@@ -137,8 +143,9 @@ function recordChatCompletionMessages({
137
143
  bedrockCommand,
138
144
  bedrockResponse,
139
145
  isResponse: true,
140
- index: index + 1,
146
+ index: promptContextMessages.length + completionIndex,
141
147
  content,
148
+ role: 'assistant',
142
149
  completionId: summary.id
143
150
  })
144
151
  recordEvent({ agent, type: 'LlmChatCompletionMessage', msg: chatCompletionMessage })
@@ -179,18 +186,22 @@ function recordEmbeddingMessage({
179
186
  return
180
187
  }
181
188
 
182
- const embedding = new LlmEmbedding({
189
+ const embeddings = bedrockCommand.prompt.map(prompt => new LlmEmbedding({
183
190
  agent,
184
191
  segment,
185
192
  transaction,
186
193
  bedrockCommand,
194
+ input: prompt.content,
187
195
  bedrockResponse,
188
196
  isError: err !== null
197
+ }))
198
+
199
+ embeddings.forEach(embedding => {
200
+ recordEvent({ agent, type: 'LlmEmbedding', msg: embedding })
189
201
  })
190
202
 
191
- recordEvent({ agent, type: 'LlmEmbedding', msg: embedding })
192
203
  if (err) {
193
- const llmError = new LlmError({ bedrockResponse, err, embedding })
204
+ const llmError = new LlmError({ bedrockResponse, err, embedding: embeddings.length === 1 ? embeddings[0] : undefined })
194
205
  agent.errors.add(transaction, err, llmError)
195
206
  }
196
207
  }
@@ -5,6 +5,8 @@
5
5
 
6
6
  'use strict'
7
7
 
8
+ const { stringifyClaudeChunkedMessage } = require('./utils')
9
+
8
10
  /**
9
11
  * Parses an AWS invoke command instance into a re-usable entity.
10
12
  */
@@ -68,37 +70,34 @@ class BedrockCommand {
68
70
  /**
69
71
  * The question posed to the LLM.
70
72
  *
71
- * @returns {string|string[]|undefined}
73
+ * @returns {object[]} The array of context messages passed to the LLM (or a single user prompt for legacy "non-chat" models)
72
74
  */
73
75
  get prompt() {
74
- let result
75
76
  if (this.isTitan() === true || this.isTitanEmbed() === true) {
76
- result = this.#body.inputText
77
+ return [
78
+ {
79
+ role: 'user',
80
+ content: this.#body.inputText
81
+ }
82
+ ]
77
83
  } else if (this.isCohereEmbed() === true) {
78
- result = this.#body.texts.join(' ')
84
+ return [
85
+ {
86
+ role: 'user',
87
+ content: this.#body.texts.join(' ')
88
+ }
89
+ ]
79
90
  } else if (
80
- this.isClaude() === true ||
91
+ this.isClaudeTextCompletionApi() === true ||
81
92
  this.isAi21() === true ||
82
93
  this.isCohere() === true ||
83
94
  this.isLlama() === true
84
95
  ) {
85
- result = this.#body.prompt
86
- } else if (this.isClaude3() === true) {
87
- const collected = []
88
- for (const message of this.#body?.messages) {
89
- if (message?.role === 'assistant') {
90
- continue
91
- }
92
- if (typeof message?.content === 'string') {
93
- collected.push(message?.content)
94
- continue
95
- }
96
- const mappedMsgObj = message?.content.map((msgContent) => msgContent.text)
97
- collected.push(mappedMsgObj)
98
- }
99
- result = collected.join(' ')
96
+ return [{ role: 'user', content: this.#body.prompt }]
97
+ } else if (this.isClaudeMessagesApi() === true) {
98
+ return normalizeClaude3Messages(this.#body?.messages)
100
99
  }
101
- return result
100
+ return []
102
101
  }
103
102
 
104
103
  /**
@@ -151,6 +150,41 @@ class BedrockCommand {
151
150
  isTitanEmbed() {
152
151
  return this.#modelId.startsWith('amazon.titan-embed')
153
152
  }
153
+
154
+ isClaudeMessagesApi() {
155
+ return (this.isClaude3() === true || this.isClaude() === true) && 'messages' in this.#body
156
+ }
157
+
158
+ isClaudeTextCompletionApi() {
159
+ return this.isClaude() === true && 'prompt' in this.#body
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Claude v3 requests in Bedrock can have two different "chat" flavors. This function normalizes them into a consistent
165
+ * format per the AIM agent spec
166
+ *
167
+ * @param messages - The raw array of messages passed to the invoke API
168
+ * @returns {number|undefined} - The normalized messages
169
+ */
170
+ function normalizeClaude3Messages(messages) {
171
+ const result = []
172
+ for (const message of messages ?? []) {
173
+ if (message == null) {
174
+ continue
175
+ }
176
+ if (typeof message.content === 'string') {
177
+ // Messages can be specified with plain string content
178
+ result.push({ role: message.role, content: message.content })
179
+ } else if (Array.isArray(message.content)) {
180
+ // Or in a "chunked" format for multi-modal support
181
+ result.push({
182
+ role: message.role,
183
+ content: stringifyClaudeChunkedMessage(message.content)
184
+ })
185
+ }
186
+ }
187
+ return result
154
188
  }
155
189
 
156
190
  module.exports = BedrockCommand
@@ -5,6 +5,8 @@
5
5
 
6
6
  'use strict'
7
7
 
8
+ const { stringifyClaudeChunkedMessage } = require('./utils')
9
+
8
10
  /**
9
11
  * @typedef {object} AwsBedrockMiddlewareResponse
10
12
  * @property {object} response Has a `body` property that is an IncomingMessage,
@@ -63,7 +65,7 @@ class BedrockResponse {
63
65
  // Streamed response
64
66
  this.#completions = body.completions
65
67
  } else {
66
- this.#completions = body?.content?.map((c) => c.text)
68
+ this.#completions = [stringifyClaudeChunkedMessage(body?.content)]
67
69
  }
68
70
  this.#id = body.id
69
71
  } else if (cmd.isCohere() === true) {
@@ -39,7 +39,7 @@ class LlmChatCompletionMessage extends LlmEvent {
39
39
  params = Object.assign({}, defaultParams, params)
40
40
  super(params)
41
41
 
42
- const { agent, content, isResponse, index, completionId } = params
42
+ const { agent, content, isResponse, index, completionId, role } = params
43
43
  const recordContent = agent.config?.ai_monitoring?.record_content?.enabled
44
44
  const tokenCB = agent?.llm?.tokenCountCallback
45
45
 
@@ -47,20 +47,11 @@ class LlmChatCompletionMessage extends LlmEvent {
47
47
  this.completion_id = completionId
48
48
  this.sequence = index
49
49
  this.content = recordContent === true ? content : undefined
50
- this.role = ''
50
+ this.role = role
51
51
 
52
52
  this.#setId(index)
53
- if (this.is_response === true) {
54
- this.role = 'assistant'
55
- if (typeof tokenCB === 'function') {
56
- this.token_count = tokenCB(this.bedrockCommand.modelId, content)
57
- }
58
- } else {
59
- this.role = 'user'
60
- this.content = recordContent === true ? this.bedrockCommand.prompt : undefined
61
- if (typeof tokenCB === 'function') {
62
- this.token_count = tokenCB(this.bedrockCommand.modelId, this.bedrockCommand.prompt)
63
- }
53
+ if (typeof tokenCB === 'function') {
54
+ this.token_count = tokenCB(this.bedrockCommand.modelId, content)
64
55
  }
65
56
  }
66
57
 
@@ -36,7 +36,7 @@ class LlmChatCompletionSummary extends LlmEvent {
36
36
  const cmd = this.bedrockCommand
37
37
  this[cfr] = this.bedrockResponse.finishReason
38
38
  this[rt] = cmd.temperature
39
- this[nm] = 1 + this.bedrockResponse.completions.length
39
+ this[nm] = (this.bedrockCommand.prompt.length) + this.bedrockResponse.completions.length
40
40
  }
41
41
  }
42
42
 
@@ -10,7 +10,7 @@ const LlmEvent = require('./event')
10
10
  /**
11
11
  * @typedef {object} LlmEmbeddingParams
12
12
  * @augments LlmEventParams
13
- * @property
13
+ * @property {string} input - The input message for the embedding call
14
14
  */
15
15
  /**
16
16
  * @type {LlmEmbeddingParams}
@@ -20,16 +20,18 @@ const defaultParams = {}
20
20
  class LlmEmbedding extends LlmEvent {
21
21
  constructor(params = defaultParams) {
22
22
  super(params)
23
- const { agent } = params
23
+ const { agent, input } = params
24
24
  const tokenCb = agent?.llm?.tokenCountCallback
25
25
 
26
26
  this.input = agent.config?.ai_monitoring?.record_content?.enabled
27
- ? this.bedrockCommand.prompt
27
+ ? input
28
28
  : undefined
29
29
  this.error = params.isError
30
30
  this.duration = params.segment.getDurationInMillis()
31
+
32
+ // Even if not recording content, we should use the local token counting callback to record token usage
31
33
  if (typeof tokenCb === 'function') {
32
- this.token_count = tokenCb(this.bedrockCommand.modelId, this.bedrockCommand.prompt)
34
+ this.token_count = tokenCb(this.bedrockCommand.modelId, input)
33
35
  }
34
36
  }
35
37
  }
@@ -0,0 +1,36 @@
1
+ /*
2
+ * Copyright 2024 New Relic Corporation. All rights reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ 'use strict'
7
+
8
+ /**
9
+ *
10
+ * @param {object[]} chunks - The "chunks" that make up a single conceptual message. In a multi-modal scenario, a single message
11
+ * might have a number of different-typed chunks interspersed
12
+ * @returns {string} - A stringified version of the message. We make a best-effort effort attempt to represent non-text chunks. In the future
13
+ * 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
14
+ * are NOT intended to be parsed as valid XML
15
+ */
16
+ function stringifyClaudeChunkedMessage(chunks) {
17
+ const stringifiedChunks = chunks.map((msgContent) => {
18
+ switch (msgContent.type) {
19
+ case 'text':
20
+ return msgContent.text
21
+ case 'image':
22
+ return '<image>'
23
+ case 'tool_use':
24
+ return `<tool_use>${msgContent.name}</tool_use>`
25
+ case 'tool_result':
26
+ return `<tool_result>${msgContent.content}</tool_result>`
27
+ default:
28
+ return '<unknown_chunk>'
29
+ }
30
+ })
31
+ return stringifiedChunks.join('\n\n')
32
+ }
33
+
34
+ module.exports = {
35
+ stringifyClaudeChunkedMessage
36
+ }
package/lib/shim/shim.js CHANGED
@@ -677,7 +677,7 @@ function recordWrapper({ shim, fn, name, recordNamer }) {
677
677
  return fnApply.call(fn, this, args)
678
678
  }
679
679
 
680
- // middleweare recorders pass in parent segment
680
+ // middleware recorders pass in parent segment
681
681
  // we need to destructure this as it is not needed past this function
682
682
  // and will overwhelm trace level loggers with logging the entire spec
683
683
  const { parent: specParent, ...segDesc } = spec
@@ -686,7 +686,7 @@ function recordWrapper({ shim, fn, name, recordNamer }) {
686
686
  const transaction = context.transaction
687
687
  const parent = transaction?.isActive() && specParent ? specParent : context.segment
688
688
 
689
- if (!transaction?.isActive()) {
689
+ if (!transaction?.isActive() || !parent) {
690
690
  shim.logger.debug('Not recording function %s, not in a transaction.', name)
691
691
  return fnApply.call(fn, this, arguments)
692
692
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newrelic",
3
- "version": "12.11.1",
3
+ "version": "12.11.2",
4
4
  "author": "New Relic Node.js agent team <nodejs@newrelic.com>",
5
5
  "license": "Apache-2.0",
6
6
  "contributors": [