npx skills add ...
npx skills add datadog-labs/agent-skills --skill llm-obs-eval-bootstrap
npx skills add datadog-labs/agent-skills --skill llm-obs-eval-bootstrap
Bootstrap evaluators from production traces — by default propose online LLM-judge evaluators and, after you confirm, create them in Datadog as disabled drafts (never auto-enabled); on request emit Python SDK code or a framework-agnostic JSON spec instead. Use when user says "bootstrap evaluators", "generate evaluators", "create evals from traces", "eval bootstrap", "write evaluators", "build eval suite", "publish evaluators", or wants to generate BaseEvaluator/LLMJudge code or online judge configs from production LLM trace data. Works with ml_app and optional RCA report or failure hypothesis.
Detection — At the start of every invocation, before taking any action, determine which backend to use:
--backend pup anywhere in their invocation → use pup mode immediately, regardless of whether MCP tools are present. Skip steps 2–4.mcp__datadog-llmo-mcp__list_llmobs_evals appears in your available tools.pup is executable: run pup --version via Bash. A JSON response containing "version" confirms pup is available."Neither the Datadog MCP server nor the pup CLI is available. Connect the MCP server (
claude mcp add --scope user --transport http datadog-llmo-mcp 'https://mcp.datadoghq.com/api/unstable/mcp-server/mcp?toolsets=llmobs') or install pup."
--backend pup is accepted anywhere in the invocation arguments and is stripped before passing remaining args to the skill logic.
pup invocation rules:
pup llm-obs <subcommand> [flags][{"type": "text", "text": "<json>"}]).pup auth login and stop.1h, 7d, 30m) and RFC3339 timestamps. Do not use now--prefixed strings — strip the prefix when converting from a skill --timeframe argument: now-7d → 7d, now-24h → 24h, now-30d → 30d.--summary on pup llm-obs spans search strips payload fields to essential metadata only. Use it in bulk/search phases where content is not needed.Invocation ID: At the very start of each invocation, before any MCP tool call, generate an 8-character hex invocation ID (e.g., 3a9f1c2b). Keep it constant for the entire invocation.
Intent tagging: On every MCP tool call, prefix telemetry.intent with skill:llm-obs-eval-bootstrap[<inv_id>] — followed by a description of why the tool is being called. On the first MCP tool call only, use skill:llm-obs-eval-bootstrap:start[<inv_id>] — instead (note the :start suffix). Example first call: skill:llm-obs-eval-bootstrap:start[3a9f1c2b] — Phase 0: map existing eval coverage for task-cruncher
Given a sample of production LLM traces, analyze input/output patterns and quality dimensions, then propose a ready-to-use evaluator suite. Four output modes — online evaluators are the default; SDK code, the JSON spec, and the dataset-emit mode are produced on request:
publish (default) — propose online LLM-judge evaluators, then — only after you confirm the suite — write them to Datadog via create_or_update_llmobs_evaluator as disabled drafts (enabled: false). Nothing is created until you confirm at the Phase 2 checkpoint, and nothing scores any spans until you enable it in the UI — the skill never auto-publishes a live evaluator. Once you enable a draft, it runs automatically on matching production spans, traces, or sessions (no dataset, no task function). The skill auto-classifies each proposed evaluator as span-scoped, trace-scoped, or session-scoped based on what the judgment requires (a per-LLM-call tone check vs. an agent goal completion that needs the whole trace vs. user satisfaction across a whole multi-trace conversation) — you accept or override the classification at that checkpoint. Session-scoped evaluators are only proposed when the app's spans carry a session_id (verified by a probe in Phase 1).sdk_code (on request — --sdk-code, or ask after a publish run) — Python .py file using the Datadog Evals SDK (BaseEvaluator / LLMJudge) for offline experiments.data_only (on request — --data-only) — self-contained JSON spec, framework-agnostic.emit_dataset (on request — --emit-dataset <path>) — sample production traces and write a DatasetRecordRaw[] JSON file shaped for LLMObs.create_dataset(records=...). Skips evaluator proposal and generation entirely — this mode produces a dataset, not evaluators. Used by llm-obs-eval-pipeline (Phase 4) to seed an experiment dataset from production behavior.After a publish run, if the user wants the same suite as offline code or a portable spec, they just ask — the skill regenerates the already-confirmed suite in sdk_code / data_only mode without re-exploring (see "On-request code generation" in Phase 3). The emit_dataset mode is independent of the evaluator workflow and never re-uses a prior proposal — it always re-samples traces.
Arguments: $ARGUMENTS
| Input | Required | Default | Description |
|---|---|---|---|
ml_app | Yes | — | ML application to scope traces |
timeframe | No | now-7d | How far back to look |
rca_report | No | — | Failure taxonomy from eval-trace-rca skill, or a free-text failure hypothesis |
--sdk-code | No | off | Emit a Python SDK .py file for offline experiments instead of publishing online. Mutually exclusive with --data-only and --emit-dataset. |
--data-only | No | off | Emit a self-contained JSON spec file instead of publishing online. Mutually exclusive with --sdk-code and --emit-dataset. |
--emit-dataset <path> | No | off | Dataset-only mode. Sample production traces and write a DatasetRecordRaw[] JSON to <path>. Skips the evaluator workflow entirely. Mutually exclusive with --sdk-code and --data-only. |
--trace-limit | No | 20 (cap 50) | Max traces to sample in emit_dataset mode |
If ml_app is missing, ask the user before proceeding. With no mode flag, the skill defaults to publish — it proposes online evaluators and, only after you confirm, creates them as disabled drafts (it never auto-enables them). If more than one of --sdk-code, --data-only, --emit-dataset is supplied, error out and ask which mode the user wants.
| Tool | Purpose |
|---|---|
search_llmobs_spans | Find spans by eval presence, tags, span kind, query syntax. Paginate with cursor. |
get_llmobs_span_details | Metadata, evaluations (scores, labels, reasoning), and content_info map showing available fields + sizes. |
get_llmobs_span_content | Actual content for a span field. Supports JSONPath via path param for targeted extraction. |
get_llmobs_trace | Full trace hierarchy as span tree with span counts by kind. |
get_llmobs_agent_loop | Chronological agent execution timeline (LLM calls, tool invocations, decisions). |
list_llmobs_evals | List every evaluator configured for the caller's org across all ml_apps, with enabled status and ml_app per result. Call once in Phase 0 to map existing coverage before proposing new evaluators — filter the result by ml_app client-side. |
get_llmobs_evaluator | Fetch the full persisted evaluator config by name (target ml_app + sampling + filter, provider, prompt template, parsing type, output schema, assessment criteria). Use in Phase 0 to understand what each existing custom eval measures, and (in publish mode) before any update — create_or_update_llmobs_evaluator is full-replace, so you must round-trip the full config to avoid clobbering fields. Not all evaluators have a stored config (notably source=ootb); a not-found error there is expected — skip those. |
create_or_update_llmobs_evaluator | (publish mode) Write an LLM-judge evaluator config to Datadog. Full-replace semantics: any omitted optional field resets to its default. See "Publishing Conventions" for required fields and structured output → JSON schema mapping. |
delete_llmobs_evaluator | (publish mode) Only used if the user explicitly asks to remove an evaluator. Never invoke speculatively. |
get_llmobs_span_content PatternsUse the path parameter to extract targeted data without fetching full payloads:
| Field | Path | What you get |
|---|---|---|
messages | $.messages[0] | System prompt (first message, usually system role) |
messages | $.messages[-1] | Last assistant response |
messages | (no path) | Full conversation including tool calls |
input / output | — | Span I/O |
documents | — | Retrieved documents (RAG apps) |
metadata | — | Custom metadata (prompt versions, feature flags, user segments) |
search_llmobs_spansAdditional filters combine with space (AND): @status:error @ml_app:my-app. Dedicated params (span_kind, root_spans_only, ml_app) work alongside query, but query takes precedence over tags.
To find spans with a specific eval: @evaluations.custom.<eval_name>:* — you can only query for eval presence, not specific results.
To detect whether the app uses sessions: session_id:* matches any span carrying a session_id (session_id is a first-class field — no @ prefix). The Phase 1 session probe uses this to gate session-scope evaluators.
get_llmobs_span_details: Group span_ids by trace_id. One call per trace_id with ALL its span_ids. Issue ALL calls for a page in a single message.get_llmobs_span_content: Each call is independent — always issue ALL in a single message.get_llmobs_trace / get_llmobs_agent_loop: Parallelize across different traces in a single message.get_llmobs_span_details for page 1 results immediately — don't wait to collect all pages.Applies to
sdk_codemode only. Indata_onlymode, use this section as domain context when writing rubric prompts — no SDK classes are emitted.
Only import what the generated file actually uses.
evaluate() receives)Important — span data vs evaluator data: When exploring production traces, you see span I/O (e.g., input.value, output.messages). But evaluators run in offline experiments where input_data and output_data come from the user's dataset records and task function, not from spans. The dataset schema is user-defined and may not match span structure. Write evaluator prompts with generic {{input_data}} / {{output_data}} placeholders and add comments describing what data the evaluator was designed for, so the user can adapt to their dataset shape.
evaluate() returns)Template variables in user_prompt: {{input_data}}, {{output_data}}, {{expected_output}}, {{metadata.key}} — resolved from EvaluatorContext fields via dot-path into nested dicts.
Boolean — true/false with optional pass/fail:
Score — numeric within a range with optional thresholds:
Categorical — select from predefined categories:
Custom JSON schema — arbitrary structured responses for multi-dimensional evals:
Always write standard JSON schema — the SDK adapts it per provider automatically (e.g., Anthropic doesn't support minimum/maximum on number fields, so the SDK moves range constraints into the description; Vertex AI converts const/anyOf to enum). The full parsed JSON dict becomes the eval value; a "reasoning" key (if present) is automatically extracted. No automatic pass/fail assessment.
The structured_output parameter enforces the response format via JSON schema. Do not prescribe the format in the prompt (no "Answer YES/NO", "Rate 1-10", etc.). Instead, describe the evaluation criteria and let the structured output handle the format.
{{input_data}} / {{output_data}}, then describe what good vs. bad looks like for this dimension.For deterministic checks that do not need LLM judgment:
| Signal | Evaluator Type |
|---|---|
| Output must be valid JSON | JSONEvaluator |
| Output must match a regex pattern | RegexMatchEvaluator |
| Output has length constraints | LengthEvaluator |
| Output must contain/not contain specific strings | StringCheckEvaluator |
| Semantic quality judgment (tone, accuracy, completeness) | LLMJudge + BooleanStructuredOutput |
| Graded quality on a scale | LLMJudge + ScoreStructuredOutput |
| Classification into categories | LLMJudge + CategoricalStructuredOutput |
| Multi-dimensional judgment (evaluate several aspects at once) | LLMJudge + custom JSON schema dict |
| Complex domain logic combining multiple checks | BaseEvaluator subclass |
If you have access to dd-trace-py locally, verify the API surface by reading the corresponding modules:
ddtrace.llmobs._evaluators.llm_judge — LLMJudge, BooleanStructuredOutput, ScoreStructuredOutput, CategoricalStructuredOutputddtrace.llmobs._experiment — BaseEvaluator, EvaluatorContext, EvaluatorResultddtrace.llmobs._evaluators.format — JSONEvaluator, LengthEvaluatorddtrace.llmobs._evaluators.string_matching — StringCheckEvaluator, RegexMatchEvaluatorEntry mode detection:
| Mode | Signal | Behavior |
|---|---|---|
| Cold Start | Only ml_app provided (no RCA, no hypothesis) | Full open discovery — understand what the app does, identify quality dimensions worth measuring, propose evals for coverage |
| From RCA | Conversation contains an RCA report or user provides a failure hypothesis | Skip open discovery — use existing failure taxonomy as eval targets |
Parse arguments: Extract ml_app (first non-flag argument), --timeframe (default now-7d), --trace-limit (default 20), --sdk-code, --data-only, and --emit-dataset <path> flags. Set output_mode as follows (at most one of the three mode flags may be set; error if more than one is present):
--emit-dataset <path> set → output_mode = emit_dataset. Skip the rest of the workflow entry-mode logic and jump directly to Phase 3D below.--sdk-code set → output_mode = sdk_code.--data-only set → output_mode = data_only.output_mode = publish (the default — propose online evaluators, gated on user confirmation, created as disabled drafts).Resolution steps:
If ml_app not provided → ask the user.
Auto-detect entry mode:
from_rca. Extract the taxonomy.from_rca. Use the hypothesis as the starting eval target.cold_start.If timeframe not provided → default to now-7d.
Map existing eval coverage — skip if output_mode = data_only (there is no Datadog eval project to check coverage against): Call list_llmobs_evals (org-wide; filter the result client-side to entries where ml_app == <ml_app>). Then, for each eval with source=custom, call get_llmobs_evaluator(eval_name=...) to inspect its prompt template, target, sampling, and filter, and infer which quality dimension it covers. Issue all evaluator calls in a single message (parallelize). Skip source=ootb evals — their names are self-describing and they may not have a fetchable config.
By the end of this step you have a complete coverage map: {eval_name → source, enabled, dimension}. Carry this into Phase 2 for deduplication.
In publish mode, also note any template-variable convention the existing custom evaluators already use (so a new suite reads consistently). Online evaluator templates resolve against the full span JSON, not against EvaluatorContext. See the "Online Template Variables" section under "Publishing Conventions" for the supported syntax ({{span_input}}, {{span_output}}, dot-paths, array selectors, filter accessors).
Notebook context detection: Scan the current conversation for a Datadog notebook URL that was produced by /eval-trace-rca (pattern: https://app.datadoghq.com/notebook/{numeric-id}). If found, store it as rca_notebook_url and extract the numeric ID as rca_notebook_id. This is used after Phase 3 to offer appending the evaluator suite to that notebook instead of creating a new one.
Goal: Sample production traces, understand what the app does, and identify quality dimensions worth measuring.
Sample the app: search_llmobs_spans(query="@ml_app:\"<ml_app>\" @status:ok", root_spans_only=true, limit=50, from=<timeframe>). Filter by @status:ok — error spans have no output to evaluate.
Session probe (gates session-scope proposals; publish mode): in the same message, also call search_llmobs_spans(query="@ml_app:\"<ml_app>\" session_id:*", limit=20, from=<timeframe>).
sessions_present = true. Note the distinct session_id values and, critically, whether the same session_id appears across multiple trace_ids — that cross-trace span is the real signal that a session carries context worth a session-scope evaluator. (A session_id that only ever maps to one trace adds nothing over trace scope.)sessions_present = false. Do not propose any session-scope evaluator; record a one-line "session scope skipped — no session_id on sampled spans" note for the proposal.Profile the app and identify evaluation target spans: Call get_llmobs_span_details for span_ids grouped by trace_id. Inspect content_info to classify:
| Signal | App Profile |
|---|---|
content_info has messages | LLM/chat app |
content_info has documents | RAG app |
Spans include agent kind | Agent app |
content_info has metadata | Has custom metadata |
Multiple span kinds in one trace (agent + tool / retrieval + llm from get_llmobs_trace) | Multi-step app — at least one trace-scope evaluator likely belongs in the suite (publish mode) |
Same session_id across multiple trace_ids (from the session probe) | Multi-trace sessions — at least one session-scope evaluator likely belongs in the suite (publish mode, gated on sessions_present) |
For agent/multi-step apps, also call get_llmobs_trace on 2-3 traces to see the full span hierarchy. Compare content_info between the root span and its sub-spans. Then ask two questions for each candidate quality dimension, in this order:
retrieval span's documents AND an llm span's answer; goal completion depends on the chain of tool calls AND the final response.) If yes → trace scope in publish mode. Don't try to compress this into a single span.Record the span-kind histogram (agent + tool + llm + retrieval) — multiple kinds under one root is a strong signal you'll have at least one trace-scope evaluator in the suite. See Phase 2's "Span vs. Trace vs. Session Scope Classification" for the mandatory walk-through of canonical trace-scope use cases (and, when sessions_present, the canonical session-scope use cases).
Extract content and identify targets: Call get_llmobs_span_content for representative spans. Fetch fields based on app profile:
| App Profile | Fields to Fetch |
|---|---|
| LLM/chat | messages (path=$.messages[0] for system prompt), output |
| RAG | documents, input, output |
| Agent | get_llmobs_agent_loop for the agent span, then messages for detail |
| Any with metadata | metadata |
Issue all calls in a single message. As you read, capture two streams of signal:
Generic quality signals — what does "success" look like? What variance exists across outputs? Each observed quality dimension becomes a candidate evaluator, with the traces you've just read as evidence. Also look for safety signals (scope violations, sensitive data in outputs, out-of-character responses) and add a safety evaluator if you find them.
Domain signals — these become the domain-specific evaluator category in Phase 2 (the highest-leverage category). For every 5–10 traces, write down:
applying for benefit X, comparing flight options, summarizing a policy, creating a widget)Don't try to enumerate domain signals exhaustively before reading traces — let the patterns surface as you read. The goal is breadth in the eventual proposal, not completeness in this exploration step.
Extract the failure taxonomy from the RCA report. Each failure mode with High or Medium severity becomes an eval target. Also run the Phase 1 session probe (query="session_id:*") to set sessions_present — a failure that only manifests across a multi-trace conversation (lost context, repeated mistakes, mounting frustration) is a session-scope target.
Check root cause categories for infrastructure failures. Before proposing evaluators, scan the Root Cause column of the taxonomy for any of: Instrumentation Deficiency, Harness Deficiency, Runtime Error, Upstream Data Issue, or any other root cause that points to infrastructure/environment rather than model behavior. If any are present, pause and ask:
"Some failure modes were diagnosed as infrastructure or instrumentation issues rather than model behavior (e.g.,
{list the infra root causes}). Evaluators can be designed two ways:
- Behavior-targeted (recommended for ongoing quality): measure whether the model produces correct, specific output — useful once the infrastructure is fixed and you want to track real quality
- Artifact-targeted (useful as regression guard): detect the specific broken output observed (e.g., generic placeholder responses) — catches regressions if the infrastructure breaks again
Which approach do you want, or both?"
expected_output / gold-standard examples as the quality bar.StringCheckEvaluator for a known bad string, LLMJudge that checks for generic placeholders).If all root causes are behavioral (System Prompt Deficiency, Tool Gap, Tool Misuse, Retrieval Failure, etc.) → skip this step and proceed directly.
For each target: if the RCA includes trace IDs, use them directly; otherwise search for matching traces. Fetch 2-3 traces per target with get_llmobs_span_content to understand the concrete pattern.
Goal: Present a concrete evaluator proposal for user confirmation.
In sdk_code / data_only mode — and for eval_scope: span in publish mode — each evaluator judges one data point: input and output for a single record/span, not a full trace or batch. In publish mode, eval_scope: trace judges a whole trace and eval_scope: session a whole multi-trace session — design those against the trace / session payload instead (see "Span vs. Trace vs. Session Scope Classification" below). Design evaluators accordingly for their scope.
Targeting depends on output_mode:
sdk_code / data_only → offline experiments. Template variables use EvaluatorContext fields ({{input_data}}, {{output_data}}). The actual data shape depends on the user's dataset and task function (see EvaluatorContext note in SDK Reference).publish → online evaluation on production spans. Template variables resolve against the full span JSON via dot-paths ({{meta.input.value}}, {{meta.output.messages[*].content}}, …) or the built-in span-kind-aware aliases ({{span_input}}, {{span_output}}). For eval_scope: trace and eval_scope: session, templates resolve against the trace payload ({{spans[...]}}) or the session payload ({{traces[*].spans[...]}}) instead. See "Online Template Variables" under Publishing Conventions for the full syntax. Each evaluator also needs eval_scope, sampling_percentage, and (optionally) filter — surface these in the proposal table so the user can confirm before publishing. Session scope is only used when the Phase 1 probe set sessions_present.Order proposals from broadest signal to most granular. Propose broadly, let the user curate — see "How many evaluators to propose" below.
Domain-specific evaluators — What does "good" mean for this specific app? These are the highest-leverage proposals because they capture quality bars generic evaluators miss. Derive them from the domain signals Phase 1 captured:
intent_classification or intent_handling_correctness evaluator scoped to the dominant intents.cited_url_is_real, agency_name_matches_request, monetary_amount_is_consistent_with_input).tool spans. Propose a per-tool argument-correctness evaluator for the tools with non-trivial schemas (e.g., search_flights_args_match_user_request, update_dashboard_widget_targets_correct_widget).cites_a_source, refuses_medical_advice, tone_matches_brand).Name each evaluator after the user-facing concern, not the technical check (agency_url_is_real over regex_url_match). Use the trace IDs you read in Phase 1 as evidence — at least one passing case and one failing case per evaluator if you saw both.
Outcome evaluators — Did this span / trace produce a good result for the request?
task_completion, answer_correctness, response_groundednessFormat evaluators — Does the output meet structural requirements?
valid_json_output, response_length, citation_formatSafety evaluators — Does the output stay within appropriate boundaries?
no_pii_leakage, scope_adherence, no_hallucinationThe default 4-6 cap from the older skill version was too tight — it pushed the skill toward generic evaluators only and left domain signals on the table. Updated guidance:
response_quality") if you don't have evidence for them.In data_only mode: skip this section entirely (coverage map was not built in Phase 0). Proceed directly to the proposal table.
Before building the proposal, apply the coverage map from Phase 0. Coverage is keyed on (dimension, scope) — not on dimension alone: every OOTB evaluator runs at span scope, and an enabled OOTB eval does NOT preclude proposing a trace-scope or session-scope evaluator for the same dimension. The three scopes answer different questions.
Enabled span-scope eval (OOTB or custom) for dimension D:
Goal Completeness evaluates each LLM span in isolation; this trace-scope goal_completion checks whether the agent's full sequence of steps achieved the user's request, and a session-scope session_goal_completion checks it across the whole conversation — three different questions."Enabled trace-scope custom eval for dimension D: do NOT propose another trace-scope evaluator for the same dimension; that's a real duplicate. Span-scope on the same dimension is still fair game if the data also fits a single span, and session-scope is fair game if the dimension also needs cross-trace context. Likewise, an enabled session-scope custom eval for D blocks only another session-scope eval for D — span and trace scope remain fair game.
Disabled OOTB eval: Do NOT propose a new custom span-scope evaluator for that dimension. Instead, surface it in a short note within the proposal and suggest enabling it in the Datadog UI rather than creating a duplicate. Example:
hallucination(ootb, disabled) — consider enabling in Datadog UI (Evaluations → Configure) instead of creating a custom span-scope eval. (A trace-scoperag_faithfulnessis still in scope and covers a different question.)
Gap identification: Open the proposal with a coverage summary line: "Existing coverage: N evaluator(s) already configured ({names}, all span-scope unless noted). Proposing evaluators for uncovered dimensions and uncovered scopes."
All dimensions covered: A dimension is "fully covered" only when the relevant scopes are present (span, plus trace and/or session where the app shape calls for them). If the coverage map accounts for every identified quality dimension at the appropriate scope(s), surface this explicitly and ask the user what they want: (a) review/improve existing eval prompts, (b) add coverage for additional dimensions, or (c) proceed anyway.
For each proposed evaluator:
^[a-zA-Z0-9_-]+$ (alphanumeric, underscore, hyphen only)LLMJudge (Boolean/Score/Categorical/custom JSON schema), built-in (JSONEvaluator, RegexMatchEvaluator, etc.), or BaseEvaluator subclass. In publish mode, only LLM-judge evaluators are supported by the MCP tool — code-based checks must NOT be silently dropped. List them in the same proposal table with Type set to the code-based class, mark them under a "Not publishable in this mode" subsection of the proposal, and tell the user they can get them as offline code on request (--sdk-code, or ask after the publish run) or as a --data-only spec. Treat the code-based proposals as part of the suite for counting and coverage purposes.anthropic.request", "all llm spans"). If the root span's I/O is too lossy for the quality dimension (e.g., tool call results aren't visible), note this and specify which sub-span has the signal. In publish mode this maps to a combination of eval_scope (span/trace/session), root_spans_only, and the EVP filter query (e.g. @meta.span.kind:llm or service:web).pass_when=True, min_threshold=7, pass_values=["correct"], or "no automatic assessment" for custom JSON schemainput_data, output_data, expected_output, metadata.* it uses (offline) — or which span paths / aliases it pulls from (publish mode: {{span_input}}, {{span_output}}, {{meta.input.messages[*].content}}, {{meta.metadata.<key>}}, etc.)publish mode): integration_provider (default openai), model_name (default gpt-5.4-mini), sampling_percentage (default 10), eval_scope (default span), and any filter query needed to scope to the right spans. Surface defaults in the proposal so the user can override before publishing.integration_account_id (only in publish mode): the integration account the judge LLM is called through. Auto-detected from existing evaluators in the same ml_app (Phase 0 coverage map). Never asked from the user as a raw UUID. If no existing evaluator has one, the field is omitted and the user picks an account in the UI before activating. All evaluators are published with enabled: false regardless — see "Always publish as draft" in Phase 3C for the full activation workflow.publish mode)Don't ask the user; classify per evaluator and let them override at the checkpoint.
If Phase 1 found multi-step traces (≥ 2 span kinds, or any tool / retrieval / workflow span under an agent root), you MUST walk through the four canonical trace-scope use cases below before finalizing the suite. For each, decide explicitly: applies (include with eval_scope: trace) or does not apply (record a one-line reason in a "Skipped trace-scope candidates" subsection of the proposal). Skipping all four without per-item justification is a sign you've over-anchored on span scope — re-check.
| Canonical use case | Triggers when |
|---|---|
goal_completion — did the agent finish the user's request? | Any agent / multi-step app. Almost always applies. |
tool_use_correctness — right tool with right arguments? | Trace contains tool kind spans. |
rag_faithfulness — answer grounded in retrieved documents? | Trace contains retrieval kind spans. |
conversation_quality — coherence across multi-turn LLM calls? | Trace contains ≥ 2 llm spans, or app instruments multi-turn sessions. |
sessions_present)Gate: perform this walk-through only if the Phase 1 session probe set sessions_present = true. If sessions are absent, skip session scope entirely and note "session scope skipped — no session_id on sampled spans" in the proposal.
When sessions_present, you MUST walk through the four canonical session-scope use cases below. For each, decide explicitly: applies (include with eval_scope: session) or does not apply (one-line reason in a "Skipped session-scope candidates" subsection). Session scope answers questions that span more than one trace under the same session_id — a single trace cannot see prior or later turns.
| Canonical session use case | Triggers when |
|---|---|
session_goal_completion — were the user's goals met across the whole session? | A session_id spans ≥ 2 traces. Almost always applies for multi-trace sessions. |
multi_turn_conversation_quality — coherence, memory, and consistent tone across turns | Multi-trace chat / assistant sessions. |
user_frustration_signals — frustration, confusion, repetition, or abandonment over the session | Any multi-turn session (repeated or rephrased asks across traces). |
agent_consistency_across_session — did the agent stay consistent and recover from errors across traces? | Agent app whose sessions span ≥ 2 traces. |
For other proposed evaluators (e.g. tone, format, safety), apply this scope test in order:
meta.input + meta.output, where "correctly" means the verdict cannot change if you considered other spans in the trace? → eval_scope: span.eval_scope: trace. Default to trace when the evaluator name contains grounding, faithfulness, hallucination, completeness, correctness across steps, consistency, or workflow — these almost always need cross-span context.session_id (overall satisfaction, behavior over time, multi-turn coherence) and sessions_present → eval_scope: session. Default to session when the name contains session, conversation, across turns, over time, satisfaction, frustration, or abandonment. If sessions_present is false, fall back to trace scope and note the limitation.Trace scope costs more than span scope: one judgment per completed trace (vs. per matching span), larger prompt payloads, and a 3-minute trigger latency (Datadog waits 3 minutes of inactivity before considering a trace complete; later spans are excluded). Session scope costs the most: one judgment per completed session (a session_id is complete after 30 minutes of inactivity — vs. 3 minutes for a trace — and spans arriving > 30 min after the prior span are excluded), with the largest payloads (every span of every trace in the session, capped at 10,000 spans). These are cost-control levers — handle with sampling_percentage and filter, not by demoting scope. The correctness of the eval is what picks the scope.
Add a Scope column to the proposal table and a one-sentence rationale per evaluator. If you skipped a canonical trace-scope or session-scope use case, list it under the matching "Skipped …-scope candidates" subsection with the reason — the user will see and can override.
Example rationales:
tone_check— span. Judging "is this single response polite" needs only one LLM span'smeta.output.messages[*].content; no other span in the trace can change that verdict.goal_completion— trace. Whether the agent finished the user's request depends on the sequence of tool calls and the final LLM response together —meta.outputof any single span only shows that step's output.tool_use_correctness— trace. Comparing tool inputs against the request and the final response requires correlating ≥ 3 spans (root, tool, final LLM).rag_faithfulness— trace. Grounding pairs theretrievalspan's documents with the LLM span's answer.session_goal_completion— session. Whether the user's overall goals were met depends on every trace in thesession_id, not just the last one — only session scope sees the full conversation.user_frustration_signals— session. Frustration surfaces as repeated or rephrased asks across traces; a single trace can't reveal the pattern.Example "Skipped trace-scope candidates" entry:
conversation_quality— skipped: traces contain a single LLM call (no multi-turn signal in this app's instrumentation).Example "Skipped session-scope candidates" entry:
session_goal_completion— skipped: everysession_idmaps to a single trace (no cross-trace context — trace scope already covers it).
You MUST output the proposal and wait for user confirmation before proceeding.
Which evaluators should I generate? Treat the proposal as a candidate set — the suite below is intentionally broad so you can pick what matters for your team's quality bar. Reply with which to keep, which to drop, and which to rename; not every domain-specific proposal will fit your priorities. In sdk_code mode you may also add custom evaluators or change provider/model. In publish mode you may override integration_provider, model_name, sampling_percentage, eval_scope, root_spans_only, or filter per evaluator. (In the default publish mode these are created as online drafts in Datadog on confirmation — you review and enable them in the UI. Prefer offline SDK code or a JSON spec instead? Say so and I'll generate the confirmed suite that way.)
Do NOT proceed to code generation until the user confirms.
Branch on output_mode:
publish (default) → skip to Phase 3Csdk_code → Phase 3A belowdata_only → skip to Phase 3Bemit_dataset → skip to Phase 3D (Phases 0 step 4, 1, and 2 are bypassed — see Phase 3D for the dataset-mode workflow)The default path publishes online evaluators. If the user then asks for the suite as offline code or a portable spec (e.g. "now generate the SDK code for these", "give me a JSON spec"), do not re-run Phase 1–2. Reuse the already-confirmed evaluator suite and jump straight to Phase 3A (sdk_code) or Phase 3B (data_only), translating each published online evaluator into the offline form:
{{input_data}} / {{output_data}} placeholders (offline data comes from the user's dataset/task function, not spans — see the EvaluatorContext note), preserving the rubric and pass criteria.BaseEvaluator / built-in evaluators here.This works the other way too: a user who started with --sdk-code can ask to publish the confirmed suite online (Phase 3C). The emit_dataset path is separate from the evaluator workflow — it never has a "confirmed suite" to translate.
Goal: Generate the final .py file and write it to disk.
For each confirmed evaluator, generate production-quality Python code following the SDK Reference patterns above.
Ground prompts in traces: LLMJudge system prompts and user prompts must reference patterns actually observed in production traces. Never write generic prompts like "evaluate whether the response is good" — ground them in the app's domain, observed failure patterns, and success criteria.
Keep template variables generic, add comments for context: Use {{input_data}} and {{output_data}} as top-level placeholders in prompts — do NOT reference nested span paths like {{input_data.messages[-1].content}}. The evaluator's data comes from the user's dataset and task function, not directly from spans. Instead, add a comment above each evaluator describing what data it was designed for and what the user should adapt:
Use the narrowest evaluator type: If a check can be done with JSONEvaluator, RegexMatchEvaluator, StringCheckEvaluator, or LengthEvaluator, do NOT use an LLMJudge. Code-based evaluators are faster, cheaper, and deterministic.
BaseEvaluator subclasses:
super().__init__(name=name) in __init__EvaluatorResult from evaluate()evaluate() (thread safety)Names: Must match ^[a-zA-Z0-9_-]+$. Use snake_case descriptive names.
Imports: Consolidate at the top of the file. Only import classes that are actually used.
Evaluator list: Collect all evaluators into an evaluators list at the bottom of the file.
Anonymize PII: Strip emails, names, and sensitive data from any trace content included in LLMJudge prompts or the header comment.
The generated .py file should follow this structure:
Only include section comments (Outcome/Format/Safety) for categories that have evaluators.
Write the generated code to the output path (suggest ./evals/{ml_app}_evaluators.py if not specified), then display a summary:
After displaying the summary, offer notebook export.
If rca_notebook_url was detected in Phase 0:
An RCA notebook was created earlier in this session:
{rca_notebook_url}Would you like to (a) append the evaluator suite summary to that notebook, or (b) create a new standalone notebook?
If append: use the notebook creation fallback pattern (see below) with mcp__datadog-mcp__edit_datadog_notebook (id={rca_notebook_id}, append_only=true, evaluator suite summary cell).
If new: use the notebook creation fallback pattern (see below) with mcp__datadog-mcp__create_datadog_notebook.
If no rca_notebook_url:
Would you like to export this evaluator suite summary to a Datadog notebook?
If yes: use the notebook creation fallback pattern (see below) with mcp__datadog-mcp__create_datadog_notebook:
name: Eval Bootstrap: {ml_app} — YYYY-MM-DDtype: reportcells: single markdown cell with the evaluator suite summarytime: { "live_span": "1h" }Notebook creation fallback pattern (apply to every create_datadog_notebook / edit_datadog_notebook call):
/tmp/nb_bootstrap_{ml_app}.json as a full API envelope: {"data": {"attributes": {"name": "...", "time": {...}, "cells": [...]}, "type": "notebooks"}}pup notebooks create --file /tmp/nb_bootstrap_{ml_app}.jsonEvaluator suite exported to notebook: <url>Notebook cell content — the markdown cell should contain:
Goal: Serialize the confirmed evaluator suite and representative trace samples to a single self-contained JSON file — zero SDK dependencies.
Output path: ./evals/{ml_app}_eval_spec.json
evaluators[].type: "llm_judge" for semantic evaluators; "code_check" for deterministic checks (regex, length, JSON validity, etc.).evaluators[].rubric: For llm_judge — full prompt text grounded in observed trace patterns. Use {{input}} and {{output}} as generic placeholders (not {{input_data}} — that's ddeval-specific). For code_check — null.evaluators[].implementation_hints.notes: Optional framework-agnostic guidance, e.g. "For OpenAI Evals, use rubric as a model-graded criterion. For Braintrust, use as an LLM scorer. For Promptfoo, use as an llm-rubric assertion."sample_records: 10–20 representative traces from Phase 1. suggested_labels are Claude's best-read from trace inspection — not ground truth. The field name communicates this explicitly.input, output, and evidence[].observation fields before writing (same as Phase 3A).sample_records from traces already fetched in Phase 1. Fetch additional traces (up to 20 total) if fewer than 10 were read.input, output, and evidence[].observation fields.Same logic as Phase 3A — offer to append to the RCA notebook if rca_notebook_url was detected, or create a new standalone notebook. Use the same notebook cell format as Phase 3A, substituting output_path with the JSON spec file path. In pup mode, use pup notebooks create / pup notebooks edit as described in Phase 3A.
Reached only after the user confirms the suite at the Phase 2 checkpoint — nothing below is written to Datadog before that.
Goal: For each confirmed evaluator, write an LLM-judge configuration to Datadog via create_or_update_llmobs_evaluator as a disabled draft (enabled: false). It scores no spans until the user reviews and enables it in the UI; once enabled, it runs automatically on matching production spans.
For every proposed eval_name, call get_llmobs_evaluator(eval_name=...):
Not found → safe to create.
Found → existing evaluator with the same name. Surface a diff to the user (existing dimension/prompt vs. proposed) and ask:
Evaluator
{name}already exists. Overwrite, rename, or skip?
If overwrite: keep the fetched config as the base and merge your generated fields on top, then send the complete object back. The MCP tool is full-replace — any field you omit (e.g. temperature, max_tokens, filter, sampling_percentage) reverts to its default. Never re-publish without round-tripping the existing config.
If rename: append a suffix (e.g. _v2) and treat as new.
If skip: drop from the publish set.
Required parameters for each create_or_update_llmobs_evaluator call: eval_name, application_name (= ml_app), enabled, integration_provider, model_name, prompt_template, parsing_type, output_schema, plus a telemetry.intent string.
Defaults to use unless the user overrides:
| Field | Default |
|---|---|
enabled | false (always — see "Always publish as draft") |
integration_provider | openai |
model_name | gpt-5.4-mini |
temperature | 0 |
parsing_type | structured_output |
sampling_percentage | 10 for span scope, 5 for trace scope, 5 for session scope (the heaviest — consider lowering) |
eval_scope | span (auto-promoted to trace or session per the classification rule in Phase 2) |
Prompt template: convert the LLMJudge prompt into the MCP shape — an ordered array of {role, content} messages. The system prompt becomes {role: "system"}, the user prompt becomes {role: "user"}. Use span-data placeholders (see below) — not the offline {{input_data}} / {{output_data}} form, which only exists in EvaluatorContext.
Online evaluator prompts run through the dd-source template library (domains/ml-observability/shared/libs/template). Missing paths → empty string. The data shape templates resolve against depends on eval_scope:
eval_scope: span (default) — placeholders resolve against a single span's JSON (the llmobs.Span JSON-marshaled to a map). Use the span aliases / dot-paths below directly.eval_scope: trace — placeholders resolve against the trace payload { spans: [...] }. Use {{spans[N]...}}, {{spans[*]...}}, or {{spans[field.path:value]...}} to select span(s) before applying field paths. The {{span_input}} / {{span_output}} aliases are not available in trace scope — reference span data through the spans array instead.eval_scope: session — placeholders resolve against the session payload { session_id, traces: [ { trace_id, root_span_id, spans: [...] }, … ] }. Reach spans through {{traces[*].spans[*]...}} (nested one level deeper than trace scope) plus the array selectors below. The {{span_input}} / {{span_output}} aliases and trace scope's top-level {{spans[...]}} are not available — spans live under traces[*].spans. Only used when sessions_present (Phase 1 probe).eval_scope: span)Built-in span-kind-aware aliases (preferred when the evaluator is generic across span kinds):
| Alias | LLM span (meta.span.kind = "llm") | Other spans (agent, workflow, task, …) |
|---|---|---|
{{span_input}} | meta.input.messages[*].content | meta.input.value |
{{span_output}} | meta.output.messages[*].content | meta.output.value |
Common explicit dot-paths (use when the evaluator is purpose-built for one span kind):
| Path | What you get |
|---|---|
{{meta.input.value}} / {{meta.output.value}} | Plain string I/O on agent / workflow / task / tool spans |
{{meta.input.messages[*].content}} | All input message contents on an LLM span (newline-joined) |
{{meta.input.messages[0].content}} | First message (typically system prompt) |
{{meta.output.messages[*].content}} | Assistant response(s) |
{{meta.input.documents}} | Retrieved docs (RAG) — JSON-serialized |
{{meta.metadata.<key>}} | Custom metadata fields |
{{meta.tool_definitions}} | Available tools — JSON array |
{{*}} | Entire span as compact JSON (debug / fall-back catch-all) |
eval_scope: trace)| Pattern | What you get |
|---|---|
{{spans}} | JSON of every span in the trace |
{{spans[N].meta.input.value}} | Single span by index — spans[0] is the trace root |
{{spans[*].name}} | All span names in order, newline-joined |
{{spans[*].meta.output.value}} | All spans' outputs, newline-joined (handy for "final answer = last output") |
{{spans[name:my-span].meta.input.value}} | Filter by span name |
{{spans[meta.span.kind:llm].meta.output.value}} | All LLM-kind span outputs |
{{spans[meta.span.kind:tool]}} | Whole tool spans as JSON, paired in/out — useful for tool-use correctness |
{{spans[meta.span.kind:retrieval].meta.output.documents[*].text}} | Text of every retrieved document — useful for RAG faithfulness |
{{*}} | Entire trace payload as JSON (debug fallback) |
eval_scope: session)| Pattern | What you get |
|---|---|
{{traces}} | JSON of every trace in the session, each { trace_id, root_span_id, spans: [...] } |
{{session_id}} | The session's ID |
{{traces[*].trace_id}} | Every trace ID in the session, newline-joined |
{{traces[0].spans[0].meta.input.value}} | First span of the first trace |
{{traces[*].spans[*].name}} | Every span name across the whole session |
{{traces[*].spans[*].meta.output.value}} | Every span's output across the session |
{{traces[*].spans[meta.span.kind:llm].meta.input.messages[*].content}} | All LLM-span input messages across the session — the user turns |
{{traces[*].spans[meta.span.kind:llm].meta.output.messages[*].content}} | All assistant responses across the session |
{{traces[*].spans[meta.span.kind:tool]}} | Every tool span (paired in/out) across the session |
{{traces[*].spans[meta.span.kind:retrieval].meta.output.documents[*].text}} | Text of every retrieved document across the session (RAG) |
{{*}} | Entire session payload as JSON (debug fallback) |
Note: the filter selector applies to the spans array (traces[*].spans[meta.span.kind:llm]), not to traces — a trace object has only trace_id, root_span_id, and spans, so there is nothing to filter on at the trace level.
[N] — index (0-based)[START,END] — inclusive range, END is clamped to slice length[*] — wildcard (fan-out over all elements)[field.path:value] — filter array elements by a nested field equality, e.g. messages[role:user] or spans[meta.span.kind:tool]Resolution rules to keep in mind when writing prompts:
messages.content behaves the same as messages[*].content[N] with a known index, or [*] for "last assistant turn" semanticsWhen to pick which form:
tone_check, output_format) → use {{span_input}} / {{span_output}} so it works across span kinds.system_prompt_adherence) → reach for explicit meta.input.messages[*].content / meta.output.messages[*].content so you can split system vs. user vs. assistant turns.{{meta.input.documents}} with {{span_output}}.{{meta.metadata.<key>}} directly.If the user has existing custom evaluators in the same ml_app (Phase 0 coverage map), match their convention when there is no strong reason to deviate.
Concrete user-prompt bodies for the four canonical trace-scope use cases, drawn from the public docs (Trace-Level Evaluations). Each goes alongside a static System prompt that describes the rubric (no placeholders).
| Use case | filter | User prompt body |
|---|---|---|
| Goal completion — agent finished the user's request | @parent_id:undefined @meta.span.kind:agent | User goal:\n{{spans[0].meta.input.value}}\n\nAgent steps:\n{{spans}} |
| Tool-use correctness — right tool with right arguments | @parent_id:undefined @meta.span.kind:agent | User question:\n{{spans[0].meta.input.value}}\n\nTool calls:\n{{spans[meta.span.kind:tool].meta.input.parameters}}\n\nFinal response:\n{{spans[*].meta.output.value}} |
| RAG faithfulness — answer grounded in retrieved docs | @parent_id:undefined | Retrieved context:\n{{spans[meta.span.kind:retrieval].meta.output.documents[*].text}}\n\nFinal answer:\n{{spans[meta.span.kind:llm].meta.output.value}} |
| Conversation quality — coherence and consistency across turns | @parent_id:undefined | Conversation:\n{{spans[meta.span.kind:llm].meta.input.messages[*].content}}\n\nAssistant responses:\n{{spans[meta.span.kind:llm].meta.output.messages[*].content}} |
Use these as starting points. Adapt the filter and span paths to the actual span names / kinds the app emits (observed during Phase 1).
Concrete user-prompt bodies for the four canonical session-scope use cases (adapted from the public Session-Level Evaluations docs). Each goes alongside a static System prompt that describes the rubric (no placeholders). The filter is matched against the session's root span (not every span), so scope it with root-span attributes; there is no @parent_id:undefined requirement beyond that.
| Use case | filter | User prompt body |
|---|---|---|
| Session goal completion — user's goals met across the session | @meta.span.kind:agent (or the app's root span kind) | Session traces (chronological):\n{{traces}} |
| Multi-turn conversation quality — coherence and memory across turns | @meta.span.kind:agent | User messages:\n{{traces[*].spans[meta.span.kind:llm].meta.input.messages[*].content}}\n\nAssistant responses:\n{{traces[*].spans[meta.span.kind:llm].meta.output.messages[*].content}} |
| User frustration signals — frustration / abandonment over the session | @meta.span.kind:agent | Full session:\n{{traces}} |
| Agent consistency across the session — consistent and recovers across traces | @meta.span.kind:agent | Session traces (chronological):\n{{traces}} |
Adapt the filter and span paths to the actual span kinds the app emits (observed in Phase 1).
output_schema wrapper format (required for all providers)
The output_schema field is NOT a bare JSON Schema. It must use the OpenAI json_schema object shape. name is a fixed type discriminator, not the evaluator name — the UI validates it against a strict allowlist and rejects any other value:
| LLMJudge type | name value | property key inside schema |
|---|---|---|
| Boolean | "boolean_eval" | boolean_eval |
| Score | "score_eval" | score_eval |
| Categorical | "categorical_eval" | categorical_eval |
The property key inside schema.properties must match name exactly. The required array may only be ["<type_key>"] or ["<type_key>", "reasoning"] — any other value is rejected. Always include "reasoning": {"type": "string"} for UI display.
Boolean (BooleanStructuredOutput(pass_when=True)):
Score (ScoreStructuredOutput(min_score=1, max_score=10, min_threshold=7)):
Add max_threshold to assessment_criteria if set.
Categorical (CategoricalStructuredOutput(categories={...}, pass_values=[...])):
Note: categorical uses "type": "string" alongside anyOf (each const is a string value), unlike the offline SDK which uses bare anyOf at the property root.
Custom / multi-dimensional: not directly supported via the fixed-name schema. Implement as a score or categorical evaluator where possible, or split into multiple evaluators. The name must be one of the three fixed values above.
Filter scoping: when the proposal targets a specific span kind (e.g. an LLM sub-span), translate it into an EVP filter query — e.g. @meta.span.kind:llm, service:checkout-agent, or a more specific tag. Combine with root_spans_only:true only when the target is the trace root.
For eval_scope: trace:
filter query must match the trace's root span only — always include @parent_id:undefined (or root_spans_only: true) to avoid double-firing across descendants. Combine with @meta.span.kind:agent (or whatever kind the app uses for root spans, observed in Phase 1) for narrowing.sampling_percentage to 5 for trace-scope evaluators (instead of the span default 10); the user can raise it after a manual review pass.For eval_scope: session:
sessions_present (Phase 1 probe). Otherwise the evaluator never fires — no span carries a session_id.session_id is complete after 30 minutes of inactivity (vs. 3 minutes for a trace); spans arriving > 30 min after the prior span are excluded. Surface both the latency and the exclusion — long-running or sparse sessions may be split or truncated, and very large sessions are capped at 10,000 spans.filter is matched against the session's root span (the same prefilter trace scope uses), so scope it with root-span attributes — e.g. @meta.span.kind:agent (or the app's root span kind, observed in Phase 1). You do not need @parent_id:undefined: the session root is already a root span.sampling_percentage to 5 (matching trace) and flag it as the heaviest scope; the user can lower it further after a manual review pass.enabled: false)Always create / update evaluators with enabled: false — regardless of whether integration_account_id was auto-detected from existing evaluators. The UI is the source of truth for activation; the skill should never auto-enable evaluators on the user's behalf. The user reviews each draft in the UI, confirms the integration account is correct (the auto-detected ID may belong to a different judge LLM than the one they want for this app), and flips the toggle when they're satisfied.
This makes the workflow safe by default: a wrong integration_account_id, a mistuned prompt, or an over-broad filter never goes live without a human pass. Auto-detection of the account ID still helps because the draft renders with the right account pre-selected — review is faster.
The integration_account_id is an opaque UUID that the UI matches against the org's integration accounts list to populate the account section dropdown. Users typically don't know this value, so never ask the user to supply a raw UUID.
Resolution order:
Inherit from existing evaluators — in Phase 0 you called get_llmobs_evaluator for each existing custom evaluator. Check the llm_provider.integration_account_id field on those responses. If any of them have a value, use that same ID on the published drafts. If multiple different IDs appear across existing evaluators, pick the most common one and note which you chose so the user can correct it during the UI review pass.
Omit if no existing evaluator has one — if no custom evaluator in the ml_app has an integration_account_id, omit the field from the publish payload. The draft will render without an account pre-selected; the user picks one during the UI review pass before activating.
Either way, the evaluator is published with enabled: false. The user is the gate — see "Always publish as draft" above.
Issue all create_or_update_llmobs_evaluator calls in a single message (one per evaluator). Set telemetry.intent to a short English description like "skill:llm-obs-eval-bootstrap — Bootstrap evaluator suite for ml_app=<ml_app> from production trace analysis.".
If any call fails, capture the error and continue with the remaining evaluators — never silently abort the batch. Report failures explicitly in the summary.
Same logic as Phase 3A — offer to append to the RCA notebook if rca_notebook_url was detected, or create a new standalone notebook. The notebook cell should list the published evaluators with their UI links and the ml_app they target. In pup mode, use pup notebooks create / pup notebooks edit as described in Phase 3A.
Goal: Sample production traces for ml_app, extract input / output pairs, and write a DatasetRecordRaw[] JSON file shaped for LLMObs.create_dataset(records=...). This mode does not propose or generate evaluators — Phases 1 and 2 are bypassed. The dataset is the sole artifact.
When this mode runs: triggered by --emit-dataset <path>. Skip Phase 0 step 4 (eval coverage map) — it's unrelated to dataset extraction.
search_llmobs_spans(query="@ml_app:\"<ml_app>\" @status:ok", root_spans_only=true, limit=<trace-limit>, from=<timeframe>). Filter to @status:ok so the resulting dataset records reflect intended app behavior (errored traces have no usable output baseline).get_llmobs_span_details (group by trace_id) and get_llmobs_span_content to read the root span's input and output fields. Issue all calls for a page in a single message (parallelize).For each sampled root span, build one DatasetRecordRaw entry:
| Field | Source | Notes |
|---|---|---|
input_data | Root span's meta.input | If meta.input.value is a plain string, wrap as {"input": "<value>"}. If meta.input.messages exists, use {"messages": [...]} (preserving the role/content shape). For RAG roots that also have documents, include {"documents": [...]}. |
expected_output | Root span's meta.output.value (or meta.output.messages[-1].content for LLM-kind roots) | This is the production behavior baseline, not ground truth. Document this clearly in the summary so the user does not over-trust it. |
metadata | {"source": "production", "trace_id": "<id>", "span_id": "<id>", "ml_app": "<ml_app>", "extracted_at": "<ISO 8601 UTC>"} | Trace/span IDs let the user click back to the source trace in Datadog. |
tags | ["env:prod", "source:traces", "ml_app:<ml_app>"] | Always "key:value" strings — bare strings will fail Dataset.append(). See the per-record tag rules in llm-obs-experiment-py-bootstrap/SKILL.md (search "Per-record tags"). |
PII scrub. Before writing, run the same scrub used elsewhere in the dd-llmo skill set: replace email / phone / SSN / API-key regex matches in all string values with <REDACTED:pii-type>. Surface a one-line warning listing affected record indices in the summary.
Tag normalization (MANDATORY pre-write step). The SDK's Dataset.append() calls validate_tags_list which rejects any tag without a : separator (Tag '<tag>' is malformed. Tags must be in 'key:value' format.). Before writing the JSON, run this normalization on every record's tags list:
None, or empty-string entries.key:value (":" in tag and both halves non-empty) → keep as-is.:) → wrap as "tag:<value>" (so e.g. "interactive" becomes "tag:interactive"). Never silently drop — the original observation is preserved under the generic tag: key.: (e.g. "foo:", ":bar") → wrap as "tag:<full-original-string>" so the malformed value is preserved without crashing the SDK.Track the count of normalizations and surface in the summary as a one-line note (Tag normalizations applied: N (bare strings auto-prefixed with 'tag:')). Do not write the dataset if any record's tags list still contains a non-key:value string after normalization — that's a bug in this step.
Skip records where:
meta.output is empty or missing (nothing usable as a baseline).<path> using the Write tool. The path was supplied by the user via --emit-dataset <path> — use it verbatim.LLMObs.create_dataset(records=...)) and is handled by the caller (the onboarding orchestrator or the user directly).After writing, print:
llm-obs-experiment-py-bootstrap --dataset-name <your-name>.Use unique temp file names when publishing multiple evaluators in parallel (e.g. /tmp/eval_toxicity.json).
get-evaluator field | Flat JSON key |
|---|---|
llm_provider.integration_provider | integration_provider |
llm_provider.model_name | model_name |
llm_provider.integration_account_id | integration_account_id |
llm_provider.temperature | temperature |
| All other fields | Unchanged (already top-level) |
| MCP Tool | pup Command |
|---|---|
create_datadog_notebook(name, cells, ...) | pup notebooks create --title "TITLE" --file /tmp/nb_cells.json — confirm exact flags with pup notebooks create --help |
edit_datadog_notebook(id, cells, append_only=true) | pup notebooks edit NOTEBOOK_ID --file /tmp/nb_cells.json (fetches current notebook, appends provided cells, writes back) |
The cells file is a JSON array of cell objects:
type(result), top-level keys, and whether the payload is nested inside a content block (e.g. [{'type': 'text', 'text': '<json>'}]). Extract and json.loads() the inner payload if needed before parsing. Never assume MCP results are bare dicts or lists.@dataclass(frozen=True)
class EvaluatorContext:
input_data: dict[str, Any] # Task inputs (from dataset record, NOT from span)
output_data: Any # Task output (from task function return, NOT from span)
expected_output: Optional[JSONType] = None # Ground truth (if available)
metadata: dict[str, Any] = {} # Additional metadata
span_id: Optional[str] = None # LLMObs span ID
trace_id: Optional[str] = None # LLMObs trace IDEvaluatorResult(
value=..., # Required. JSONType (str, int, float, bool, None, list, dict)
reasoning="...", # Optional. Explanation string
assessment="pass" or "fail", # Optional. Pass/fail assessment
metadata={...}, # Optional. Evaluation metadata dict
tags={...}, # Optional. Tags dict
)judge = LLMJudge(
user_prompt="...", # Required. Supports {{template_vars}}
system_prompt="...", # Optional. Does NOT support template vars
structured_output=..., # Optional. Boolean/Score/Categorical output, or a dict for custom JSON schema
provider="openai", # "openai" | "anthropic" | "azure_openai" | "vertexai" | "bedrock"
model="gpt-4o", # Model identifier
model_params={"temperature": 0.0}, # Optional. Passed to LLM API
name="eval_name", # Optional. Must match ^[a-zA-Z0-9_-]+$
)BooleanStructuredOutput(
description="Whether the response is factually accurate",
reasoning=True, # Include reasoning field in LLM response
reasoning_description=None, # Optional custom description for reasoning field
pass_when=True, # True → pass when true, False → pass when false, None → no assessment
)ScoreStructuredOutput(
description="Helpfulness score",
min_score=1, # Minimum possible score
max_score=10, # Maximum possible score
reasoning=True,
reasoning_description=None,
min_threshold=7, # Scores >= 7 pass (optional)
max_threshold=None, # Scores <= N pass (optional)
)CategoricalStructuredOutput(
categories={
"correct": "The response correctly answers the question",
"partially_correct": "The response is partially correct but missing key information",
"incorrect": "The response is factually wrong or irrelevant",
},
reasoning=True,
reasoning_description=None,
pass_values=["correct"], # Which categories count as passing (optional)
)# Pass a raw dict as structured_output — used as the JSON schema directly
structured_output={
"type": "object",
"properties": {
"relevance": {"type": "boolean", "description": "Whether the response addresses the question"},
"confidence": {"type": "number", "description": "Confidence score (0.0 to 1.0)"},
"reasoning": {"type": "string", "description": "Explanation for the evaluation"},
},
"required": ["relevance", "confidence", "reasoning"],
"additionalProperties": False,
}class MyEvaluator(BaseEvaluator):
def __init__(self, name=None, ...custom_params...):
super().__init__(name=name)
self._param = ... # Store config as private attrs
def evaluate(self, context: EvaluatorContext) -> EvaluatorResult:
# Access: context.input_data, context.output_data, context.expected_output, context.metadata
# Must NOT modify self attributes (thread safety)
passed = ... # Your logic here
return EvaluatorResult(
value=passed,
reasoning="...",
assessment="pass" if passed else "fail",
)# Validate JSON syntax + optional required keys
JSONEvaluator(required_keys=["name", "age"], output_extractor=None, name=None)
# Validate length (characters, words, or lines)
LengthEvaluator(count_by="words", min_length=10, max_length=500, output_extractor=None, name=None)
# count_by: "characters" | "words" | "lines"
# String matching
StringCheckEvaluator(operation="contains", expected="success", case_sensitive=False, name=None)
# operation: "eq" | "ne" | "contains" | "icontains"
# Regex matching
RegexMatchEvaluator(pattern=r"\d{4}-\d{2}-\d{2}", match_mode="search", name=None)
# match_mode: "search" | "match" | "fullmatch"## Proposed Evaluator Suite
**App profile**: {LLM | RAG | Agent | Multi-agent}
**Entry mode**: {cold_start | from_rca}
| # | Name | Type | Scope | Measures | Pass Criteria |
|---|------|------|-------|----------|---------------|
| 1 | task_completion | LLMJudge (Boolean) | span | Whether the task was completed on this span | pass_when=True |
| 2 | tool_use_correctness | LLMJudge (Categorical) | trace | Right tool with right arguments across the agent run | pass_values=["correct"] |
| 3 | session_goal_completion | LLMJudge (Categorical) | session | Whether the user's goals were met across the whole multi-trace session | pass_values=["completed"] |
| 4 | ... | ... | ... | ... | ... |
(Drop the **Scope** column when not in `publish` mode.)
For each evaluator:
- **{name}**: {what it measures}
- Target span: {which span's data it was designed for}
- Rationale: {which quality dimension it covers and why}
- {Only in publish mode:} Scope: {span | trace | session} — {one-sentence rationale}
- Evidence: [Trace {id_short}](https://app.datadoghq.com/llm/traces?query=trace_id:{full_id})
{Only in publish mode, for multi-step apps. Required if any of the four canonical trace-scope use cases was not included above:}
**Skipped trace-scope candidates:**
- `{canonical_use_case}` — {one-line reason it does not apply to this app}
{Only in publish mode, when `sessions_present`. Required if any of the four canonical session-scope use cases was not included above:}
**Skipped session-scope candidates:**
- `{canonical_use_case}` — {one-line reason it does not apply, e.g. "every `session_id` maps to a single trace"}
{Only in publish mode, when the session probe found no sessions:}
**Session scope skipped** — no `session_id` on sampled spans; session-scope evaluators not proposed.
{Only in publish mode, when the suite contains code-based evaluators (JSONEvaluator, RegexMatchEvaluator, LengthEvaluator, StringCheckEvaluator, BaseEvaluator). Required when any code-based proposal exists.}
**Not publishable in this mode** (code-based evaluators — the publish API is LLM-judge only):
- `{name}` ({type}) — {what it would check}. Ask me to emit these as offline SDK code (or run `/eval-bootstrap {ml_app} --sdk-code`), or `/eval-bootstrap {ml_app} --data-only` for a framework-agnostic JSON spec.