Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -10,7 +10,7 @@ import {
SENTRY_OP,
SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import { GEN_AI_EVALUATE } from '@sentry/conventions/op';
import { GEN_AI_DECIDE } from '@sentry/conventions/op';
import { expect, it } from 'vitest';
import { createRunner } from '../../../runner';
import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils';
Expand All @@ -29,28 +29,28 @@ it('traces a TypeSafeClassifier call through the Vite plugin instrumentation', a

expect(genAiSpans).toHaveLength(1);

const evaluateSpan = genAiSpans[0]!;
expect(evaluateSpan.name).toBe('evaluate jev-latest');
expect(evaluateSpan.parent_span_id).toBe(segmentSpan?.span_id);
expect(evaluateSpan.status).toBe('ok');
expect(evaluateSpan.attributes).toMatchObject({
[SENTRY_OP]: { value: GEN_AI_EVALUATE, type: 'string' },
const decideSpan = genAiSpans[0]!;
expect(decideSpan.name).toBe('decide jev-latest');
expect(decideSpan.parent_span_id).toBe(segmentSpan?.span_id);
expect(decideSpan.status).toBe('ok');
expect(decideSpan.attributes).toMatchObject({
[SENTRY_OP]: { value: GEN_AI_DECIDE, type: 'string' },
[SENTRY_ORIGIN]: { value: 'auto.ai.langchain', type: 'string' },
[GEN_AI_OPERATION_NAME]: { value: 'evaluate', type: 'string' },
[GEN_AI_OPERATION_NAME]: { value: 'decide', type: 'string' },
[GEN_AI_PROVIDER_NAME]: { value: 'typesafe', type: 'string' },
[GEN_AI_REQUEST_MODEL]: { value: 'jev-latest', type: 'string' },
[GEN_AI_RESPONSE_MODEL]: { value: 'jev-1.13', type: 'string' },
[GEN_AI_USAGE_INPUT_TOKENS]: { value: 30, type: 'integer' },
[GEN_AI_USAGE_OUTPUT_TOKENS]: { value: 2, type: 'integer' },
});
expect(JSON.parse(evaluateSpan.attributes[GEN_AI_INPUT_MESSAGES]!.value as string)).toEqual([
expect(JSON.parse(decideSpan.attributes[GEN_AI_INPUT_MESSAGES]!.value as string)).toEqual([
{
type: 'evaluation',
state: ['user: My payouts have been failing.'],
questions: { urgent: { type: 'noul', instructions: 'Is this urgent?' } },
},
]);
expect(JSON.parse(evaluateSpan.attributes[GEN_AI_OUTPUT_MESSAGES]!.value as string)).toEqual([
expect(JSON.parse(decideSpan.attributes[GEN_AI_OUTPUT_MESSAGES]!.value as string)).toEqual([
{ type: 'evaluation', answers: { urgent: { type: 'noul', noul: 0.9 } } },
]);
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
SENTRY_OP,
SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import { GEN_AI_EVALUATE } from '@sentry/conventions/op';
import { GEN_AI_DECIDE } from '@sentry/conventions/op';
import { GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE } from '../../../../../../packages/server-utils/src/ai/core/gen-ai-attributes';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';
import { createEsmTests } from '../../../../utils/runner/createEsmAndCjsTests';
Expand Down Expand Up @@ -241,7 +241,7 @@ describe('LangChain integration (v1)', () => {
'scenario-typesafe-classifier.mjs',
'instrument-with-pii.mjs',
(createRunner, test) => {
test('records a TypeSafeClassifier run as a gen_ai.evaluate span', async () => {
test('records a TypeSafeClassifier run as a gen_ai.decide span', async () => {
const runner = createRunner().ignore('event');
const spansPromise = runner.collectStreamedSpansUntilSegment('main');

Expand All @@ -250,34 +250,34 @@ describe('LangChain integration (v1)', () => {
const spans = (await spansPromise).filter(
span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain',
);
expect(spans.map(span => span.name)).toEqual(['evaluate jev-latest', 'evaluate jev-latest']);

const [evaluateSpan, messageEvaluateSpan] = spans.sort((a, b) => a.start_timestamp - b.start_timestamp);
expect(evaluateSpan.attributes[SENTRY_OP].value).toBe(GEN_AI_EVALUATE);
expect(evaluateSpan.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
expect(evaluateSpan.attributes[GEN_AI_OPERATION_NAME].value).toBe('evaluate');
expect(evaluateSpan.attributes[GEN_AI_PROVIDER_NAME].value).toBe('typesafe');
expect(evaluateSpan.attributes[GEN_AI_REQUEST_MODEL].value).toBe('jev-latest');
expect(evaluateSpan.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('jev-1.13');
expect(evaluateSpan.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(30);
expect(evaluateSpan.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(2);
expect(evaluateSpan.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(32);
expect(JSON.parse(evaluateSpan.attributes[GEN_AI_INPUT_MESSAGES].value)).toEqual([
expect(spans.map(span => span.name)).toEqual(['decide jev-latest', 'decide jev-latest']);

const [decideSpan, messageDecideSpan] = spans.sort((a, b) => a.start_timestamp - b.start_timestamp);
expect(decideSpan.attributes[SENTRY_OP].value).toBe(GEN_AI_DECIDE);
expect(decideSpan.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
expect(decideSpan.attributes[GEN_AI_OPERATION_NAME].value).toBe('decide');
expect(decideSpan.attributes[GEN_AI_PROVIDER_NAME].value).toBe('typesafe');
expect(decideSpan.attributes[GEN_AI_REQUEST_MODEL].value).toBe('jev-latest');
expect(decideSpan.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('jev-1.13');
expect(decideSpan.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(30);
expect(decideSpan.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(2);
expect(decideSpan.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(32);
expect(JSON.parse(decideSpan.attributes[GEN_AI_INPUT_MESSAGES].value)).toEqual([
{
type: 'evaluation',
state: 'My payouts have been failing.',
questions: { urgent: { type: 'noul', instructions: 'Is this urgent?' } },
},
]);
// A message is recorded as the transcript line the classifier sends, not as LangChain's serialized form.
expect(JSON.parse(messageEvaluateSpan!.attributes[GEN_AI_INPUT_MESSAGES].value)).toEqual([
expect(JSON.parse(messageDecideSpan!.attributes[GEN_AI_INPUT_MESSAGES].value)).toEqual([
{
type: 'evaluation',
state: 'user: My card was charged twice.',
questions: { urgent: { type: 'noul', instructions: 'Is this urgent?' } },
},
]);
expect(JSON.parse(evaluateSpan.attributes[GEN_AI_OUTPUT_MESSAGES].value)).toEqual([
expect(JSON.parse(decideSpan.attributes[GEN_AI_OUTPUT_MESSAGES].value)).toEqual([
{ type: 'evaluation', answers: { urgent: { type: 'noul', noul: 0.9 } } },
]);
});
Expand Down Expand Up @@ -305,7 +305,7 @@ describe('LangChain integration (v1)', () => {
const spans = await spansPromise;
const segment = spans.find(span => span.is_segment && span.name === 'main');
expect(segment!.attributes['test.recorded_runs'].value).toBe('RunnableLambda:root,TypeSafeClassifier:child');
expect(spans.filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_EVALUATE)).toHaveLength(1);
expect(spans.filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_DECIDE)).toHaveLength(1);
});
},
{
Expand All @@ -330,15 +330,15 @@ describe('LangChain integration (v1)', () => {
await runner.start().completed();

const spans = await spansPromise;
const evaluateSpan = spans.find(span => span.attributes[SENTRY_OP]?.value === GEN_AI_EVALUATE)!;
const decideSpan = spans.find(span => span.attributes[SENTRY_OP]?.value === GEN_AI_DECIDE)!;
const chatSpan = spans.find(span => span.name === 'chat claude-3-5-haiku-20241022')!;

// The router classifies in `beforeAgent`, inside the agent run, where other chain steps are skipped.
expect(evaluateSpan.name).toBe('evaluate jev-latest');
expect(evaluateSpan.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
expect(evaluateSpan.attributes[GEN_AI_AGENT_NAME].value).toBe('support_agent');
expect(evaluateSpan.parent_span_id).toBe(chatSpan.parent_span_id);
expect(JSON.parse(evaluateSpan.attributes[GEN_AI_INPUT_MESSAGES].value)).toEqual([
expect(decideSpan.name).toBe('decide jev-latest');
expect(decideSpan.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
expect(decideSpan.attributes[GEN_AI_AGENT_NAME].value).toBe('support_agent');
expect(decideSpan.parent_span_id).toBe(chatSpan.parent_span_id);
expect(JSON.parse(decideSpan.attributes[GEN_AI_INPUT_MESSAGES].value)).toEqual([
{
type: 'evaluation',
// The router passes the latest human message, which the classifier sends as a transcript line.
Expand All @@ -352,7 +352,7 @@ describe('LangChain integration (v1)', () => {
},
},
]);
expect(JSON.parse(evaluateSpan.attributes[GEN_AI_OUTPUT_MESSAGES].value)).toEqual([
expect(JSON.parse(decideSpan.attributes[GEN_AI_OUTPUT_MESSAGES].value)).toEqual([
{
type: 'evaluation',
answers: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
SENTRY_OP,
SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import { GEN_AI_EVALUATE } from '@sentry/conventions/op';
import { GEN_AI_DECIDE } from '@sentry/conventions/op';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';

describe('LangGraph integration (v1)', () => {
Expand All @@ -28,21 +28,21 @@ describe('LangGraph integration (v1)', () => {
const spans = await spansPromise;
const rootSpan = spans.find(span => span.is_segment && span.name === 'main')!;
const agentSpan = spans.find(span => span.name === 'invoke_agent triage_graph')!;
const evaluateSpan = spans.find(span => span.name === 'evaluate jev-latest')!;
const decideSpan = spans.find(span => span.name === 'decide jev-latest')!;

expect(agentSpan.parent_span_id).toBe(rootSpan.span_id);
expect(evaluateSpan.parent_span_id).toBe(agentSpan.span_id);
expect(evaluateSpan.attributes[SENTRY_OP].value).toBe(GEN_AI_EVALUATE);
expect(evaluateSpan.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
expect(evaluateSpan.attributes[GEN_AI_AGENT_NAME].value).toBe('triage_graph');
expect(JSON.parse(evaluateSpan.attributes[GEN_AI_INPUT_MESSAGES].value)).toEqual([
expect(decideSpan.parent_span_id).toBe(agentSpan.span_id);
expect(decideSpan.attributes[SENTRY_OP].value).toBe(GEN_AI_DECIDE);
expect(decideSpan.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
expect(decideSpan.attributes[GEN_AI_AGENT_NAME].value).toBe('triage_graph');
expect(JSON.parse(decideSpan.attributes[GEN_AI_INPUT_MESSAGES].value)).toEqual([
{
type: 'evaluation',
state: 'My payouts have been failing.',
questions: { urgent: { type: 'noul', instructions: 'Is this urgent?' } },
},
]);
expect(JSON.parse(evaluateSpan.attributes[GEN_AI_OUTPUT_MESSAGES].value)).toEqual([
expect(JSON.parse(decideSpan.attributes[GEN_AI_OUTPUT_MESSAGES].value)).toEqual([
{ type: 'evaluation', answers: { urgent: { type: 'noul', noul: 0.9 } } },
]);
});
Expand All @@ -69,11 +69,11 @@ describe('LangGraph integration (v1)', () => {
await runner.start().completed();

const spans = await spansPromise;
const evaluateSpan = spans.find(span => span.name === 'evaluate jev-latest')!;
const decideSpan = spans.find(span => span.name === 'decide jev-latest')!;

expect(evaluateSpan.attributes[SENTRY_OP].value).toBe(GEN_AI_EVALUATE);
expect(evaluateSpan.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
expect(evaluateSpan.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();
expect(decideSpan.attributes[SENTRY_OP].value).toBe(GEN_AI_DECIDE);
expect(decideSpan.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
expect(decideSpan.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();
});
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
SENTRY_OP,
SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import { GEN_AI_DECIDE } from '@sentry/conventions/op';
import { afterAll, describe, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';

Expand All @@ -28,22 +29,20 @@ describe('TypeSafe integration', () => {
scenarioFile,
instrumentFile,
(createRunner, test) => {
test('creates evaluate spans for systemOne', async () => {
test('creates decide spans for systemOne', async () => {
await createRunner()
.unordered()
.expect({
span: container => {
const evaluateSpans = container.items.filter(
span => span.attributes[SENTRY_OP]?.value === 'gen_ai.evaluate',
);
expect(evaluateSpans).toHaveLength(4);
for (const span of evaluateSpans) {
const decideSpans = container.items.filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_DECIDE);
expect(decideSpans).toHaveLength(4);
for (const span of decideSpans) {
expect(span.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.typesafe');
expect(span.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('evaluate');
expect(span.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('decide');
expect(span.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('typesafe');
}

const defaultModelSpan = evaluateSpans.find(span => span.name === 'evaluate jev-latest')!;
const defaultModelSpan = decideSpans.find(span => span.name === 'decide jev-latest')!;
expect(defaultModelSpan).toBeDefined();
expect(defaultModelSpan.status).toBe('ok');
expect(defaultModelSpan.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('jev-latest');
Expand Down Expand Up @@ -92,18 +91,18 @@ describe('TypeSafe integration', () => {
},
]);

const asResponseSpan = evaluateSpans.find(span => span.name === 'evaluate jev-1.13')!;
const asResponseSpan = decideSpans.find(span => span.name === 'decide jev-1.13')!;
expect(asResponseSpan).toBeDefined();
expect(asResponseSpan.status).toBe('ok');
expect(asResponseSpan.attributes[GEN_AI_RESPONSE_MODEL]?.value).toBe('jev-1.13.0');

const errorSpan = evaluateSpans.find(span => span.name === 'evaluate error-model')!;
const errorSpan = decideSpans.find(span => span.name === 'decide error-model')!;
expect(errorSpan).toBeDefined();
expect(errorSpan.status).toBe('error');
expect(errorSpan.attributes[GEN_AI_RESPONSE_MODEL]).toBeUndefined();
expect(errorSpan.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();

const validationErrorSpan = evaluateSpans.find(span => span.name === 'evaluate validation-error')!;
const validationErrorSpan = decideSpans.find(span => span.name === 'decide validation-error')!;
expect(validationErrorSpan).toBeDefined();
expect(validationErrorSpan.status).toBe('error');
expect(validationErrorSpan.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();
Expand All @@ -130,12 +129,10 @@ describe('TypeSafe integration', () => {
.unordered()
.expect({
span: container => {
const evaluateSpans = container.items.filter(
span => span.attributes[SENTRY_OP]?.value === 'gen_ai.evaluate',
);
expect(evaluateSpans).toHaveLength(4);
expect(evaluateSpans.filter(span => span.status === 'error')).toHaveLength(2);
for (const span of evaluateSpans) {
const decideSpans = container.items.filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_DECIDE);
expect(decideSpans).toHaveLength(4);
expect(decideSpans.filter(span => span.status === 'error')).toHaveLength(2);
for (const span of decideSpans) {
expect(span.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
expect(span.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();
// State, questions and answers must not come back through another attribute (e.g. an error message).
Expand Down
8 changes: 4 additions & 4 deletions packages/server-utils/src/ai/langchain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}):

// Internal state - single instance tracks all spans
const spanMap = new Map<string, Span>();
const evaluateRunIds = new Set<string>();
const decideRunIds = new Set<string>();

/**
* Exit a span and clean up
Expand All @@ -59,7 +59,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}):
span.end();
}
spanMap.delete(runId);
evaluateRunIds.delete(runId);
decideRunIds.delete(runId);
};

/**
Expand Down Expand Up @@ -225,7 +225,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}):
// A Jev call is a real model call, so it is recorded inside an agent too.
if (isTypeSafeClassifier(chain)) {
spanMap.set(runId, startTypeSafeClassifierSpan(chain, inputs, metadata, recordInputs));
evaluateRunIds.add(runId);
decideRunIds.add(runId);
return;
}

Expand Down Expand Up @@ -278,7 +278,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}):
handleChainEnd(outputs: unknown, runId: string) {
const span = spanMap.get(runId);
if (span?.isRecording()) {
if (evaluateRunIds.has(runId)) {
if (decideRunIds.has(runId)) {
addTypeSafeClassifierResponseAttributes(span, outputs, recordOutputs);
} else if (recordOutputs) {
span.setAttributes({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { SENTRY_ORIGIN } from '@sentry/conventions/attributes';
import type { Span } from '@sentry/core';
import { isObjectLike } from '@sentry/core';
import { addResponseAttributes, startEvaluateSpan } from '../typesafe';
import { addResponseAttributes, startDecideSpan } from '../typesafe';
import { LANGCHAIN_ORIGIN } from './constants';
import { getAgentNameFromMetadata, getConversationIdFromMetadata } from './utils';

Expand Down Expand Up @@ -40,7 +40,7 @@ export function isTypeSafeClassifier(chain: unknown): boolean {
return isObjectLike(chain) && Array.isArray(chain.id) && chain.id[chain.id.length - 1] === TYPESAFE_CLASSIFIER_NAME;
}

/** Start an `evaluate` span for a `TypeSafeClassifier` run, from its serialized constructor arguments. */
/** Start a `decide` span for a `TypeSafeClassifier` run, from its serialized constructor arguments. */
export function startTypeSafeClassifierSpan(
chain: unknown,
inputs: Record<string, unknown>,
Expand All @@ -50,7 +50,7 @@ export function startTypeSafeClassifierSpan(
const kwargs = isObjectLike(chain) && isObjectLike(chain.kwargs) ? chain.kwargs : {};
const model = typeof kwargs.model === 'string' ? kwargs.model : DEFAULT_TYPESAFE_CLASSIFIER_MODEL;

return startEvaluateSpan({ model, state: getState(inputs), questions: kwargs.questions }, undefined, recordInputs, {
return startDecideSpan({ model, state: getState(inputs), questions: kwargs.questions }, undefined, recordInputs, {
[SENTRY_ORIGIN]: LANGCHAIN_ORIGIN,
...getAgentNameFromMetadata(metadata),
...getConversationIdFromMetadata(metadata),
Expand Down
Loading
Loading