Repository navigation
feat(server-utils): Record LangChain TypeSafeClassifier runs as gen_ai.evaluate spans #25120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
11e88d2
feat(server-utils): Record LangChain TypeSafeClassifier runs as gen_a…
andreiborza 973ab3e
Set classifier span attributes at start and restore handler type
andreiborza a63f8ef
Record the classifier's wire state as the span input
andreiborza de8bbca
Update dev-packages/node-integration-tests/suites/tracing/langchain/v…
andreiborza 7db245d
Merge remote-tracking branch 'origin/develop' into ab/langchain-types…
andreiborza 99b5dad
Merge remote-tracking branch 'origin/ab/langchain-typesafe-evaluate-s…
andreiborza 9bcd985
Remove the flush from the TypeSafe classifier scenario
andreiborza 3c88c34
Keep the parent run callbacks when a LangChain call has none
andreiborza cce3f61
Clean up sampled-out chain runs and match the classifier by class name
andreiborza File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
61 changes: 61 additions & 0 deletions
61
...on-tests/suites/tracing/langchain/v1/scenario-typesafe-classifier-inherited-callbacks.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import { BaseCallbackHandler } from '@langchain/core/callbacks/base'; | ||
| import { RunnableLambda } from '@langchain/core/runnables'; | ||
| import { TypeSafeClassifier } from '@langchain/typesafe'; | ||
| // Sets up the AsyncLocalStorage that passes a parent run's config to its children, as it is inside `createAgent`. | ||
| import '@langchain/langgraph'; | ||
| import * as Sentry from '@sentry/node'; | ||
| import express from 'express'; | ||
|
|
||
| function startMockTypeSafeServer() { | ||
| const app = express(); | ||
| app.use(express.json()); | ||
|
|
||
| app.post('/v1/systemone', (req, res) => { | ||
| res.json({ | ||
| model: 'jev-1.13', | ||
| answers: { urgent: { type: 'noul', noul: 0.9 } }, | ||
| usage: { input_tokens: 30, output_tokens: 2 }, | ||
| }); | ||
| }); | ||
|
|
||
| return new Promise(resolve => { | ||
| const server = app.listen(0, () => { | ||
| resolve(server); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| // Stands in for a tracer such as LangSmith, which the user passes to the parent run. | ||
| class RecordingHandler extends BaseCallbackHandler { | ||
| name = 'RecordingHandler'; | ||
| runs = []; | ||
|
|
||
| handleChainStart(chain, _inputs, _runId, parentRunId) { | ||
| this.runs.push(`${chain.id.at(-1)}:${parentRunId ? 'child' : 'root'}`); | ||
| } | ||
| } | ||
|
|
||
| async function run() { | ||
| const server = await startMockTypeSafeServer(); | ||
| const baseUrl = `http://localhost:${server.address().port}`; | ||
|
|
||
| await Sentry.startSpan({ op: 'function', name: 'main' }, async span => { | ||
| const classifier = new TypeSafeClassifier({ | ||
| apiKey: 'mock-api-key', | ||
| baseUrl, | ||
| questions: { urgent: { type: 'noul', instructions: 'Is this urgent?' } }, | ||
| }); | ||
|
|
||
| // Like the `@langchain/typesafe` middlewares, call the classifier without a config. | ||
| const parent = RunnableLambda.from(input => classifier.invoke(input)).withConfig({ runName: 'parent' }); | ||
| const recorder = new RecordingHandler(); | ||
| await parent.invoke('My payouts have been failing.', { callbacks: [recorder] }); | ||
|
|
||
| span.setAttribute('test.recorded_runs', recorder.runs.join(',')); | ||
| }); | ||
|
|
||
| await Sentry.flush(2000); | ||
| server.close(); | ||
| } | ||
|
|
||
| run(); |
43 changes: 43 additions & 0 deletions
43
...kages/node-integration-tests/suites/tracing/langchain/v1/scenario-typesafe-classifier.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { HumanMessage } from '@langchain/core/messages'; | ||
| import { TypeSafeClassifier } from '@langchain/typesafe'; | ||
| import * as Sentry from '@sentry/node'; | ||
| import express from 'express'; | ||
|
|
||
| function startMockTypeSafeServer() { | ||
| const app = express(); | ||
| app.use(express.json()); | ||
|
|
||
| app.post('/v1/systemone', (req, res) => { | ||
| res.json({ | ||
| model: 'jev-1.13', | ||
| answers: { urgent: { type: 'noul', noul: 0.9 } }, | ||
| usage: { input_tokens: 30, output_tokens: 2 }, | ||
| }); | ||
| }); | ||
|
|
||
| return new Promise(resolve => { | ||
| const server = app.listen(0, () => { | ||
| resolve(server); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| async function run() { | ||
| const server = await startMockTypeSafeServer(); | ||
| const baseUrl = `http://localhost:${server.address().port}`; | ||
|
|
||
| await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { | ||
| const classifier = new TypeSafeClassifier({ | ||
| apiKey: 'mock-api-key', | ||
| baseUrl, | ||
| questions: { urgent: { type: 'noul', instructions: 'Is this urgent?' } }, | ||
| }); | ||
|
|
||
| await classifier.invoke('My payouts have been failing.'); | ||
| await classifier.invoke(new HumanMessage('My card was charged twice.')); | ||
| }); | ||
|
|
||
| server.close(); | ||
| } | ||
|
|
||
| run(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
86 changes: 86 additions & 0 deletions
86
packages/server-utils/src/ai/langchain/typesafe-classifier.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| import { SENTRY_ORIGIN } from '@sentry/conventions/attributes'; | ||
| import type { Span } from '@sentry/core'; | ||
| import { isObjectLike } from '@sentry/core'; | ||
| import { addResponseAttributes, startEvaluateSpan } from '../typesafe'; | ||
| import { LANGCHAIN_ORIGIN } from './constants'; | ||
| import { getAgentNameFromMetadata, getConversationIdFromMetadata } from './utils'; | ||
|
|
||
| // `TypeSafeClassifier` from `@langchain/typesafe` calls Jev with `fetch`, not through `@typesafe-ai/sdk`, | ||
| // so the TypeSafe integration does not see it. Its serialized id is `[...lc_namespace, lc_name()]`; only | ||
| // the class name is matched, as the namespace is the part that changes between versions. | ||
| const TYPESAFE_CLASSIFIER_NAME = 'TypeSafeClassifier'; | ||
|
|
||
| /** The package's default, used when the classifier is constructed without a `model`. */ | ||
| const DEFAULT_TYPESAFE_CLASSIFIER_MODEL = 'jev-latest'; | ||
|
|
||
| // The `state` the classifier sends to Jev (messages rendered as transcript lines), keyed by the input | ||
| // passed to `invoke()`. LangChain hands that same object to `handleChainStart`, so the span records what | ||
| // Jev received rather than LangChain's serialized messages. | ||
| const wireStates = new WeakMap<object, unknown>(); | ||
|
|
||
| /** Record the `state` a `TypeSafeClassifier` sends for `input`, using the classifier's own serializer. */ | ||
| export function recordTypeSafeClassifierState(classifier: unknown, input: unknown): void { | ||
| // A string state is sent as is, so there is nothing to record. | ||
| if (!isObjectLike(input) || !isObjectLike(classifier) || typeof classifier.payload !== 'function') { | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| const body: unknown = JSON.parse(classifier.payload(input)); | ||
| if (isObjectLike(body)) { | ||
| wireStates.set(input, body.state); | ||
| } | ||
| } catch { | ||
| // The classifier rejects the same input itself; the span keeps LangChain's form of it. | ||
| } | ||
| } | ||
|
|
||
| // Typed loosely: LangChain's `Serialized` union does not match our handler's chain type. | ||
| 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. */ | ||
| export function startTypeSafeClassifierSpan( | ||
| chain: unknown, | ||
| inputs: Record<string, unknown>, | ||
| metadata: Record<string, unknown> | undefined, | ||
| recordInputs: boolean, | ||
| ): Span { | ||
| 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, { | ||
| [SENTRY_ORIGIN]: LANGCHAIN_ORIGIN, | ||
| ...getAgentNameFromMetadata(metadata), | ||
| ...getConversationIdFromMetadata(metadata), | ||
| }); | ||
| } | ||
|
|
||
| /** The classifier result reports usage in camelCase, the TypeSafe helpers read the API's snake_case. */ | ||
| export function addTypeSafeClassifierResponseAttributes(span: Span, outputs: unknown, recordOutputs: boolean): void { | ||
| if (!isObjectLike(outputs)) { | ||
| return; | ||
| } | ||
|
|
||
| const usage = isObjectLike(outputs.usage) ? outputs.usage : {}; | ||
| addResponseAttributes( | ||
| span, | ||
| { | ||
| model: outputs.model, | ||
| answers: outputs.answers, | ||
| usage: { input_tokens: usage.inputTokens, output_tokens: usage.outputTokens }, | ||
| }, | ||
| recordOutputs, | ||
| ); | ||
| } | ||
|
|
||
| function getState(inputs: Record<string, unknown>): unknown { | ||
| // LangChain hands a string or array input to callbacks wrapped as `{ input }`. | ||
| const keys = Object.keys(inputs); | ||
| const input = inputs.input; | ||
| const state = | ||
| keys.length === 1 && keys[0] === 'input' && (typeof input === 'string' || Array.isArray(input)) ? input : inputs; | ||
|
|
||
| return isObjectLike(state) && wireStates.has(state) ? wireStates.get(state) : state; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.