Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
82e75eb
fix(server-utils): Instrument LangGraph stream executions
psh4607 Aug 26, 2026
591bb2b
fix(server-utils): Complete LangGraph stream spans
psh4607 Aug 26, 2026
7b86b35
fix(server-utils): Preserve LangGraph pipe lifecycle
psh4607 Aug 26, 2026
ca15369
fix(server-utils): Preserve pipeThrough errors
psh4607 Aug 26, 2026
cb32555
fix(server-utils): Keep span open on pipeThrough preconditions
psh4607 Aug 26, 2026
bf66118
fix(server-utils): Address LangGraph stream review feedback
psh4607 Oct 6, 2026
a78a23e
fix(server-utils): Preserve spans on stream caller errors
psh4607 Oct 6, 2026
5fd009c
fix(server-utils): Reconcile LangGraph streaming with develop
psh4607 Oct 6, 2026
5396574
fix(server-utils): Preserve stream outcomes on recording failures
psh4607 Oct 6, 2026
a809823
Merge branch 'develop' into fix/JS-1858/langgraph-stream-instrumentation
RulaKhaled Oct 21, 2026
29855c7
fix(server-utils): Address LangGraph stream review feedback
RulaKhaled Oct 21, 2026
2847297
fix(server-utils): Harden LangGraph stream span lifecycle
RulaKhaled Oct 21, 2026
a6d40c8
ref(server-utils): Simplify LangGraph stream lifecycle
RulaKhaled Oct 22, 2026
aac1c7a
fix(server-utils): Keep stream errors after a rejected LangGraph cancel
RulaKhaled Oct 22, 2026
53c76fb
Merge branch 'develop' into fix/JS-1858/langgraph-stream-instrumentation
RulaKhaled Oct 22, 2026
e5b4875
fix(server-utils): Check pipe options before locking a LangGraph stream
RulaKhaled Oct 22, 2026
1dc0f5b
Merge branch 'fix/JS-1858/langgraph-stream-instrumentation' of https:…
RulaKhaled Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ export default Sentry.withSentry(
messages: [{ role: 'user', content: 'What is the weather in SF?' }],
});

const stream = await compiled.stream({
messages: [{ role: 'user', content: 'Stream the weather in SF' }],
});
for await (const _chunk of stream) {
// Consuming the iterator is what runs the graph and completes the agent span.
}

return new Response(JSON.stringify({ success: true }));
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import {
GEN_AI_INPUT_MESSAGES,
GEN_AI_OPERATION_NAME,
GEN_AI_PIPELINE_NAME,
GEN_AI_RESPONSE_FINISH_REASONS,
GEN_AI_RESPONSE_MODEL,
GEN_AI_RESPONSE_STREAMING,
GEN_AI_RESPONSE_TEXT,
GEN_AI_USAGE_INPUT_TOKENS,
GEN_AI_USAGE_OUTPUT_TOKENS,
GEN_AI_USAGE_TOTAL_TOKENS,
Expand All @@ -17,7 +20,7 @@ import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils';
// want to test that the instrumentation does not break in our
// cloudflare SDK.

it('traces langgraph compile and invoke operations', async ({ signal }) => {
it('traces langgraph invoke and stream operations', async ({ signal }) => {
const runner = createRunner(__dirname)
.ignore('event')
.expect(envelope => {
Expand All @@ -26,10 +29,13 @@ it('traces langgraph compile and invoke operations', async ({ signal }) => {
expect(segmentSpan?.name).toBe('GET /');

const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.'));
expect(genAiSpans).toHaveLength(1);
expect(genAiSpans.map(span => span.name).sort()).toEqual(['invoke_agent weather_assistant']);
expect(genAiSpans).toHaveLength(2);
expect(genAiSpans.map(span => span.name).sort()).toEqual([
'invoke_agent weather_assistant',
'invoke_agent weather_assistant',
]);

const invokeAgentSpan = genAiSpans.find(span => span.name === 'invoke_agent weather_assistant');
const invokeAgentSpan = genAiSpans.find(span => span.attributes[GEN_AI_RESPONSE_STREAMING] === undefined);
expect(invokeAgentSpan).toBeDefined();
expect(invokeAgentSpan!.status).toBe('ok');
expect(invokeAgentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
Expand Down Expand Up @@ -66,6 +72,38 @@ it('traces langgraph compile and invoke operations', async ({ signal }) => {
type: 'integer',
value: 30,
});

const streamSpan = genAiSpans.find(span => span.attributes[GEN_AI_RESPONSE_STREAMING]?.value === true);
expect(streamSpan).toBeDefined();
expect(streamSpan!.status).toBe('ok');
expect(streamSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
type: 'string',
value: '[{"role":"user","content":"Stream the weather in SF"}]',
});
expect(streamSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
type: 'string',
value: '[{"role":"assistant","content":"Mock response from LangGraph agent"}]',
});
expect(streamSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
type: 'string',
value: 'mock-model',
});
expect(streamSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
type: 'array',
value: ['stop'],
});
expect(streamSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
type: 'integer',
value: 20,
});
expect(streamSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
type: 'integer',
value: 10,
});
expect(streamSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
type: 'integer',
value: 30,
});
})
.start(signal);
await runner.makeRequest('get', '/');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { ChatAnthropic } from '@langchain/anthropic';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
import { createReactAgent } from '@langchain/langgraph/prebuilt';
import * as Sentry from '@sentry/node';
import express from 'express';

function startMockAnthropicServer() {
const app = express();
app.use(express.json());

app.post('/v1/messages', (req, res) => {
res.json({
id: 'msg_react_agent_stream_123',
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: 'Paris is the capital of France.' }],
model: req.body.model,
stop_reason: 'end_turn',
stop_sequence: null,
usage: { input_tokens: 20, output_tokens: 10 },
});
});

return new Promise(resolve => {
const server = app.listen(0, () => resolve(server));
});
}

async function run() {
const server = await startMockAnthropicServer();
const baseUrl = `http://localhost:${server.address().port}`;

await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
const llm = new ChatAnthropic({
model: 'claude-3-5-sonnet-20241022',
apiKey: 'mock-api-key',
clientOptions: { baseURL: baseUrl },
});
const agent = createReactAgent({ llm, tools: [], name: 'helpful_assistant' });

const stream = await agent.stream({
messages: [new SystemMessage('You are a helpful assistant.'), new HumanMessage('What is the capital of France?')],
});
for await (const _chunk of stream) {
// Consume the stream so the agent and its child spans complete before the root span ends.
}
});

await Sentry.flush(2000);
server.close();
}

run();
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ async function run() {
{ role: 'user', content: 'Tell me about the weather' },
],
});

const stream = await graph.stream({
messages: [{ role: 'user', content: 'Stream the weather forecast' }],
});
await stream.pipeTo(new WritableStream());

const canceledStream = await graph.stream({
messages: [{ role: 'user', content: 'Cancel the weather forecast' }],
});
await canceledStream.cancel('no longer needed');
});

await Sentry.flush(2000);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
GEN_AI_OPERATION_NAME,
GEN_AI_OUTPUT_MESSAGES,
GEN_AI_PIPELINE_NAME,
GEN_AI_RESPONSE_FINISH_REASONS,
GEN_AI_RESPONSE_MODEL,
GEN_AI_RESPONSE_STREAMING,
GEN_AI_RESPONSE_TEXT,
GEN_AI_RESPONSE_TOOL_CALLS,
GEN_AI_SYSTEM_INSTRUCTIONS,
Expand Down Expand Up @@ -34,14 +36,16 @@ describe('LangGraph integration', () => {
const segment = container.items.find(span => span.is_segment && span.name === 'langgraph-test');
expect(segment).toBeDefined();
const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langgraph');
expect(spans).toHaveLength(2);
expect(spans).toHaveLength(4);
expect(spans.map(span => span.name).sort()).toEqual([
'invoke_agent weather_assistant',
'invoke_agent weather_assistant',
'invoke_agent weather_assistant',
'invoke_agent weather_assistant',
]);

const invokeAgentSpans = spans.filter(span => span.name === 'invoke_agent weather_assistant');
expect(invokeAgentSpans).toHaveLength(2);
expect(invokeAgentSpans).toHaveLength(4);
for (const span of invokeAgentSpans) {
expect(span.status).toBe('ok');
expect(span.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
Expand All @@ -50,6 +54,11 @@ describe('LangGraph integration', () => {
expect(span.attributes[GEN_AI_AGENT_NAME].value).toBe('weather_assistant');
expect(span.attributes[GEN_AI_PIPELINE_NAME].value).toBe('weather_assistant');
}

const streamSpans = invokeAgentSpans.filter(
span => span.attributes[GEN_AI_RESPONSE_STREAMING]?.value === true,
);
expect(streamSpans).toHaveLength(2);
},
})
.start()
Expand All @@ -65,7 +74,7 @@ describe('LangGraph integration', () => {
const segment = container.items.find(span => span.is_segment && span.name === 'langgraph-test');
expect(segment).toBeDefined();
const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langgraph');
expect(spans).toHaveLength(2);
expect(spans).toHaveLength(4);

const weatherTodaySpan = spans.find(span =>
getStringAttributeValue(span.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(
Expand All @@ -85,6 +94,32 @@ describe('LangGraph integration', () => {
expect(weatherDetailsSpan!.name).toBe('invoke_agent weather_assistant');
expect(weatherDetailsSpan!.status).toBe('ok');
expect(weatherDetailsSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');

const weatherStreamSpan = spans.find(span =>
getStringAttributeValue(span.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(
'Stream the weather forecast',
),
);
expect(weatherStreamSpan).toBeDefined();
expect(weatherStreamSpan!.status).toBe('ok');
expect(weatherStreamSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
expect(weatherStreamSpan!.attributes[GEN_AI_RESPONSE_TEXT].value).toBe(
'[{"role":"assistant","content":"Mock LLM response"}]',
);
expect(weatherStreamSpan!.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('mock-model');
expect(weatherStreamSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS].value).toEqual(['stop']);
expect(weatherStreamSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(20);
expect(weatherStreamSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(10);
expect(weatherStreamSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(30);

const canceledStreamSpan = spans.find(span =>
getStringAttributeValue(span.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(
'Cancel the weather forecast',
),
);
expect(canceledStreamSpan).toBeDefined();
expect(canceledStreamSpan!.status).toBe('ok');
expect(canceledStreamSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
},
})
.start()
Expand Down Expand Up @@ -294,6 +329,34 @@ describe('LangGraph integration', () => {
});
});

createEsmAndCjsTests(__dirname, 'agent-stream-scenario.mjs', 'instrument-agent.mjs', (createRunner, test) => {
test('parents the streaming agent chat span under its agent span', { timeout: 30000 }, async () => {
await createRunner()
.expect({
span: container => {
const agentSpans = container.items.filter(
span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent',
);
const chatSpans = container.items.filter(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.chat');
expect(agentSpans).toHaveLength(1);
expect(chatSpans).toHaveLength(1);

const agentSpan = agentSpans[0]!;
const chatSpan = chatSpans[0]!;
expect(agentSpan.name).toBe('invoke_agent helpful_assistant');
expect(agentSpan.status).toBe('ok');
expect(agentSpan.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true);
expect(chatSpan.parent_span_id).toBe(agentSpan.span_id);
expect(chatSpan.trace_id).toBe(agentSpan.trace_id);
expect(chatSpan.status).toBe('ok');
expect(chatSpan.attributes[GEN_AI_AGENT_NAME]?.value).toBe('helpful_assistant');
},
})
.start()
.completed();
});
});

// createReactAgent with tools - verifies tool execution spans (asserted order-independently, see above).
createEsmAndCjsTests(__dirname, 'agent-tools-scenario.mjs', 'instrument-agent.mjs', (createRunner, test) => {
test('should create tool execution spans for createReactAgent with tools', { timeout: 30000 }, async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { FakeListChatModel } from '@langchain/core/utils/testing';
import { END, MessagesAnnotation, START, StateGraph } from '@langchain/langgraph';
import * as Sentry from '@sentry/node';

function weatherGraph(model) {
return new StateGraph(MessagesAnnotation)
.addNode('agent', async state => ({ messages: [await model.invoke(state.messages)] }))
.addEdge(START, 'agent')
.addEdge('agent', END);
}

async function run() {
const model = new FakeListChatModel({ responses: ['Sunny', 'Cloudy', 'Rainy', 'Windy'] });
const user = content => ({ messages: [{ role: 'user', content }] });

await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
const graph = weatherGraph(model).compile({ name: 'weather_graph' });

const canceled = await graph.stream(user('Cancel while iterating'));
try {
for await (const _chunk of canceled) {
await canceled.cancel('done');
}
} catch {
// LangGraph rejects the next read after a cancel; the caller asked for it.
}

for await (const _chunk of await graph.stream(user('Stream with subgraphs'), {
streamMode: 'values',
subgraphs: true,
})) {
// drain
}

const [first, second] = (await graph.stream(user('Tee the forecast'))).tee();
for await (const _chunk of first) {
// drain
}
for await (const _chunk of second) {
// drain
}

const manual = Sentry.instrumentStateGraph(weatherGraph(model), { recordInputs: false }).compile({
name: 'manual_graph',
});
for await (const _chunk of await manual.stream(user('Keep this prompt private'))) {
// drain
}
});
}

run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { FakeListChatModel } from '@langchain/core/utils/testing';
import { END, MessagesAnnotation, START, StateGraph } from '@langchain/langgraph';
import * as Sentry from '@sentry/node';

async function run() {
const model = new FakeListChatModel({ responses: ['Sunny', 'Cloudy', 'Rainy'] });

await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
const graph = new StateGraph(MessagesAnnotation)
.addNode('agent', async state => ({ messages: [await model.invoke(state.messages)] }))
.addEdge(START, 'agent')
.addEdge('agent', END)
.compile({ name: 'weather_graph' });

await graph.invoke({ messages: [{ role: 'user', content: 'Invoke the forecast' }] });

for await (const _chunk of await graph.stream({ messages: [{ role: 'user', content: 'Stream the forecast' }] })) {
// Consume the whole stream.
}

for await (const _chunk of await graph.stream(
{ messages: [{ role: 'user', content: 'Abandon the forecast' }] },
{ streamMode: 'values' },
)) {
break;
}
});
}

run();
Loading