.agents/skills/llmobs-testing/references/category-strategies.md
A package may expose several kinds of surface. Choose the response source and expected span fields for each instrumented operation.
FORBIDDEN:
new Client() classes (orchestration libraries don't have Client classes)REQUIRED:
FORBIDDEN:
spanKind: 'workflow' for chat or generationREQUIRED:
fetchspanKind: 'llm'modelName and modelProviderExercise the real abstraction and provider path. ai records cassettes for some providers and hands a canned
fetch to others. Do not assign one kind to the package: ai and LangChain emit kinds such as llm, workflow,
embedding, retrieval, and tool according to the operation.
REQUIRED:
Response strategy depends on the surface under test:
| Surface | Response source | Harness |
|---|---|---|
| LLM client | Provider response | Real client through VCR or a canned fetch |
| Multi-provider | Provider response | Real abstraction and provider through VCR or a canned fetch |
| Orchestration | Plain node response | Native graph/workflow APIs |
| Infrastructure | Protocol handler response | SDK server and client over in-memory transport |
A cassette is the default provider response. Where the client takes a fetch option (openai-agents, some ai
providers) or only reaches for global.fetch (google-cloud-vertexai), the spec answers the call itself instead.
Strategy: the real SDK path through VCR or a canned fetch
const client = new MyLLMClient({
apiKey: 'test-key',
baseURL: 'http://127.0.0.1:9126/vcr/provider'
})
it('instruments chat completion', async () => {
await client.chat.completions.create({
messages: [{ role: 'user', content: 'Hello' }],
model: 'gpt-4'
})
const { apmSpans, llmobsSpans } = await getEvents()
assertLlmObsSpanEvent(llmobsSpans[0], {
span: apmSpans[0],
spanKind: 'llm',
name: 'MyLLMClient.createChatCompletion',
modelName: 'my-model',
modelProvider: 'my-provider',
inputMessages: [{ content: 'Hello', role: 'user' }],
outputMessages: [{ content: MOCK_STRING, role: 'assistant' }],
metrics: {
input_tokens: MOCK_NOT_NULLISH,
output_tokens: MOCK_NOT_NULLISH,
total_tokens: MOCK_NOT_NULLISH
},
tags: { ml_app: 'test', integration: 'my-integration' }
})
})
name and modelProvider are whatever the plugin reports, not strings the spec picks, and tags is required —
see assertion-helpers.md for the openai shape and the required-field list.
before()A module is instrumented by the require that runs after the hooks are installed: RITM's patched require pulls
the exports (from Node's cache if they are already there) and hands them to every registered hook.
useLlmObs() loads the tracer from its own before() hook, so a file-scope require returns the exports as
they were before any hook existed. Load the version fixtures from before():
withVersions('openai-agents', '@openai/agents', (version) => {
before(() => {
agentsCore = require(`../../../../../../versions/@openai/agents@${version}`).get()
const { OpenAIResponsesModel } =
require(`../../../../../../versions/@openai/agents-openai@${version}`).get()
})
})
beforeEach() works the same way and is what the openai and langgraph specs use; the requirement is any hook
rather than file scope.
Order between the two requires does not matter here: packages/datadog-instrumentations/src/openai-agents.js hooks
both @openai/agents and @openai/agents-openai. Where a spec's comment does ask for an order — the MCP spec
requires its client entry before the server — keep it.
Symptom when wrong: tests time out — getEvents() never resolves, no APM traces arrive, only the SDK's own
internal tracing output appears.
Strategy: Pure function tests, NO VCR, NO real API calls
No proxy and no client — the graph is built from the library's own exports, required from the version fixture in a hook so the tracer is already installed:
let StateGraph
let Annotation
beforeEach(() => {
const langgraph = require(`../../../../../../versions/@langchain/langgraph@${version}`).get()
StateGraph = langgraph.StateGraph
Annotation = langgraph.Annotation
})
it('creates a workflow span for streaming execution', async () => {
const StateAnnotation = Annotation.Root({
messages: Annotation({
reducer: (existingMessages, newMessages) => existingMessages.concat(newMessages),
default: () => [],
}),
})
const workflow = new StateGraph(StateAnnotation)
.addNode('chat', () => ({ messages: [{ role: 'assistant', content: 'Streaming response' }] }))
.addEdge('__start__', 'chat')
.addEdge('chat', '__end__')
const app = workflow.compile({ name: 'my-graph' })
const chunks = []
for await (const chunk of await app.stream({ messages: [{ role: 'user', content: 'Test' }] })) {
chunks.push(chunk)
}
assert.ok(chunks.length > 0, `Expected ${chunks.length} > 0`)
const { apmSpans, llmobsSpans } = await getEvents()
assertLlmObsSpanEvent(llmobsSpans[0], {
span: apmSpans[0],
spanKind: 'workflow',
name: 'my-graph',
inputValue: JSON.stringify({ messages: [{ role: 'user', content: 'Test' }] }),
outputValue: MOCK_STRING,
tags: { ml_app: 'test', integration: 'langgraph' },
})
})
The instrumented entry point is Pregel.stream, so the span closes when the iterator is drained, and name is
whatever compile({ name }) was given. The graph's state shape comes from Annotation.Root, and the terminal
nodes are the '__start__' / '__end__' literals the spec uses.
useLlmObs() installs the tracerPure orchestration operations do not make provider HTTP calls. Their tests exercise graph state and execution rather than a provider API.
Strategy: the SDK's own server and client, wired over its in-memory transport
const server = new McpServer({ name: 'test-server', version: '1.0.0' })
server.registerTool('test-tool', { description: 'A test tool', inputSchema: {} }, async () => ({
content: [{ type: 'text', text: 'Result from test-tool' }],
}))
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
await server.connect(serverTransport)
client = new Client({ name: 'test-client', version: '1.0.0' })
await client.connect(clientTransport)
it('creates a tool span for a basic tool call', async () => {
await client.callTool({ name: 'test-tool', arguments: {} })
const { apmSpans, llmobsSpans } = await getEvents()
assertLlmObsSpanEvent(llmobsSpans[0], {
span: apmSpans[0],
spanKind: 'tool',
name: 'MCP Client Tool Call: test-tool',
inputValue: JSON.stringify({ name: 'test-tool', arguments: {} }),
outputValue: JSON.stringify({
content: [{ type: 'text', text: 'Result from test-tool', annotations: {}, meta: {} }],
isError: false,
}),
tags: {
ml_app: 'test',
integration: 'modelcontextprotocol-sdk',
mcp_tool_kind: 'client',
mcp_server_name: 'test-server',
mcp_server_version: '1.0.0',
},
})
})
'tool' for tool calls and 'task' for list-toolsmcp_tool_kind, mcp_server_name, mcp_server_version)inputValue / outputValue carry the JSON-stringified protocol payloadsThe question that picks the response source is whether the instrumented operation reaches a provider. Pointing an
orchestrator at a proxy baseURL, or letting a node construct a real client, tests the provider instead of the graph;
letting a client reach https://api.openai.com needs a key and stops being deterministic.
const openai = new OpenAI({
apiKey: 'test',
baseURL: 'http://127.0.0.1:9126/vcr/openai'
})
await openai.chat.completions.create({ ... })
const model = createOpenAI({
apiKey: 'test',
baseURL: 'http://127.0.0.1:9126/vcr/openai'
})
await generateText({ model, prompt: '...' })
const workflow = new StateGraph(StateAnnotation)
.addNode('chat', () => ({ messages: [{ role: 'assistant', content: 'Mock' }] }))
.addEdge('__start__', 'chat')
.addEdge('chat', '__end__')
const app = workflow.compile({ name: 'my-graph' })
for await (const chunk of await app.stream({ messages: [] })) { /* drain to close the span */ }
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
await server.connect(serverTransport)
await client.connect(clientTransport)
await client.callTool({ name: 'test-tool', arguments: {} })