datadog/llmobs-testing
| Use when writing, modifying, or debugging tests for an LLMObs plugin in "assertLlmObsSpanEvent", "useLlmObs", "getEvents", any MOCK_* matcher ("MOCK_STRING" / "MOCK_NOT_NULLISH" / "MOCK_NUMBER" / "MOCK_OBJECT"),
npx skills add https://github.com/DataDog/dd-trace-js --skill llmobs-testing
That choice picks the response source and test setup — cassettes for a workflow record nothing, while
pure-function tests for a provider-backed call miss the network surface entirely. The operation independently
determines its span kind and fields. These are working categories for reasoning; none exists as a code constant.
anthropic, genai, ai, langchain): VCR cassettes or a canned fetch.
drive nodes with plain return values.
and client over its in-memory transport.
fetch instead of a cassette — where the spec supplies the responses itself: google-cloud-vertexaiswaps global.fetch per test and stubs Google auth, openai-agents and some ai providers pass a fetch option
to the client they construct.
See references/category-strategies.md for the forbidden-vs-required matrix per
strategy.
LLMObs tests use special helpers to validate span events.
Key components:
useLlmObs() - Initializes LLMObs test environmentgetEvents() - Retrieves captured span eventsassertLlmObsSpanEvent() - Validates span structure with flexible matchersBasic test flow:
useLlmObs({ plugin: 'name' })getEvents()assertLlmObsSpanEvent()See references/test-structure.md for complete test file templates.
Provider traffic is recorded once and replayed afterwards. Clients reach the proxy at
http://127.0.0.1:9126/vcr/{provider}; the category block above decides which categories use it at all.
Two facts block every first run:
docker compose up -d testagent.Without it every call fails with ECONNREFUSED 127.0.0.1:9126, which reads like a provider outage.
packages/dd-trace/test/llmobs/cassettes/{provider}/, withgenerated names, rather than beside the spec.
See references/vcr-cassettes.md for recording, provider mapping, body
normalizers, and the commands to run a single integration.
The block at the top maps response source to test strategy. The operation maps independently to a span kind:
http://127.0.0.1:9126/vcr/{provider} or a canned fetch. Chat and generation emit llm; LangChain and ai
also expose operations with other kinds.
'workflow' or 'agent', never 'llm' — the orchestrator coordinates libraries thatcall providers rather than calling them itself. Nodes return plain values, so the test exercises graph execution
instead of a provider API.
validation, no VCR.
See references/category-strategies.md for the patterns per shape.
assertLlmObsSpanEvent(actual, expected)
Validates span structure with flexible matchers for non-deterministic values.
Available matchers: each one is a typeof or nullish check, not a value check.
MOCK_STRING - any string, '' included (use for output text)MOCK_NOT_NULLISH - anything but null / undefined, so 0 and '' pass (use for token counts)MOCK_NUMBER - any numberMOCK_OBJECT - anything with typeof 'object', null included (opaque schema / metadata payloads, ora whole output message whose shape varies, as the ai specs do)
Required fields: span, spanKind, name, tags. A missing tags throws
TypeError: Cannot read properties of undefined (reading 'ml_app') instead of failing an assertion, and every
plugin span carries at least { ml_app: 'test', integration: '<integration>' }.
Optional fields: modelName, modelProvider, inputMessages, outputMessages, inputDocuments,
outputDocuments, inputValue, outputValue, metrics, metadata, toolDefinitions, error, parentId,
sessionId, traceId. Omitting a field asserts its absence rather than ignoring it: no model fields, no input, no
output, no metadata, no tool definitions, metrics of {}, status: 'ok', and the root parent id. traceId is
the exception: omission defaults to MOCK_STRING because every event has one. See
references/assertion-helpers.md for the patterns.
Location: packages/dd-trace/test/llmobs/plugins/{integration}/index.spec.js. One file per
major-version surface when the SDK's shape changed across majors, named after it rather than kept in
one file — openaiv3.spec.js / openaiv4.spec.js, index.spec.js / index.v7.spec.js.
Structure:
'../../util'useLlmObs() installs the tracer, then recreate mutable clients per testdescribe('chat completions', ...))Standard imports:
useLlmObs, assertLlmObsSpanEvent, MOCK_STRING, MOCK_NOT_NULLISH, MOCK_NUMBER, MOCK_OBJECT
See references/test-structure.md for complete template.
SPAN_KINDS in packages/dd-trace/src/llmobs/constants/tags.js is the list the public SDK validates against:
llm (chat / completions), workflow, agent, task (a unit of work inside a workflow), tool, embedding,
retrieval. Plugins set the kind directly and skip that validation, so kinds outside the list exist — ai v7
and claude-agent-sdk both emit step.
Pinning a field the kind never emits asserts metadata production does not produce:
llm — modelName, modelProvider, inputMessages / outputMessages, and any emitted token metrics /metadata
embedding — modelName, modelProvider, inputDocuments, outputValue, sometimes metricsretrieval — inputValue, outputDocumentsworkflow / agent / task / step / tool — kind-specific inputValue / outputValue, sometimesmetadata, never
model fields or token metrics
Cover every instrumented method, and a multi-turn conversation where the surface takes one.
On errors the span is still submitted. Match the plugin's output contract: OpenAI and GenAI carry
outputMessages: [{ content: '', role: '' }], while Anthropic and non-llm integrations may omit output.
Pass a truthy marker to expect an error:
error: {},
The option decides only whether the expected event carries status: 'error' — assertLlmObsSpanEvent
copies the three error fields out of the span it is checking, so the marker does not pin the throw.
A call that resolves still fails on that status. Pin which error was thrown on the APM span the LLMObs
span was built from:
assert.strictEqual(apmSpans[0].meta['error.message'], error.message)
Take datadog/llmobs-testing from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.