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 +19 -0
- package/lib/llm-events/aws-bedrock/chat-completion-summary.js +1 -0
- package/lib/llm-events/error-message.js +8 -2
- package/lib/llm-events/google-genai/chat-completion-summary.js +1 -0
- package/lib/llm-events/langchain/chat-completion-summary.js +2 -1
- package/lib/llm-events/langgraph/agent.js +41 -0
- package/lib/metrics/names.js +7 -1
- package/lib/spans/span-event.js +8 -0
- package/lib/spans/span-link.js +5 -0
- package/lib/subscriber-configs.js +1 -0
- package/lib/subscribers/langchain/runnable-stream.js +76 -48
- package/lib/subscribers/langchain/runnable.js +63 -18
- package/lib/subscribers/langchain/tool.js +17 -5
- package/lib/subscribers/langgraph/config.js +39 -0
- package/lib/subscribers/langgraph/graph-stream.js +131 -0
- package/lib/transaction/trace/partial-trace.js +32 -35
- package/package.json +1 -1
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
|
|
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
|
+
}
|
package/lib/metrics/names.js
CHANGED
|
@@ -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`,
|
package/lib/spans/span-event.js
CHANGED
|
@@ -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)
|
package/lib/spans/span-link.js
CHANGED
|
@@ -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
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
70
|
+
instrumentStream({ ctx, response, request, metadata, tags }) {
|
|
69
71
|
const self = this
|
|
70
|
-
const orig = response.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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:
|
|
98
|
+
response: responseContent,
|
|
83
99
|
metadata,
|
|
84
|
-
tags
|
|
100
|
+
tags,
|
|
101
|
+
err: error
|
|
85
102
|
})
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
87
|
-
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:
|
|
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 = `${
|
|
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
|
-
|
|
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
|
|
58
|
-
output
|
|
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 {
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
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 {
|
|
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(
|
|
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
|
|
121
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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 }) {
|