Get Started with Datadog

The Monitor

Using TypeSafe’s Jev for evals in Datadog Agent Observability

Published

Read time

9m

Using TypeSafe’s Jev for evals in Datadog Agent Observability
Fouad Wahabi

Fouad Wahabi

Software Engineering Lead

Alex Barksdale

Alex Barksdale

Senior Software Engineer

Miguel Tulla Lizardi

Miguel Tulla Lizardi

Software Engineer

TypeSafe AI released Jev in September 2026 to do one thing: make decisions. Give it a state (a string or a JSON object) plus a set of typed questions, and it returns typed answers with probabilities. It never explains itself, and that constraint is the whole idea.

Evaluation pipelines have spent the last two years asking text generators for yes/no verdicts, wrapping the reply in a JSON schema, and paying generation prices for what amounts to a single bit. Jev is built for that bit, which makes it a good fit for the two evaluation surfaces in Datadog Agent Observability: online evals, which score production spans as they arrive, and experiments, which score a dataset offline. The same rubric can drive both. 

In this post, we’ll use Jev to build one rubric that scores every criterion in a single request for faster and cheaper evals, and wire it into both online evals on live spans and offline evals inside Datadog experiments.

What Jev returns

Each question for Jev is identified by a key, and its answer is returned under that key. Jev supports three question types, and each one returns a different kind of answer:

Question TypeReturned SignalExample evaluation
NoulProbability that a yes/no proposition is trueAre the reply’s policy claims supported by the retrieved excerpts?
ChoiceSelected category, probabilities for all categories, and confidenceWhat is the reply’s main failure mode?
ScoreA probability-weighted average of rubric levels, plus the level distribution and confidenceHow severe is the potential customer impact?

A Noul probability expresses uncertainty about a proposition; it does not measure how much of the reply is correct. Choice probabilities and Score confidence summarize the spread of their probabilities, though keep in mind that a confident answer can still be wrong. 

Questions in the same request are evaluated independently against a shared state. This lets you ask several focused questions without resending the same evidence for each one, then combine the answers in application code. 

Putting Jev to the test

We’ll use a support agent for a fictional airline, Vega Air, as an example for using Jev. The agent answers customer tickets from retrieved policy excerpts, but the policy corpus has deliberate holes, so some tickets have no grounded answer.

A good agent replies either with answers from the excerpts, or it says the excerpts don’t cover the question and offers a handoff to a human. A bad reply fills the hole by inventing a policy, which is usually a fee that appears nowhere in the excerpts.

The Jev rubric breaks that good and bad distinction into five narrow questions sent in one request. Two of them are below and are a Noul and a Choice question type. Each question includes two fields: instructions, which holds the question, and criteria, which spells out what counts as each outcome.

instructions can be a plain string or a JSON object. In the following example, it is an object with keys that we chose: question, scope, and an optional inspect that names the part of the state being judged. failure_mode skips inspect because its question already names reply. You can find the other three questions, and a walkthrough of each field, in our Jev rubric notebook.

from typesafe_sdk import Choice, Noul, NoulCriteria, TypeSafeClient
# Pinned rather than jev-latest: the thresholds below were calibrated against
# this exact version, and an alias moves when a release ships.
JEV_MODEL = "jev-1.13.0"
GROUNDED_THRESHOLD = 0.70
QUESTIONS = {
"grounded": Noul(
instructions={
"question": (
"Is every factual claim about Vega Air policy in `reply` stated in, or "
"directly restated from, `policy_context`?"
),
"inspect": "reply",
"scope": [
"Only policy claims count: fees, amounts, deadlines, weight limits, eligibility.",
"Ignore greetings, apologies, and offers to hand off to a human agent.",
"A reply that states no policy claims at all is grounded.",
],
},
criteria=NoulCriteria(
true="Every policy claim in `reply` appears in `policy_context`.",
false=(
"At least one policy claim in `reply` is absent from `policy_context`, "
"contradicts it, or changes a number, fee, or deadline."
),
),
),
"failure_mode": Choice(
instructions={
"question": "What is the single biggest problem with `reply`?",
"scope": (
"Pick `none` when the reply is fine. Pick `unclear` only when the reply "
"is too short or too garbled to judge."
),
},
criteria={
"none": "The reply is accurate, on-policy, and useful.",
"unsupported_claim": "The reply states a fee, rule, or number that is not in `policy_context`.",
"missed_handoff": (
"`policy_context` does not cover the question and the reply neither says so "
"nor offers a human agent."
),
"partial_answer": "The reply covers part of the question and silently drops the rest.",
"unsafe_request": (
"The reply complies with a request for personal data or something outside "
"support scope."
),
"unclear": "The reply is too short or too garbled to judge.",
},
),
# answers_question and offers_handoff are two more Nouls; customer_impact is a Score.
}

The unclear option in failure_mode is deliberate. A Choice question always returns the option with the highest probability, so Jev never abstains. If an evaluation needs a way to say “cannot judge this one,” that outcome has to exist in the criteria. TypeSafe’s self-consistency cookbook uses the same pattern for moderation.

Here’s a real response. The ticket asked about cancellation compensation when the retrieved policy only covers delays, so the agent declined to answer and offered a handoff:

{
"model": "jev-1.13.0",
"answers": {
"grounded": {"type": "noul", "noul": 0.63},
"answers_question": {"type": "noul", "noul": 0.02},
"offers_handoff": {"type": "noul", "noul": 0.99},
"failure_mode": {
"type": "choice",
"choice": "none",
"confidence": 0.34,
"probabilities": {
"none": 0.46, "partial_answer": 0.42, "unsupported_claim": 0.11,
"missed_handoff": 0.01, "unclear": 0.0, "unsafe_request": 0.0
}
},
"customer_impact": {
"type": "score",
"score": 1.25,
"confidence": 0.74,
"probabilities": {"0": 0.01, "1": 0.76, "2": 0.21, "3": 0.02},
"legend": {
"0": "No harm. The customer gets what they need.",
"1": "Mild friction. The customer must ask again or look elsewhere.",
"2": "Real cost. The customer acts on wrong information or is stranded without a route forward.",
"3": "Serious harm. The customer loses money, misses travel, or their privacy is breached."
}
}
},
"usage": {"input_tokens": 1181, "output_tokens": 139}
}

The interesting part sits inside failure_mode. Jev picked none, but none at 0.46 and partial_answer at 0.42 are nearly tied, and confidence came back at 0.34. Flattening that to the string none throws the interesting part away. A near-tie between two categories is a signal in its own right, and a natural trigger for routing the trace to a human reviewer.

The composite verdict stays in application code:

# Handled correctly means grounded, and either answered or routed to a human.
handled = grounded >= GROUNDED_THRESHOLD and (
answered >= ANSWERED_THRESHOLD or handoff >= HANDOFF_THRESHOLD
)

That composite verdict could have been a sixth Jev question. Keeping it in code is more useful, because the thresholds are application policy rather than model judgment. Outside the evaluator, they’re easy to read and easy to retune without touching the rubric or rescoring anything. The same goes for arithmetic and dates: Jev reads dates as text and doesn’t count reliably, so anything a parser can compute belongs in code.

The call itself is one request against a pinned model:

def make_client():
return TypeSafeClient(model=JEV_MODEL)
def judge(client, question, policy_context, reply, extra_questions=None):
"""One request, five answers, all scored in parallel against one state."""
return client.system_one(
state={"question": question, "policy_context": policy_context, "reply": reply},
questions={**QUESTIONS, **(extra_questions or {})},
)

TypeSafeClient reads TYPESAFE_API_KEY from the environment, so nothing else needs configuring. The state is three named fields rather than the whole trace. Jev loses accuracy as the state fills with material the question doesn’t need, so filter in code and send only what each question reads. The offline experiment reuses judge() unchanged, which is what keeps one rubric driving both surfaces.

Scoring live spans with online evals

For online evals, the appeal of Jev is that you pay for the verdict and nothing else. Live turns are scored as they arrive, and the resulting probabilities, labels, and scores go straight into Datadog.

This screenshot shows the Evaluations tab of a Datadog trace for an airline support agent, showing five Jev eval metrics on one span with probability scores, thresholds, and judge model tags.
This screenshot shows the Evaluations tab of a Datadog trace for an airline support agent, showing five Jev eval metrics on one span with probability scores, thresholds, and judge model tags.

The traced application never imports Jev. A separate worker does the scoring out of band, which means production traffic can be scored asynchronously and historical traffic can be backfilled the same way.

The only thing the two processes have to agree on is how a verdict finds its span. Datadog handles that through external evaluations, so the judge never needs to know a span ID. The application tags each span with a domain key (e.g., turn_id) and the scorer joins on that tag.

turn_id = f"turn-{uuid.uuid4().hex[:12]}"
with LLMObs.agent(name="support_turn") as span:
LLMObs.annotate(
span=span,
input_data=ticket["question"],
tags={"turn_id": turn_id, "ticket_id": ticket["id"]},
)
result = answer_ticket(ticket)
LLMObs.annotate(span=span, output_data=result["reply"])

In this example, the scorer reads a JSON Lines file, standing in for whatever queue, table, or store already holds the turns you want judged:

result = judge(client, turn["question"], turn["policy_context"], turn["reply"])
for metric in eval_metrics(verdict(result)):
LLMObs.submit_evaluation(
span_with_tag_value={"tag_key": "turn_id", "tag_value": turn["turn_id"]},
ml_app=ML_APP,
timestamp_ms=turn["timestamp_ms"],
**metric,
)

Passing the turn’s own timestamp_ms rather than the current time keeps reruns idempotent. Rescoring a turn updates its existing verdict instead of adding a second one. Both halves end-to-end can be found in the online evals notebook in our GitHub repo.

Mapping Jev answers to Datadog metrics

LLMObs.submit_evaluation accepts four metric types: score, categorical, boolean, and json. The ones you choose decide how much of Jev’s output survives into the query layer.

For a Noul question, submit the raw probability as a score and put the pass/fail cut in the assessment field. Binarizing at submission time destroys the distribution, so changing the threshold later means rerunning the judge over the whole backlog. Keeping the probability turns a threshold change into a query change. Jev doesn’t return a written explanation, so the reasoning field above is built in code from the probability and threshold. Keep the question, criteria, and evidence with each score so a reviewer can investigate one that looks wrong.

This screenshot shows the Evaluations tab filtered to jev_grounded, showing a score distribution with a 90% pass rate alongside other scored spans and probabilities.
This screenshot shows the Evaluations tab filtered to jev_grounded, showing a score distribution with a 90% pass rate alongside other scored spans and probabilities.
{
"label": "jev_grounded",
"metric_type": "score",
"value": round(v["grounded"], 4),
"assessment": "pass" if v["grounded"] >= GROUNDED_THRESHOLD else "fail",
"reasoning": f"P(grounded)={v['grounded']:.2f}, threshold={GROUNDED_THRESHOLD}",
"tags": {"judge": "typesafe-jev", "judge_model": v["model"]},
}

For a Choice question, use categorical so the labels can be faceted in the UI, and submit its confidence as a separate score. A wrong verdict and an uncertain verdict are different problems, and separating them lets you tell whether the agent got worse, whether the evaluator got less sure, or both. Rising uncertainty over time can also mean that the rubric no longer matches the traffic, which makes confidence a signal about the evaluation rather than only about one trace.

This screenshot shows a Datadog trace faceted by jev_failure_mode, with none and partial answer selected to isolate a trace flagged as a partial answer.
This screenshot shows a Datadog trace faceted by jev_failure_mode, with none and partial answer selected to isolate a trace flagged as a partial answer.

Tag every metric with judge_model. Aliases like jev-latest move when a release ships, and the response always reports the versioned model that actually answered. Logging it lets you compare two judge models on the same spans and the same rubric.

Using Jev inside a Datadog experiment for offline evals

A Datadog Agent Observability experiment takes a dataset, a task, and a list of evaluators. It runs the task over every row, scores each result, and stores the run so you can compare it against the next one.

The interface expects one evaluator object per metric. Ported naively, that’s one Jev request per evaluator per row. That’s exactly what Jev’s parallel questions exist to avoid: Six evaluators over ten rows would be sixty requests instead of ten.

The rubric runs once per row behind a small cache, and every evaluator reads the same response. One detail matters in that cache: guard the dictionary, not the request. experiment.run(jobs=4) runs rows concurrently, and holding a lock across the network call would serialize every row and undo it. Within a single row, the evaluators run in order, so the first one pays for the Jev call and the other five read the cache.

The evaluators themselves are thin:

class JevNoul(BaseEvaluator):
"""Submit the probability itself and put the threshold in the assessment."""
def __init__(self, name, key, threshold):
super().__init__(name=name)
self.key, self.threshold = key, threshold
def evaluate(self, context):
v, _ = RUBRIC.for_row(context)
p = v[self.key]
return EvaluatorResult(
value=round(p, 4),
assessment="pass" if p >= self.threshold else "fail",
reasoning=f"P={p:.2f}, threshold={self.threshold}",
metadata={"judge_model": v["model"]},
)

Wiring it up looks like any other experiment:

experiment = LLMObs.experiment(
name="support-agent-judged-by-jev",
task=answer_ticket,
dataset=dataset,
evaluators=[
JevNoul("jev_grounded", "grounded", GROUNDED_THRESHOLD),
JevNoul("jev_answers_question", "answers_question", ANSWERED_THRESHOLD),
JevFailureMode(),
JevCustomerImpact(),
JevHandledCorrectly(),
JevAgreesWithLabel(),
],
summary_evaluators=[JevHandledRate()],
project_name=PROJECT,
description="Airline support agent scored by TypeSafe Jev, one request per row.",
)
result = experiment.run(jobs=4)
print(experiment.url)

The offline surface can also ask something that the online one cannot. Dataset rows carry a ground-truth label, so BEHAVIOR_QUESTION adds another Choice question asking what the reply actually did, and JevAgreesWithLabel compares that answer against context.expected_output. That is the jev_agrees_with_label below, and it turns the experiment into a calibration check on both the agent and the judge. Rerun that check whenever you change the rubric or move to a new Jev version, since a threshold tuned for one question or model version may not carry over to another.

A screenshot showing a Datadog experiment run with summary cards for all six Jev evaluators above a table of ten scored records.
A screenshot showing a Datadog experiment run with summary cards for all six Jev evaluators above a table of ten scored records.

The cache class, all six evaluators, and the dataset setup are in our third Jev rubric for experiments.

Get started with Jev for your Datadog evals

You can access Jev directly or through AI gateways like OpenRouter or Vercel AI Gateway. TypeSafe provides Python and JavaScript SDKs, as well as a TypeSafe agent skill that gives coding agents the full API context. Like any judge, Jev has known limitations, so measure its agreement with human reviewers and its repeatability on your own traffic before you rely on it.

On the Datadog side, everything runs on the released ddtrace>=v4.5.0 package. LLMObs.submit_evaluation with span_with_tag_value handles online evals, and LLMObs.experiment with BaseEvaluator subclasses handles offline evals. There’s no preview builds and no private endpoints. There’s no preview builds and no private endpoints. Clone the repo, add your keys, and run them in order:

Terminal window
git clone https://github.com/DataDog/llm-observability
cd llm-observability/typesafe-jev
pip install -r requirements.txt
# .env
DD_API_KEY=...
DD_APPLICATION_KEY=...
DD_SITE=datadoghq.com
OPENAI_API_KEY=...
TYPESAFE_API_KEY=...

Our GitHub repo contains three notebooks that are a complete, runnable version of everything in this post: 

  • 1-jev-rubric.ipynb builds the rubric one question at a time and reads the answers. This notebook needs a TypeSafe key to get started.

  • 2-online-evals.ipynb traces the agent, then scores the spans out of band.

  • 3-experiments.ipynb runs the same rubric over a dataset and compares two rubric versions.

Check out our Agent Observability documentation to learn more about monitoring and evaluating your LLM applications. And read our blog on using evaluation frameworks with Agent Observability.

If you’re new to Datadog, .

Start monitoring your metrics in minutes