newrelic 13.11.0 → 13.12.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
+ ### v13.12.0 (2026-02-02)
2
+
3
+ #### Features
4
+
5
+ * Added instrumentation support for `@langchain/langgraph` ([#3645](https://github.com/newrelic/node-newrelic/pull/3645)) ([f339675](https://github.com/newrelic/node-newrelic/commit/f3396754fb182a3f0af09488a8abb1981b6f3e3f))
6
+ * Added `timestamp` to `LlmChatCompletionSummary` messages
7
+ * Added `timestamp` to AWS Bedrock `LlmChatCompletionSummary` ([#3702](https://github.com/newrelic/node-newrelic/pull/3702)) ([430d1dd](https://github.com/newrelic/node-newrelic/commit/430d1dd5ef847203045486a054142df3a02c7c1c))
8
+ * Added `timestamp` to Google Gen AI `LlmChatCompletionSummary` ([#3690](https://github.com/newrelic/node-newrelic/pull/3690)) ([7748e26](https://github.com/newrelic/node-newrelic/commit/7748e26aceb7b050b13dc686c852513026f043ac))
9
+ * Added `timestamp` to LangChain `LlmChatCompletionSummary` ([#3701](https://github.com/newrelic/node-newrelic/pull/3701)) ([7472118](https://github.com/newrelic/node-newrelic/commit/747211814396e4bf52dc63d249c0ec045b35457a))
10
+ * Added compact mode for span links ([#3681](https://github.com/newrelic/node-newrelic/pull/3681)) ([6ff6961](https://github.com/newrelic/node-newrelic/commit/6ff6961e6b46feb14638da97a95d0891a0d88645))
11
+
12
+ #### Bug fixes
13
+
14
+ * Updated langchain tool instrumentation to properly redefine the segment name on every call ([#3691](https://github.com/newrelic/node-newrelic/pull/3691)) ([4df6068](https://github.com/newrelic/node-newrelic/commit/4df6068d23b5da276486bd3d3f0c3af4b748c4a8))
15
+
16
+ #### Documentation
17
+
18
+ * Updated compatibility report ([#3699](https://github.com/newrelic/node-newrelic/pull/3699)) ([40b6b81](https://github.com/newrelic/node-newrelic/commit/40b6b813e7b6490c2e866a33cb1a7492b35d2a36))
19
+
1
20
  ### v13.11.0 (2026-01-29)
2
21
 
3
22
  #### Features
@@ -37,6 +37,7 @@ class LlmChatCompletionSummary extends LlmEvent {
37
37
  this['request.temperature'] = cmd.temperature
38
38
  this['response.number_of_messages'] = (cmd.prompt.length ?? 0) + (res.completions.length ?? 0)
39
39
 
40
+ this.timestamp = segment.timer.start
40
41
  this.setTokens(agent)
41
42
  }
42
43
 
@@ -21,20 +21,26 @@ module.exports = class LlmErrorMessage {
21
21
  * @param {LlmVectorStoreSearch} [params.vectorsearch] Details about the vector
22
22
  * search if it was a vector search event.
23
23
  * @param {LlmTool} [params.tool] Details about the tool event if it was a tool event.
24
+ * @param {object} [params.aiAgent] Details about the AI agent event if it was an AI agent event.
24
25
  * @param {boolean} [params.useNameAsCode] defaults to false, only Bedrock sets it to true so far
25
26
  */
26
- constructor({ response, cause, summary = {}, embedding = {}, vectorsearch = {}, tool = {}, useNameAsCode = false } = {}) {
27
+ constructor({ response, cause, summary = {}, embedding = {}, vectorsearch = {}, tool = {}, aiAgent = {}, useNameAsCode = false } = {}) {
27
28
  this['http.statusCode'] = response?.statusCode ?? response?.status ?? cause?.status
28
29
  this['error.message'] = cause?.message
29
- this['error.code'] = response?.code ?? cause?.error?.code
30
+ this['error.code'] = response?.code ?? cause?.error?.code ?? cause?.code
30
31
  if (useNameAsCode) {
31
32
  this['error.code'] = cause?.name
32
33
  }
34
+ if (cause?.['lc_error_code']) {
35
+ // this is where langchain error codes live
36
+ this['error.code'] = cause['lc_error_code']
37
+ }
33
38
  this['error.param'] = response?.param ?? cause?.error?.param
34
39
  this.completion_id = summary?.id
35
40
  this.embedding_id = embedding?.id
36
41
  this.vector_store_id = vectorsearch?.id
37
42
  this.tool_id = tool?.id
43
+ this.agent_id = aiAgent?.id
38
44
 
39
45
  if (embedding?.vendor === 'gemini' || summary?.vendor === 'gemini') {
40
46
  this._handleGemini(cause)
@@ -21,6 +21,7 @@ module.exports = class LlmChatCompletionSummary extends LlmEvent {
21
21
  this['request.max_tokens'] = request.config?.maxOutputTokens
22
22
  this['request.temperature'] = request.config?.temperature
23
23
 
24
+ this.timestamp = segment.timer.start
24
25
  this.setTokens(agent, request, response)
25
26
  }
26
27
 
@@ -33,8 +33,9 @@ class LangChainCompletionSummary extends LangChainEvent {
33
33
  super(params)
34
34
  const { segment } = params
35
35
 
36
- this.duration = segment?.getDurationInMillis()
36
+ this.duration = segment.getDurationInMillis()
37
37
  this['response.number_of_messages'] = params.messages?.length
38
+ this.timestamp = segment.timer.start
38
39
  }
39
40
  }
40
41
 
@@ -0,0 +1,41 @@
1
+ /*
2
+ * Copyright 2026 New Relic Corporation. All rights reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ const BaseLlmEvent = require('../event')
7
+ const { makeId } = require('../../util/hashes')
8
+
9
+ /**
10
+ * @typedef {object} LangGraphAgentEventParams
11
+ * @property {Agent} agent The New Relic agent instance.
12
+ * @property {string} name The name of the LangGraph agent, defaults to 'agent'.
13
+ * @property {object} segment The associated NR segment.
14
+ * @property {object} transaction The associated NR transaction.
15
+ * @property {boolean} error A boolean flag to indicate if an error occurred.
16
+ */
17
+
18
+ module.exports = class LangGraphAgentEvent extends BaseLlmEvent {
19
+ id = makeId(36)
20
+ span_id
21
+ trace_id
22
+ ingest_source = 'Node'
23
+ vendor = 'langgraph'
24
+
25
+ /**
26
+ * @param {LangGraphAgentEventParams} params should contain all necessary and optional LangGraph data
27
+ */
28
+ constructor(params) {
29
+ super(params)
30
+ const { agent, segment, transaction, error = false, name = 'agent' } = params
31
+
32
+ this.name = name
33
+ this.span_id = segment.id
34
+ this.trace_id = transaction.traceId
35
+ this.error = error
36
+
37
+ // Setting `metadata` as the NR agent instance will allow `BaseLlmEvent`
38
+ // to extract the relevant `llm.<user_defined_metadata>`.
39
+ this.metadata = agent
40
+ }
41
+ }
@@ -171,7 +171,8 @@ const AI = {
171
171
  COMPLETION: 'Llm/completion',
172
172
  TOOL: 'Llm/tool',
173
173
  CHAIN: 'Llm/chain',
174
- VECTORSTORE: 'Llm/vectorstore'
174
+ VECTORSTORE: 'Llm/vectorstore',
175
+ AGENT: 'Llm/agent'
175
176
  }
176
177
 
177
178
  AI.GEMINI = {
@@ -199,6 +200,11 @@ AI.LANGCHAIN = {
199
200
  VECTORSTORE: `${AI.VECTORSTORE}/LangChain`
200
201
  }
201
202
 
203
+ AI.LANGGRAPH = {
204
+ TRACKING_PREFIX: `${AI.TRACKING_PREFIX}/LangGraph`,
205
+ AGENT: `${AI.AGENT}/LangGraph`
206
+ }
207
+
202
208
  const MCP = {
203
209
  TRACKING_PREFIX: `${AI.TRACKING_PREFIX}/MCP`,
204
210
  TOOL: `${AI.TOOL}/MCP`,
@@ -318,6 +318,14 @@ class SpanEvent {
318
318
  * @returns {SpanEvent|null} the span after applying the rules, or null if dropped
319
319
  */
320
320
  applyPartialTraceRules({ isEntry, partialTrace }) {
321
+ // in essential and compact mode - we need to remove the non intrinsic attributes from span links early in this process because there can be an entry span,
322
+ // llm span exit span or span with no entity relationship attributes can have non intrinsics on it.
323
+ if (partialTrace.type !== PARTIAL_TYPES.REDUCED) {
324
+ for (const link of this.spanLinks) {
325
+ link.removeNonIntrinsicsAttrs()
326
+ }
327
+ }
328
+
321
329
  if (isEntry) {
322
330
  this.addIntrinsicAttribute('nr.pg', true)
323
331
  logger.trace('Span %s is an entry point, keeping span unchanged.', this.intrinsics.name)
@@ -86,6 +86,11 @@ class SpanLink {
86
86
  filterNulls(this.agentAttributes.get(DESTINATIONS.TRANS_SEGMENT))
87
87
  ]
88
88
  }
89
+
90
+ removeNonIntrinsicsAttrs() {
91
+ this.userAttributes = Object.create(null)
92
+ this.agentAttributes = Object.create(null)
93
+ }
89
94
  }
90
95
 
91
96
  function filterNulls(inputObj) {
@@ -18,6 +18,7 @@ const subscribers = {
18
18
  ...require('./subscribers/ioredis/config'),
19
19
  ...require('./subscribers/iovalkey/config'),
20
20
  ...require('./subscribers/langchain/config'),
21
+ ...require('./subscribers/langgraph/config'),
21
22
  ...require('./subscribers/mcp-sdk/config'),
22
23
  ...require('./subscribers/mysql/config'),
23
24
  ...require('./subscribers/mysql2/config'),
@@ -16,7 +16,6 @@ class LangchainRunnableStreamSubscriber extends LangchainRunnableSubscriber {
16
16
  this.logger.debug('`ai_monitoring.enabled` is set to false, stream will not be instrumented.')
17
17
  return
18
18
  }
19
-
20
19
  if (!this.streamingEnabled) {
21
20
  this.logger.debug('`ai_monitoring.streaming.enabled` is set to false, stream will not be instrumented.')
22
21
  this.agent.metrics.getOrCreateMetric(STREAMING_DISABLED).incrementCallCount()
@@ -24,29 +23,32 @@ class LangchainRunnableStreamSubscriber extends LangchainRunnableSubscriber {
24
23
  }
25
24
 
26
25
  const ctx = this.agent.tracer.getContext()
27
-
28
26
  const { transaction } = ctx
29
27
  if (transaction?.isActive() !== true) {
30
28
  return
31
29
  }
32
30
 
33
- // Extract data.
34
31
  const request = data?.arguments?.[0]
32
+ // Requests via LangGraph API have the `messages` property with the
33
+ // information we need, otherwise it just lives on the `request`
34
+ // object directly.
35
+ const userRequest = request?.messages ? request.messages?.[0] : request
35
36
  const params = data?.arguments?.[1] || {}
36
37
  const metadata = params?.metadata ?? {}
37
38
  const tags = params?.tags ?? []
38
39
  const { result: response, error: err } = data
39
40
 
40
- // Instrument stream.
41
- if (response?.next) {
42
- this.wrapNextHandler({ response, ctx, request, metadata, tags })
41
+ // Note: as of 18.x `ReadableStream` is a global
42
+ // eslint-disable-next-line n/no-unsupported-features/node-builtins
43
+ if (response instanceof ReadableStream) {
44
+ this.instrumentStream({ response, ctx, request: userRequest, metadata, tags })
43
45
  } else {
44
46
  // Input error occurred which means a stream was not created.
45
47
  // Skip instrumenting streaming and create Llm Events from
46
48
  // the data we have
47
49
  this.recordChatCompletionEvents({
48
50
  ctx,
49
- request,
51
+ request: userRequest,
50
52
  err,
51
53
  metadata,
52
54
  tags
@@ -55,7 +57,7 @@ class LangchainRunnableStreamSubscriber extends LangchainRunnableSubscriber {
55
57
  }
56
58
 
57
59
  /**
58
- * Wraps the next method on the IterableReadableStream. It will also record the Llm
60
+ * Wraps `read` method on the ReadableStream reader. It will also record the Llm
59
61
  * events when the stream is done processing.
60
62
  *
61
63
  * @param {object} params function params
@@ -65,56 +67,82 @@ class LangchainRunnableStreamSubscriber extends LangchainRunnableSubscriber {
65
67
  * @param {object} params.metadata metadata for the call
66
68
  * @param {Array} params.tags tags for the call
67
69
  */
68
- wrapNextHandler({ ctx, response, request, metadata, tags }) {
70
+ instrumentStream({ ctx, response, request, metadata, tags }) {
69
71
  const self = this
70
- const orig = response.next
71
- let content = ''
72
- const { segment } = ctx
73
-
74
- async function wrappedIterator(...args) {
75
- try {
76
- const result = await orig.apply(this, args)
77
- // only create Llm events when stream iteration is done
78
- if (result?.done) {
72
+ const orig = response.getReader
73
+ response.getReader = function wrapedGetReader() {
74
+ const reader = orig.apply(this, arguments)
75
+ const origRead = reader.read
76
+ let responseContent = ''
77
+ reader.read = async function wrappedRead(...args) {
78
+ try {
79
+ const result = await origRead.apply(this, args)
80
+ if (result?.done) {
81
+ // only create Llm events when stream iteration is done
82
+ self.recordChatCompletionEvents({
83
+ ctx,
84
+ response: responseContent,
85
+ request,
86
+ metadata,
87
+ tags
88
+ })
89
+ } else {
90
+ // Concat the streamed content
91
+ responseContent = self.concatResponseContent(result, responseContent)
92
+ }
93
+ return result
94
+ } catch (error) {
79
95
  self.recordChatCompletionEvents({
80
96
  ctx,
81
97
  request,
82
- response: content,
98
+ response: responseContent,
83
99
  metadata,
84
- tags
100
+ tags,
101
+ err: error
85
102
  })
86
- } else {
87
- // Concat the streamed content
88
- if (typeof result?.value?.content === 'string') {
89
- // LangChain BaseMessageChunk case
90
- content += result.value.content
91
- } else if (typeof result?.value === 'string') {
92
- // Base LangChain case
93
- content += result.value
94
- } else if (typeof result?.value?.[0] === 'string') {
95
- // Array parser case
96
- content += result.value[0]
97
- }
103
+ throw error
104
+ } finally {
105
+ // update segment duration on every stream
106
+ // iteration to extend the timer
107
+ ctx.segment.touch()
98
108
  }
99
- return result
100
- } catch (error) {
101
- self.recordChatCompletionEvents({
102
- ctx,
103
- request,
104
- response: content,
105
- metadata,
106
- tags,
107
- err: error
108
- })
109
- throw error
110
- } finally {
111
- // update segment duration on every stream iteration to extend
112
- // the timer
113
- segment.touch()
114
109
  }
110
+ return reader
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Concats streamed content from various LangChain/LangGraph result formats.
116
+ *
117
+ * @param {object} result the stream result chunk
118
+ * @param {string|object} content the response so far
119
+ * @returns {string|object} updated response content. For LangGraph, it will return an object
120
+ * (e.g. AIMessage), so we have more info if we need to drop this response if it is incomplete
121
+ * (e.g outgoing tool call).
122
+ */
123
+ concatResponseContent(result, content) {
124
+ if (result?.value?.messages || result?.value?.agent?.messages) {
125
+ // LangGraph case:
126
+ // The result.value.%messages field contains all messages,
127
+ // request and response, and appends new events at the
128
+ // end of the array. Therefore, the last message is the
129
+ // relevant response object.
130
+ const langgraphMessages = result?.value?.messages ?? result?.value?.agent?.messages
131
+ if (langgraphMessages.length > 0) {
132
+ content = langgraphMessages[langgraphMessages.length - 1]
133
+ }
134
+ } else if (typeof result?.value?.content === 'string') {
135
+ // LangChain MessageChunk case
136
+ content += result.value.content
137
+ } else if (typeof result?.value === 'string') {
138
+ // Base LangChain case
139
+ content += result.value
140
+ } else if (typeof result?.value?.[0] === 'string') {
141
+ // LangChain array parser case
142
+ content += result.value[0]
115
143
  }
116
144
 
117
- response.next = this.agent.tracer.bindFunction(wrappedIterator, ctx, false)
145
+ return content
118
146
  }
119
147
  }
120
148
 
@@ -21,10 +21,15 @@ class LangchainRunnableSubscriber extends AiMonitoringChatSubscriber {
21
21
  const ctx = this.agent.tracer.getContext()
22
22
  const request = data?.arguments?.[0]
23
23
  const params = data?.arguments?.[1] || {}
24
- const { result: response, error: err } = data
24
+ const { result, error: err } = data
25
+ // Requests and responses via LangGraph API have the `messages`
26
+ // property with the information we need, otherwise it just
27
+ // lives on the `request`/`result` object directly.
28
+ const userRequest = request?.messages ? request.messages?.[0] : request
29
+ const response = result?.messages ? result.messages?.[0] : result
25
30
  this.recordChatCompletionEvents({
26
31
  ctx,
27
- request,
32
+ request: userRequest,
28
33
  response,
29
34
  err,
30
35
  metadata: params?.metadata,
@@ -49,16 +54,26 @@ class LangchainRunnableSubscriber extends AiMonitoringChatSubscriber {
49
54
  })
50
55
  }
51
56
 
57
+ /**
58
+ * Determines if the LangChain request and response should be stored and
59
+ * later captured as `LlmChatCompletionMessage`s.
60
+ * @param {object} params function parameters
61
+ * @param {object|string} params.request request object (`{ content, role, ... }`) or string
62
+ * @param {object|string} params.response response object (`{ content, role, ... }`) or string
63
+ * @returns {object[]} an array with all of the valid request and response objects/strings
64
+ */
52
65
  getMessages({ request, response }) {
53
66
  const messages = []
54
- // check if request is truthy and an empty string
55
67
  if (request || request === '') {
56
68
  messages.push(request)
57
69
  }
58
70
 
59
- // check if response is truthy and an empty string
60
71
  if (response || response === '') {
61
- messages.push(response)
72
+ // Add the response if it is NOT an outgoing
73
+ // tool call with no result yet
74
+ if (!(response?.content === '' && response?.tool_calls?.length > 0)) {
75
+ messages.push(response)
76
+ }
62
77
  }
63
78
 
64
79
  return messages
@@ -68,23 +83,13 @@ class LangchainRunnableSubscriber extends AiMonitoringChatSubscriber {
68
83
  const { segment, transaction } = ctx
69
84
  // check before grabbing a key from it in the AiMessageChunk case
70
85
  const isResponse = message === response
71
- if (message?.content) {
72
- message = message.content
73
- }
74
-
75
- let msgString
76
- try {
77
- msgString = typeof message === 'string' ? message : JSON.stringify(message)
78
- } catch (error) {
79
- this.logger.error(error, 'Failed to stringify message')
80
- msgString = ''
81
- }
86
+ const { content, role } = this.extractContentAndRole(message)
82
87
 
83
88
  return new LangChainCompletionMessage({
84
89
  sequence: index,
85
90
  agent: this.agent,
86
- content: msgString,
87
- role: message?.role,
91
+ content,
92
+ role,
88
93
  completionId,
89
94
  segment,
90
95
  transaction,
@@ -92,6 +97,46 @@ class LangchainRunnableSubscriber extends AiMonitoringChatSubscriber {
92
97
  isResponse
93
98
  })
94
99
  }
100
+
101
+ /**
102
+ * Grabs the message content and conversation role from the given
103
+ * LangChain message object.
104
+ * @param {object|string} msg The message, can be a variety of different types.
105
+ * @returns {object} an object with `content` (string) and `role` (string)
106
+ */
107
+ extractContentAndRole(msg) {
108
+ // Get message content
109
+ let content = ''
110
+ if (typeof msg === 'string') {
111
+ content = msg
112
+ } else if (typeof msg?.content === 'string') {
113
+ // If msg is a BaseMessage
114
+ content = msg.content
115
+ } else {
116
+ // Fallback for different kind of message
117
+ try {
118
+ content = JSON.stringify(msg)
119
+ } catch (error) {
120
+ this.logger.error(error, 'Failed to stringify message')
121
+ }
122
+ }
123
+
124
+ // Get conversation role
125
+ let role = msg?.role
126
+ if (msg?.type) {
127
+ // LangGraph defines this for us
128
+ if (msg.type === 'human') {
129
+ role = 'user'
130
+ } else if (msg.type === 'ai') {
131
+ role = 'assistant'
132
+ } else {
133
+ // e.g. tool
134
+ role = msg.type
135
+ }
136
+ }
137
+
138
+ return { content, role }
139
+ }
95
140
  }
96
141
 
97
142
  module.exports = LangchainRunnableSubscriber
@@ -14,18 +14,19 @@ const LlmErrorMessage = require('../../llm-events/error-message')
14
14
 
15
15
  class LangchainToolSubscriber extends AiMonitoringSubscriber {
16
16
  constructor({ agent, logger }) {
17
- super({ agent, logger, packageName: '@langchain/core', channelName: 'nr_call', trackingPrefix: LANGCHAIN.TRACKING_PREFIX, name: LANGCHAIN.TOOL })
17
+ super({ agent, logger, packageName: '@langchain/core', channelName: 'nr_call', trackingPrefix: LANGCHAIN.TRACKING_PREFIX, name: 'unknown' })
18
18
  this.events = ['asyncEnd']
19
19
  this.trackingPrefix = LANGCHAIN.TRACKING_PREFIX
20
20
  }
21
21
 
22
22
  handler(data, ctx) {
23
23
  const tool = data?.self
24
- this.name = `${this.name}/${tool?.name}`
24
+ this.name = `${LANGCHAIN.TOOL}/${tool?.name}`
25
25
  return super.handler(data, ctx)
26
26
  }
27
27
 
28
28
  asyncEnd(data) {
29
+ // Extract data and exit early if need be.
29
30
  const { agent, logger } = this
30
31
  if (!this.enabled) {
31
32
  logger.debug('Langchain instrumentation is disabled, not recording Llm events.')
@@ -38,12 +39,23 @@ class LangchainToolSubscriber extends AiMonitoringSubscriber {
38
39
  }
39
40
  const { result, error: err } = data
40
41
  const { name, metadata: instanceMeta, description, tags: instanceTags } = data?.self
41
- const request = data?.arguments?.[0]
42
+
43
+ // Get metadata and tags
42
44
  const params = data?.arguments?.[1] || {}
43
45
  const { metadata: paramsMeta, tags: paramsTags } = params
44
46
  const metadata = this.mergeMetadata(instanceMeta, paramsMeta)
45
47
  const tags = this.mergeTags(instanceTags, paramsTags)
46
48
 
49
+ // Get tool output and input
50
+ const request = data?.arguments?.[0]
51
+ const output = result?.content ?? result
52
+ let input
53
+ if (request?.input) {
54
+ input = request.input.toString()
55
+ } else if (request) {
56
+ input = JSON.stringify(request)
57
+ }
58
+
47
59
  segment.end()
48
60
 
49
61
  const toolEvent = new LangChainTool({
@@ -54,8 +66,8 @@ class LangchainToolSubscriber extends AiMonitoringSubscriber {
54
66
  metadata,
55
67
  transaction,
56
68
  tags,
57
- input: request?.input,
58
- output: result,
69
+ input,
70
+ output,
59
71
  segment,
60
72
  error: err != null
61
73
  })
@@ -0,0 +1,39 @@
1
+ /*
2
+ * Copyright 2026 New Relic Corporation. All rights reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ // `Pregel` is the base class of the `CompiledStateGraph` (and others),
7
+ // which is the class encapsulating a LangGraph agent.
8
+
9
+ const graphStream = {
10
+ path: './langgraph/graph-stream.js',
11
+ instrumentations: [
12
+ // CommonJs
13
+ {
14
+ channelName: 'nr_stream',
15
+ module: { name: '@langchain/langgraph', versionRange: '>=1.0.0', filePath: 'dist/pregel/index.cjs' },
16
+ functionQuery: {
17
+ index: 1,
18
+ methodName: 'stream',
19
+ kind: 'Async'
20
+ }
21
+ },
22
+ // ESM
23
+ {
24
+ channelName: 'nr_stream',
25
+ module: { name: '@langchain/langgraph', versionRange: '>=1.0.0', filePath: 'dist/pregel/index.js' },
26
+ functionQuery: {
27
+ index: 1,
28
+ methodName: 'stream',
29
+ kind: 'Async'
30
+ }
31
+ },
32
+ ]
33
+ }
34
+
35
+ module.exports = {
36
+ '@langchain/langgraph': [
37
+ graphStream
38
+ ]
39
+ }
@@ -0,0 +1,131 @@
1
+ /*
2
+ * Copyright 2026 New Relic Corporation. All rights reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ const AiMonitoringSubscriber = require('../ai-monitoring/base')
7
+ const LlmErrorMessage = require('#agentlib/llm-events/error-message.js')
8
+ const LangGraphAgentEvent = require('#agentlib/llm-events/langgraph/agent.js')
9
+ const { AI: { LANGGRAPH, STREAMING_DISABLED } } = require('#agentlib/metrics/names.js')
10
+
11
+ class LangGraphStreamSubscriber extends AiMonitoringSubscriber {
12
+ constructor({ agent, logger }) {
13
+ super({ agent,
14
+ logger,
15
+ packageName: '@langchain/langgraph',
16
+ channelName: 'nr_stream',
17
+ // 'agent' is the default name for an unknown AI
18
+ // agent or one with no name
19
+ name: `${LANGGRAPH.AGENT}/stream/agent`,
20
+ trackingPrefix: LANGGRAPH.TRACKING_PREFIX })
21
+ this.events = ['asyncEnd']
22
+ }
23
+
24
+ get streamingEnabled() {
25
+ return this.agent.config.ai_monitoring.streaming.enabled
26
+ }
27
+
28
+ handler(data, ctx) {
29
+ // Store LangGraph AI agent name and update
30
+ // segment name to use it.
31
+ this.aiAgentName = data?.self?.name ?? 'agent'
32
+ this.name = `${LANGGRAPH.AGENT}/stream/${this.aiAgentName}`
33
+ return super.handler(data, ctx)
34
+ }
35
+
36
+ asyncEnd(data) {
37
+ const { agent, logger, aiAgentName } = this
38
+ if (!this.enabled) {
39
+ logger.debug('LangGraph instrumentation is disabled, not instrumenting stream.')
40
+ return
41
+ }
42
+ if (!this.streamingEnabled) {
43
+ logger.debug('LangGraph streaming instrumentation is disabled, not instrumenting stream.')
44
+ this.agent.metrics.getOrCreateMetric(STREAMING_DISABLED).incrementCallCount()
45
+ return
46
+ }
47
+ const ctx = agent.tracer.getContext()
48
+ const { segment, transaction } = ctx
49
+ if (!(segment || transaction) || (transaction?.isActive() !== true)) {
50
+ return
51
+ }
52
+
53
+ const { error: initialErr, result: stream } = data
54
+ segment.addSpanAttribute('subcomponent', `{"type": "APM-AI_AGENT", "name": ${aiAgentName}}`)
55
+
56
+ if (initialErr) {
57
+ this.recordAiAgentEvent({ aiAgentName, transaction, segment, error: initialErr })
58
+ return
59
+ }
60
+
61
+ // Note: as of 18.x `ReadableStream` is a global
62
+ // eslint-disable-next-line n/no-unsupported-features/node-builtins
63
+ if (stream instanceof ReadableStream) {
64
+ this.instrumentStream({ stream, segment, transaction, aiAgentName })
65
+ }
66
+ }
67
+
68
+ instrumentStream({ stream, segment, transaction, aiAgentName }) {
69
+ const self = this
70
+ const orig = stream.getReader
71
+ stream.getReader = function wrappedGetReaderLangGraph() {
72
+ const reader = orig.apply(this, arguments)
73
+ const origRead = reader.read
74
+ reader.read = async function wrappedLangGraphRead(...args) {
75
+ try {
76
+ const result = await origRead.apply(this, args)
77
+ if (result?.done) {
78
+ self.recordAiAgentEvent({ aiAgentName, transaction, segment, error: false })
79
+ }
80
+ return result
81
+ } catch (err) {
82
+ self.recordAiAgentEvent({ aiAgentName, transaction, segment, error: err })
83
+ throw err
84
+ } finally {
85
+ // update segment duration on every stream
86
+ // iteration to extend the timer
87
+ segment.touch()
88
+ }
89
+ }
90
+ return reader
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Records a LangGraph `LlmAgent` event and a `LlmErrorMessage` event
96
+ * if an error occurred.
97
+ *
98
+ * @param {object} params function parameters
99
+ * @param {string} params.aiAgentName LangGraph AI agent name
100
+ * @param {object} params.transaction current transaction
101
+ * @param {object} params.segment current segment
102
+ * @param {object} [params.error] an error if it occurred
103
+ */
104
+ recordAiAgentEvent({ aiAgentName, transaction, segment, error }) {
105
+ const { agent } = this
106
+ segment.end()
107
+ const agentEvent = new LangGraphAgentEvent({
108
+ agent,
109
+ name: aiAgentName,
110
+ transaction,
111
+ segment,
112
+ error: !!error
113
+ })
114
+
115
+ this.recordEvent({ type: 'LlmAgent', msg: agentEvent })
116
+
117
+ if (error) {
118
+ agent.errors.add(
119
+ transaction,
120
+ error,
121
+ new LlmErrorMessage({
122
+ response: {},
123
+ cause: error,
124
+ aiAgent: agentEvent
125
+ })
126
+ )
127
+ }
128
+ }
129
+ }
130
+
131
+ module.exports = LangGraphStreamSubscriber
@@ -7,8 +7,7 @@
7
7
 
8
8
  const logger = require('../../logger').child({ component: 'partial-trace' })
9
9
  const { PARTIAL_TRACE } = require('../../metrics/names')
10
- const { Attributes } = require('#agentlib/attributes.js')
11
-
10
+ const { SPAN_EVENTS } = require('#agentlib/metrics/names.js')
12
11
  /**
13
12
  * A PartialTrace manages span processing for partial granularity traces.
14
13
  * It handles span reparenting, compaction logic, and finalization of span events.
@@ -58,6 +57,7 @@ class PartialTrace {
58
57
  * Runs a span through partial tracing rules.
59
58
  * If the span is null, it indicates that the span was dropped,
60
59
  * and we must keep track of its id and parentId for potential reparenting.
60
+ * Also reparents span links from dropped spans.
61
61
  *
62
62
  * @param {object} params to function
63
63
  * @param {string} params.span to apply partial rules to
@@ -66,16 +66,15 @@ class PartialTrace {
66
66
  addSpan({ span, isEntry }) {
67
67
  const id = span.id
68
68
  const parentId = span.parentId
69
+
70
+ // capture necessary information for reparenting span links before span is potentially dropped
69
71
  const spanLinks = span.spanLinks
72
+ const exitSpan = span.isExitSpan
73
+ const hasEntityAttrs = span.hasEntityRelationshipAttrs
74
+
70
75
  span = span.applyPartialTraceRules({ isEntry, partialTrace: this })
71
76
  this.createMetrics(!!span)
72
77
 
73
- if (spanLinks && spanLinks.length > 0) {
74
- if (this.type === 'essential') {
75
- this.removeNonIntrinsicsAttrs(spanLinks)
76
- }
77
- }
78
-
79
78
  if (span) {
80
79
  // span was not dropped, add to trace until all spans have been processed
81
80
  this.spans.push(span)
@@ -88,41 +87,34 @@ class PartialTrace {
88
87
 
89
88
  // span was dropped but we still need to move its span links to the last kept span
90
89
  // spanLinks were captured before the span was dropped
91
- if (spanLinks && spanLinks.length > 0) {
92
- this.reparentSpanLinks(spanLinks)
93
- }
90
+ this.reparentSpanLinks(this.spans.at(-1), spanLinks)
91
+ } else if ((this.type === 'compact') && (!exitSpan || !hasEntityAttrs)) {
92
+ // we still need to reparent links for dropped spans in compact mode that are not exit
93
+ // span or spans without entity relationship attributes
94
+ this.reparentSpanLinks(this.spans.at(-1), spanLinks)
94
95
  }
95
96
  }
96
97
 
97
98
  /**
98
- * Iterates over the span links from a span and removes all non-intrinsic attributes.
99
- *
100
- * @param {SpanLink[]} spanLinks an array of span links to reparent to last kept span
101
- */
102
- removeNonIntrinsicsAttrs(spanLinks) {
103
- for (const link of spanLinks) {
104
- link.userAttributes = new Attributes({ scope: Attributes.SCOPE_SEGMENT })
105
- link.agentAttributes = new Attributes({ scope: Attributes.SCOPE_SEGMENT })
106
- }
107
- }
108
-
109
- /**
110
- * Iterates over the span links from a dropped span and reassigns them to the last kept span.
99
+ * Iterates over the span links from a dropped span and reassigns them to the span to reparent to.
111
100
  * The id intrinsic attribute will also be updated to the value of the last kept span id.
112
101
  *
113
- * @param {SpanLink[]} spanLinks an array of span links to reparent to last kept span
102
+ * @param {SpanEvent} span the span to reparent span links to
103
+ * @param {SpanLink[]} spanLinksToReparent an array of span links to reparent to last kept span
114
104
  */
115
- reparentSpanLinks(spanLinks) {
116
- const lastSpan = this.spans.at(-1)
117
-
105
+ reparentSpanLinks(span, spanLinksToReparent = []) {
118
106
  // The id intrinsics attribute needs to be updated to equal the id of the new span the
119
107
  // span links are moving to.
120
- for (const link of spanLinks) {
121
- link.intrinsics.id = lastSpan.id
122
- }
108
+ for (const link of spanLinksToReparent) {
109
+ if (span.spanLinks.length === 100) {
110
+ logger.trace('Span links limit reached. Not moving additional span links.')
111
+ this.transaction.agent.metrics.getOrCreateMetric(SPAN_EVENTS.LINKS_DROPPED).incrementCallCount()
112
+ return
113
+ }
123
114
 
124
- // move the span links events to the last kept span
125
- Array.prototype.push.apply(lastSpan.spanLinks, spanLinks)
115
+ link.intrinsics.id = span.id
116
+ span.spanLinks.push(link)
117
+ }
126
118
  }
127
119
 
128
120
  /**
@@ -195,7 +187,7 @@ class PartialTrace {
195
187
  /**
196
188
  * Checks if span was the retained exit span for a given entity and it has other spans that talked to the same
197
189
  * entity. It will then sort all timestamps for spans that got dropped and calculate the `nr.durations` and assign
198
- * `nr.ids` for all dropped spans to same entity.
190
+ * `nr.ids` for all dropped spans to same entity. It will also reparent the span links from dropped spans to the retained exit span.
199
191
  *
200
192
  * @param {SpanEvent} span to check if it has to calculate `nr.durations` and `nr.ids`
201
193
  */
@@ -221,7 +213,8 @@ class PartialTrace {
221
213
  totalDuration: 0,
222
214
  currentStart: null,
223
215
  currentEnd: null,
224
- errorSpan: null
216
+ errorSpan: null,
217
+ spanLinks: []
225
218
  }
226
219
 
227
220
  // timestamps must be sorted to accurately calculate overlapping durations
@@ -238,6 +231,8 @@ class PartialTrace {
238
231
  } else {
239
232
  meta.droppedIds++
240
233
  }
234
+
235
+ Array.prototype.push.apply(meta.spanLinks, sameEntitySpan.spanLinks)
241
236
  }
242
237
 
243
238
  this.compactionError(meta, sameEntitySpan)
@@ -245,6 +240,8 @@ class PartialTrace {
245
240
  }
246
241
 
247
242
  this.assignCompactAttrs({ span, meta })
243
+
244
+ this.reparentSpanLinks(span, meta.spanLinks)
248
245
  }
249
246
 
250
247
  assignCompactAttrs({ span, meta }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newrelic",
3
- "version": "13.11.0",
3
+ "version": "13.12.0",
4
4
  "author": "New Relic Node.js agent team <nodejs@newrelic.com>",
5
5
  "license": "Apache-2.0",
6
6
  "contributors": [