Decides which LLM agent traces are worth keeping for review. For each trace it asks a System One decision model, TypeSafe's hosted Jev by default, whether the final answer is likely wrong, applies a keep/drop policy, and exports the kept traces over OTLP to any number of trace back ends: Arize Phoenix, Langfuse, an OpenTelemetry Collector, or anything else that accepts OTLP/HTTP traces.
Traces arrive as OpenTelemetry spans (OTLP over gRPC or HTTP) or over an HTTP
JSON API, and wait in a NATS JetStream queue: on the server queue.url
names, or, when it is empty, on a NATS server the engine runs inside its own
process. A worker pool in the same binary pulls them off the queue and does
the rest.
OTLP gRPC :4317 ─┐
OTLP HTTP :4318 ─┴─> spans stored by trace ID ──> assembled once the trace goes quiet ─┐
(stream TAILWISE_SPANS) │
v
POST /v1/traces ──> validate ───────────────────────────────> JetStream stream TAILWISE_TRACES
│
v
worker pool ──> slice ──> Jev ──> policy ──> exporters (kept only)
│
system error, after max_deliver attempts
v
JetStream stream TAILWISE_TRACES_DLQ
POST /v1/traces:score ──> validate ──> slice ──> Jev ──> policy ──> exporters (no queue)Quickstart
You need Docker and a Jev API key. The engine needs no config file.
export JEV_API_KEY=... # your TypeSafe Jev API key
make up # the engine, NATS JetStream and Phoenix, in Docker
make samples # send the bundled sample traces and print each decisionmake samples sends scripts/samples.jsonl, a
dozen agent runs whose answers are wrong, partly correct or correct, and
prints each decision next to its label, with Jev's reason in plain English:
a57d9106a210b9f56fba917038ea3be2 label=wrong keep p_keep=0.980 above_threshold
The final answer contradicts the evidence the agent gathered.
d5ca6bb1e89aa96b4290758d031c7716 label=correct drop p_keep=0.040 below_threshold
The final answer is correct, complete and supported by the evidence.The kept ones appear in Phoenix at http://localhost:6006, in the project
tailwise, with the decision as eval.* attributes on their root span.
make logs follows the engine's log; make down stops everything.
At start-up the engine warns that the model has no manifest: it asks the built-in questions, which are written for any agent rather than yours. See Manifests.
Setup
Requirements:
- A Jev API key, in
JEV_API_KEY - Docker, to run the engine, NATS and Phoenix with Compose
- Go 1.26 or newer, to build or test outside Docker (not for a release binary)
- Python 3, only for
make samples
From a release binary
Every release on GitHub Releases
has a tailwise binary for Linux, macOS and Windows, each on amd64 and
arm64, and a checksums.txt. The binary needs nothing else: the queues run
inside it unless queue.url names a NATS server.
VERSION=0.1.0 OS=linux ARCH=amd64 # OS: linux, darwin; ARCH: amd64, arm64
BASE=https://github.com/tecnosam/tailwise/releases/download/v$VERSION
curl -fsSLO "$BASE/tailwise_${VERSION}_${OS}_${ARCH}.tar.gz"
curl -fsSLO "$BASE/checksums.txt"
sha256sum --ignore-missing -c checksums.txt # on macOS: shasum -a 256 --ignore-missing -c checksums.txt
tar -xzf "tailwise_${VERSION}_${OS}_${ARCH}.tar.gz" tailwise
export JEV_API_KEY=...
./tailwise -version
./tailwise # the built-in defaults: Jev, embedded NATS, Phoenix at localhost:6006Windows archives are .zip files holding tailwise.exe. On macOS, a binary
downloaded in a browser is quarantined; clear it with
xattr -d com.apple.quarantine tailwise. To see kept traces, start
Phoenix too (make phoenix, or phoenix serve); it takes port 4317, so run
the engine with TAILWISE_OTLP_GRPC_ADDR=:14317.
With Docker Compose
make up # docker compose up --build -d: the engine, NATS JetStream and Phoenix
make logs # follow the engine's log
make down # stop all three; queued traces, dead letters and Phoenix's data stay in volumesCompose starts three services:
| Service | Host ports | Notes |
|---|---|---|
observer (container tailwise-observer) |
8080 (JSON API), 4318 (OTLP HTTP), 14317 (OTLP gRPC) | The engine: receivers, API and worker pool, on its built-in defaults |
nats |
4222 (clients), 8222 (monitoring), both on loopback only | JetStream, stored in the nats-data volume; configured by nats.conf |
phoenix |
6006 (UI and OTLP HTTP), 4317 (OTLP gRPC) | Phoenix, stored in the phoenix-data volume. Open http://localhost:6006 |
Inside the container the engine listens for OTLP gRPC on 4317. Compose publishes it on host port 14317 because Phoenix takes 4317 for its own OTLP gRPC receiver.
If a Phoenix is already running on the host (phoenix serve), it holds 6006
and 4317 and make up fails with "port is already allocated". Either stop
it, or move the compose one with PHOENIX_PORT and PHOENIX_GRPC_PORT, or
keep using the host one:
TAILWISE_EXPORT_ENDPOINT=http://host.docker.internal:6006/v1/traces \
docker compose up --build -d --no-deps nats observerCompose reads these variables:
| Variable | Default |
|---|---|
JEV_API_KEY |
(required) your Jev API key |
TAILWISE_EXPORT_ENDPOINT |
http://phoenix:6006/v1/traces (the first exporter) |
PHOENIX_PORT |
6006 |
PHOENIX_GRPC_PORT |
4317 |
TAILWISE_PORT |
8080 |
TAILWISE_OTLP_GRPC_PORT |
14317 |
TAILWISE_OTLP_HTTP_PORT |
4318 |
To run the engine with your own config instead, mount it and pass -config;
examples/README.md shows how.
On the host
make phoenix # start only Phoenix in Docker
make run # build and start the engine on its built-in defaults (Jev, embedded NATS)
make run CONFIG=examples/jev.yaml # or with a config file
make test # go vet + go test -race, fully offline
# Or with the queues in an external NATS JetStream, as in a cluster:
make nats
TAILWISE_NATS_URL=nats://localhost:4222 make runmake run binds 8080, 4317 and 4318. Phoenix, from make phoenix or on the
host, already holds 4317, and the engine exits with an "address already in
use" error; start it with TAILWISE_OTLP_GRPC_ADDR=:14317 make run instead.
Sending traces
curl -X POST localhost:8080/v1/traces:score -d @testdata/traces/wrong_river.json{"trace_id":"wrong-river-001","decision":"keep","p_keep":0.98,"rule":"above_threshold","reason":"The final answer contradicts the evidence the agent gathered.","cause":"contradicts_evidence","model":"jev-1.14.0","latency_ms":357.8,"exported":true}scripts/send_samples.py sends a batch:
python3 scripts/send_samples.py --sync # print each decision next to its label
python3 scripts/send_samples.py # through the queue
python3 scripts/send_samples.py --otlp # as OTLP spans, to :4318
python3 scripts/send_samples.py --dataset my-traces.jsonl -n 50 # your own traces--dataset takes any JSONL file of traces in the JSON API's shape; an
optional label on each row is printed next to its decision, and -n
samples that many, equally across labels. With --otlp each trace is sent
as an AGENT root span and one TOOL span per tool call.
To point an instrumented agent at the engine, give its OTLP exporter the engine's endpoint instead of Phoenix's; see Connecting an agent or service.
Flags: -config PATH (optional: without it the engine runs on its built-in
defaults and the environment), -check (validate and exit), -version and
-log-level debug|info|warn|error.
Configuration
The engine needs no config file. Without -config it runs on its built-in
defaults: Jev as the scorer, the built-in questions, an embedded NATS
server and one Phoenix exporter at http://localhost:6006/v1/traces. A
config file sets only what differs; every key it leaves out keeps its
built-in default. examples/ has ready-to-run variations, and
tailwise.schema.yaml is the full reference: a JSON
Schema with each key's type, default, limits and the rules that tie keys
together. architecture.md explains how a file is loaded.
| Key | Default | Meaning |
|---|---|---|
server.addr |
:8080 |
Listen address |
server.workers |
8 |
Workers pulling traces from the queue, so concurrent calls to the model |
server.shutdown_timeout |
30s |
Time for in-flight traces to finish on shutdown |
otlp.grpc_addr |
:4317 |
OTLP gRPC listen address; "" turns gRPC off |
otlp.http_addr |
:4318 |
OTLP HTTP listen address (POST /v1/traces); "" turns it off |
otlp.idle_timeout |
10s |
A trace with no new spans for this long is assembled and scored |
otlp.max_wait |
2m |
A trace is assembled this long after its first span even if spans keep arriving |
otlp.assemblers |
4 |
Goroutines assembling traces |
otlp.span_max_age |
1h |
Spans never assembled are removed after this |
queue.url |
empty | NATS server; empty runs one embedded in the engine. See Where the queues live |
queue.store_dir |
empty | Embedded NATS only: where its streams are kept; empty is a temporary directory, lost on restart |
queue.connect_timeout |
30s |
How long to wait for NATS at start-up before exiting |
queue.prefix |
tailwise |
Names every NATS stream, subject and consumer; engines with the same prefix share the work. See NATS names |
queue.max_messages |
100000 |
Queued traces before POST /v1/traces returns 503 |
queue.ack_wait |
2m |
Time a worker may hold a trace before it is redelivered |
queue.max_deliver |
5 |
Delivery attempts before a trace is dead-lettered |
queue.redelivery_backoff |
[5s, 30s, 2m, 5m] |
Delay before each redelivery; the last value repeats |
queue.dlq_max_age |
168h |
How long dead letters (traces and exports) are kept |
queue.replicas |
1 |
Copies of each stream a NATS cluster keeps; 3 on a three-server cluster |
queue.auth |
none | NATS authentication: one of credentials_file, nkey_seed_file, user with password_env, or token_env |
queue.tls |
none | NATS TLS: ca_file to verify the server, cert_file and key_file for mutual TLS |
scorer.kind |
jev |
Which System One model: jev (TypeSafe's hosted Jev), or another such as kev or laya |
scorer.api_key_env |
JEV_API_KEY for jev, unset otherwise |
Environment variable holding the model's API key, sent as a bearer token. Start-up fails if it names an unset variable |
scorer.endpoint |
https://api.typesafe.ai/v1/systemone |
The model's /v1/systemone URL |
scorer.model |
jev-latest |
Model name sent with every request |
scorer.manifest |
none | Optional manifest: the questions the model is asked. See Manifests |
scorer.decision_question |
answer_wrong |
Question that drives the decision; must be noul |
scorer.reason_question |
why_wrong |
Choice question whose chosen option is the plain-English reason; see Reasons. Empty turns it off |
scorer.system_prompt |
false |
Show the model the agent's system prompt, above the question (see How spans are read). Off by default: in the benchmark it kept more traces without ranking failures better, and it adds the prompt's tokens to every call. Each agents.models entry sets its own |
scorer.evidence |
grounded |
How tool results reach the model. grounded: each call's result on its line, within scorer.evidence_budget, choosing first the results whose values later calls use as arguments, then the most recent; a long result keeps the parts later calls used. first: the first three evidence items, each cut to 1,500 characters; tool-call lines show only the status. In the benchmark, first hid most tool results and the model flagged what it could not check, so it ranked failures worse at about half the cost |
scorer.evidence_budget |
12000 |
With evidence: grounded, the most characters of tool results shown across all calls |
scorer.timeout |
30s |
Timeout per call |
scorer.max_retries |
3 |
Retries after the first attempt, on the synchronous endpoint only; a queued trace is redelivered by the queue instead |
scorer.backoff |
[1s, 5s, 15s] |
Base delay before each synchronous retry; the last value repeats |
agents.attributes |
[agent.name, service.name] |
Span attributes naming a trace's agent, tried on the root span then its resource. See One model per agent |
agents.models |
none | Agent name to its own model: scorer keys, with endpoint and model required; nothing comes from scorer |
policy.keep_threshold |
0.5 |
Keep when p_keep reaches this |
policy.random_floor |
0.02 |
Share of below-threshold traces kept at random |
observability.error_sinks |
[{kind: stdout}] |
Where the engine's own errors are written as structured log records: stdout and file (with a path) as JSON lines, otlp (with an endpoint, headers, headers_env) as OpenTelemetry logs |
observability.metrics.endpoint |
empty (off) | OTLP/HTTP metrics URL for the engine's own metrics. See Metrics |
observability.metrics.headers, headers_env |
none | Headers for the metrics endpoint, as for an exporter |
observability.metrics.interval |
15s |
How often metrics are sent |
exporters |
one Phoenix at http://localhost:6006/v1/traces |
The trace back ends kept traces go to; see Exporters. An empty list exports nothing |
exporters[].name |
(required) | Names the back end in logs, errors and its NATS stream: letters, digits, -, _; unique |
exporters[].endpoint |
(required) | OTLP/HTTP traces URL, including the path |
exporters[].headers |
none | Headers sent as written. Keep secrets out of the file |
exporters[].headers_env |
none | Environment variable holding more headers, as key=value,key2=value2. Start-up fails if it is unset |
exporters[].resource_attributes |
none | Resource attributes added to every span sent to this back end |
exporters[].queue_size |
1024 |
Kept traces waiting for this back end; past it a trace is skipped for it and the skip reported |
exporters[].concurrency |
4 |
Traces sent to this back end at once |
exporters[].max_deliver |
3 |
Tries before the exporter gives up on a trace |
exporters[].backoff |
[1s, 5s, 30s] |
Wait before each retry; the last value repeats |
Environment overrides, which apply with or without a config file:
| Variable | Overrides |
|---|---|
TAILWISE_NATS_URL |
queue.url |
TAILWISE_SCORER_ENDPOINT |
scorer.endpoint |
TAILWISE_EXPORT_ENDPOINT |
exporters[0].endpoint |
TAILWISE_MANIFEST |
scorer.manifest |
TAILWISE_OTLP_GRPC_ADDR |
otlp.grpc_addr |
TAILWISE_OTLP_HTTP_ADDR |
otlp.http_addr |
The standard OTEL_EXPORTER_OTLP_HEADERS and OTEL_EXPORTER_OTLP_TRACES_HEADERS
are not read: each exporter sends only its own headers, so one back end's
credentials never reach another. Use headers_env instead.
The engine validates its configuration at start-up and exits with a clear error if:
JEV_API_KEY, or a variable the config names for a secret, is not set- a threshold is outside [0, 1], a duration does not parse, or a key is unknown
- the decision question is missing from the manifest (or the built-in
questions) or is not
noul queue.ack_waitis not longer than the slowest model'stimeout(a queued trace gets one scoring call per delivery), since a shorter value would hand a trace to a second worker while the first is still on it
tailwise -check runs the same checks and exits, printing any warnings.
Manifests
A manifest holds the questions the model is asked:
{"model": "...", "questions": {"answer_wrong": {"type": "noul", "instructions": "..."}, "why_wrong": {"type": "choice", "criteria": {"...": "..."}}}}questionsis sent to the model unchanged, all questions, in the manifest's order, in one call. The reason question's chosen option is thereason.decision, optional, lists more yes/no (noul) questions that can keep a trace (see Any question can keep a trace). Without it, the decision question decides alone. It stays in the engine and is not sent to the model.- A relative
scorer.manifestpath resolves against the config file's directory.
A manifest is optional, for every System One model. Without one the model
is asked the built-in questions
(builtin_manifest.json), and the
engine logs a warning at start-up. They are:
| Question | Asks |
|---|---|
answer_wrong |
Is the final answer wrong or only partially correct? |
unsupported_answer |
Does it make claims the evidence does not support? |
missing_tool_calls |
Did the agent skip lookups the task needed? |
answered_on_failure |
Did it answer despite a failed or partial tool call? |
ignored_instructions |
Does it go against the user's instructions? |
contradicts_knowledge |
Does it contradict general knowledge? |
why_wrong |
Which reason explains what is wrong? |
Each yes/no answer is the probability that the agent failed in that way.
answer_wrong alone decides keep or drop; the other answers are recorded
with every decision (in the log and as eval.answers.<question> on the
export) so a reviewer sees which problems the model found. In the
benchmark, letting any check keep a trace kept most traces
without ranking failures any better, so the built-in questions have no
decision block; a manifest can add one. To word the questions
for your agent, start from
examples/manifests/my-agent.json and
set scorer.manifest (or TAILWISE_MANIFEST).
How well each check tells failed runs from successful ones depends on the
agent. In the benchmark, unsupported_answer was informative on coding
agents (ROC-AUC 0.78 on SWE-agent) but no better than chance on a
customer-service agent whose answers cite its own lookups; missing_tool_calls
was informative on both. Treat a check as a hint until you have seen it on
your own agent's traces.
Any question can keep a trace
A manifest's decision block lists the yes/no questions that can keep a
trace besides the decision question, in priority order, and can give any of
them its own threshold:
"decision": {
"any_of": ["unsupported_answer", "missing_tool_calls", "answered_on_failure",
"ignored_instructions", "contradicts_knowledge"],
"thresholds": {"contradicts_knowledge": 0.7}
}- Any question keeps. A trace is kept when the decision question, or any
question in
any_of, reaches its threshold. Each question checks a different way an agent fails, and a wrong answer usually fails in one way only: a check that says "no" is not evidence the answer is right, so it never counts against keeping. - Thresholds.
thresholdssets a question's own threshold, in (0, 1]; a question without one usespolicy.keep_threshold. Raise it for a check that flags too often. The decision question can have one too. - Flags. The questions that reached their threshold are the trace's
flags: the problems the model found, in priority order. They are in the
decision log line (
flags) and on the exported trace (eval.flags), next to every answer (eval.answers.<question>). A reviewer can filter on them, for example every kept trace flaggedanswered_on_failure. - Priority. The decision question always comes first, then
any_ofin the order written, then the action checks. Flags are listed in that order. - Severity: the review order. A trace's severity is the average
answer of its deciding questions, flagged or not.
action_wrongon a trace without actions is 0; a check that failed is left out of the average rather than counted as 0, so an outage does not bury failed traces. Review traces highest severity first. It does not change whether a trace is kept (severityin the response and the log,eval.severityon the export).- Why an average and not the highest answer: a trace several checks doubt is more often a real failure than one a single check is sure of. On τ-bench with the action checks, when 10% of runs fail and a team reviews the top 5%, ranking by the highest answer finds 23% failures among the traces reviewed; ranking by the average finds 47% (benchmark), on the runs whose label the trace can show.
- Every deciding question counts the same, so list in
any_ofonly the checks you trust on your agent: a check no better than chance pulls good traces up.
p_keepis the highest answer among the deciding questions.- Every listed question must be a
noulquestion of the same manifest, listed once, and the model must answer all of them: a missing answer is a scoring error, like a missing decision answer. Questions not listed are still asked, logged and exported, but do not keep a trace. - Each listed question is one more way to be kept, so listing more keeps more traces. Recheck the share kept after changing the list, a threshold or a question's wording.
Without a decision block, the decision question decides alone, against
policy.keep_threshold.
Checking an agent's actions
An agent that acts for its users (cancels orders, books flights, sends
money) fails in ways its final answer hides: it exchanges the wrong item, or
never does one of the things the user asked, and then reports success. Name
the tools that act in the manifest's actions block, each with what it does,
worded as the user would ask:
"actions": {
"cancel_order": "cancel an order",
"issue_refund": "get a refund for an order",
"update_shipping_address": "change the shipping address of an order"
}Each trace then gets two more checks, each its own call to the model:
action_wrong, once per action call (the last 10): does the call use an argument that is not what the user asked for? The model sees the user's request, the earlier tool results that hold the call's argument values (where the right item or order is identified, trimmed to the parts holding them), the call, and the actions after it. The trace's answer is the highest over its calls.unhandled_request, once per trace: for each action, did the user ask for it, and was every such request carried out or explicitly declined? The model sees the conversation (or, without one, the user's request), the actions taken and the final answer. The answer is the highest min(requested, 1 − handled) over the actions.
Both are deciding checks after the decision question and any any_of
questions, with policy.keep_threshold unless decision.thresholds sets
their own. They are recorded in answers and flags like any question. When
one of them, and not the decision question, keeps a trace, the reason says
what it found: cause wrong_action ("The agent's action
cancel_order({"order_id":"#W2"}) does not match what the user asked for.")
or unhandled_request ("The user asked the agent to get a refund for an
order, and it neither did it nor declined."). A check that fails is left out
of the decision, and reported; on the queued path a system error redelivers
the trace instead.
The conversation. The completeness check reads the agent's turns: they
show a request it declined, or one the user withdrew. Tailwise takes them
from the agent's latest LLM span (the one with the longest
llm.input_messages, plus its llm.output_messages), or from the JSON API's
optional conversation, a list of {"role": "user" | "assistant", "content": ...}. Without them, the check sees only the user's request and
the final answer, and flags far more successful runs (26% against 11% on
τ-bench).
What it costs and buys. On τ-bench's trace-detectable runs, with
grounded evidence, the checks raise the failures kept from 54% to 70%, and
the successful runs kept from 26% to 40%; the share of kept runs that failed
barely moves (60% to 57%). They catch the failures the final answer hides:
wrong-variant exchanges the user had described go from 33% to 90%, runs that
left out an action from 50% to 68%. Each trace costs one call per action
call plus one, about 2,000 input tokens each.
Heuristic rules
Some failures need no model to spot: a final answer that should be JSON and is not, a tool call that errored, an agent calling the same tool with the same arguments again and again. Write them as rules. They cost nothing, their verdict is certain, and each fired rule is listed with a plain reason.
Rules in the config file apply to every agent. An agent's manifest adds its
own under "rules", in the same form: a rule with the same name replaces
the default, and "enabled": false turns it off for that agent.
rules:
- name: answer_json
on_fail: keep # certain: keep without asking the model
final_answer: {json_schema: {type: object, required: [order_id, status]}}
- name: tool_failed
on_fail: signal # raises severity, decides nothing
tool_output: {ok_status: true}
- name: looping
run: {max_repeated_calls: 3}Each rule sets one of:
| Key | Checks | Expectations |
|---|---|---|
final_answer |
the final answer | not_empty, min_length, max_length (characters), matches, not_matches (Go regular expressions), json, json_schema |
tool_output |
the output of every call, or every call to tool |
the same, plus ok_status: the call's status is ok |
tool_args |
the arguments of every call, or every call to tool |
json_schema |
run |
the run as a whole | max_tool_calls, max_repeated_calls (one call, same tool and arguments, more than this many times), requires_tools, forbids_tools |
A rule fires when the trace breaks any of its expectations. on_fail says
what happens then:
keep: the trace is kept without asking the model, so it costs no tokens. Rulekeep_rule, causerule, the rule's reason ("The final answer does not match the schema: at '': missing property 'status'."), severity 1, so it is reviewed first, andp_keepnull. Use it for failures that are certain.signal(the default): the rule counts in severity, 1 when it fires and 0 when it does not, next to the model's answers, and decides nothing. Every signal rule weighs as much as a check, so a rule that fires on many good traces pulls them up the review order: keep signal rules few and specific.
Fired rules are in the response's rules (name, on_fail, reason), the
log line's rules, and the export's eval.rules and eval.rules.<name>. A
rule that does not compile (a bad regular expression or schema, two rules
with one name) stops Tailwise at start-up, naming the model and the rule.
One model per agent
Each agent can have its own model: its own questions, and
if needed its own endpoint or key. The scorer section is the default
model; agents.models adds one per agent, each complete on its own:
agents:
attributes: [agent.name, service.name]
models:
support-bot:
endpoint: https://api.typesafe.ai/v1/systemone
model: jev-latest
manifest: manifests/support-bot.json
research-agent:
endpoint: https://api.typesafe.ai/v1/systemone
model: jev-latest
manifest: manifests/research-agent.json
timeout: 45s- Which agent. For OTLP traces, the first of
agents.attributesset on the root span, else on its resource: OpenInference'sagent.name, then the resource'sservice.nameby default. For the JSON API, the trace'sagentfield. - Which model. The agent's entry under
agents.models, else thescorersection. A trace that names no agent, or an agent not listed, is scored by the default. - No inheritance. Each entry takes the
scorerkeys (kind,endpoint,model,api_key_env,manifest,decision_question,timeout,max_retries,backoff) and is complete on its own:endpointandmodelare required, and nothing is taken from thescorersection. Keys it leaves out get the engine's built-in defaults, the same onesscorergets; without a manifest it gets the built-in questions and its own warning.TAILWISE_SCORER_ENDPOINTandTAILWISE_MANIFESTchange only thescorersection. - Everywhere. The decision log, the
/v1/traces:scoreresponse, theeval.agentspan attribute and the metrics all carry the agent, andeval.modelthe model./readyzprobes the default model only, so one agent's model being down does not make the whole engine unready.
examples/agents.yaml is a complete example.
OTLP ingestion
The engine runs the standard OTLP trace receivers, so an agent's exporter can point at it as if it were a collector:
| Protocol | Address | Details |
|---|---|---|
| gRPC | :4317 |
opentelemetry.proto.collector.trace.v1.TraceService/Export, gzip accepted |
| HTTP | :4318, POST /v1/traces |
application/x-protobuf or application/json, gzip accepted, 16 MiB limit |
An export is answered with success as soon as the spans are stored (in NATS,
once it has confirmed them, typically within a few milliseconds). If they
cannot be stored the answer is gRPC UNAVAILABLE or HTTP 503, which OTLP
exporters retry.
Connecting an agent or service
Point the OTLP exporter at the engine instead of at Phoenix. Nothing else in the agent changes, and no API key or header is needed.
For anything using an OpenTelemetry SDK, the standard variables are enough:
# HTTP (the SDK appends /v1/traces)
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
# or gRPC (host port 14317 under Docker Compose, 4317 inside the network)
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:14317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpcOr set it in code. In Python, with an OpenInference instrumentor:
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
provider = TracerProvider()
provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4318/v1/traces"))
)
trace.set_tracer_provider(provider)
# then instrument as usual, e.g. GoogleADKInstrumentor().instrument(tracer_provider=provider)The engine needs one root span carrying the question in input.value and the
answer in output.value; tool calls are spans with
openinference.span.kind = TOOL. The instrumentors emit these already. To
send a trace by hand:
curl -i http://localhost:4318/v1/traces -H 'Content-Type: application/json' -d '{
"resourceSpans": [{"scopeSpans": [{"spans": [{
"traceId": "5b8efff798038103d269b633813fc60c",
"spanId": "eee19b7ec3c1b174",
"name": "agent",
"startTimeUnixNano": "1760000000000000000",
"endTimeUnixNano": "1760000001000000000",
"attributes": [
{"key": "openinference.span.kind", "value": {"stringValue": "AGENT"}},
{"key": "input.value", "value": {"stringValue": "Which river flows through the capital of France?"}},
{"key": "output.value", "value": {"stringValue": "The Rhône"}}
]
}]}]}]
}'It answers 200 {}. The decision appears in the engine's log about
otlp.idle_timeout (10s) later, and the trace shows up in your exporters
(Phoenix by default) if it was kept.
From spans to a decision
- Store. Each export is split by trace ID and published to
tailwise.spans.<trace id>in theTAILWISE_SPANSstream. - Wait. A trace's spans usually arrive in several batches, with the root
span last. The trace is considered complete when no span has arrived for
otlp.idle_timeout, orotlp.max_waitafter its first span. - Assemble. The spans are merged (a span sent twice counts once) and turned into the same record the JSON API takes; see the mapping below. Spans that do not describe an agent run are discarded here, before they cost a call to the model.
- Queue, score, decide. The record goes to
TAILWISE_TRACESand from there everything is as for a JSON trace, including redelivery and the dead-letter stream. - Forward. A kept trace is sent to every exporter as its original spans,
with the original trace and span IDs, the exporter's
resource_attributes, and theeval.*attributes added to the root span. A dropped trace is sent nowhere and its spans are deleted.
Because of step 2, a decision arrives at least otlp.idle_timeout after the
trace's last span.
How spans are read
The engine reads the OpenInference attributes that Phoenix's instrumentors emit.
| Trace field | Taken from |
|---|---|
| Root span | The span whose parent is not in the trace, preferring openinference.span.kind = AGENT, then CHAIN |
agent |
The first of agents.attributes set on the root span, else on its resource |
system_prompt |
tailwise.system_prompt on the root span if set; else the system message of the first LLM span that has one (llm.input_messages.<i>.message.role = system, its content or its contents parts joined) |
question |
Root span input.value |
final_answer |
Root span output.value |
tool_calls |
Spans with kind TOOL, and the code steps of an AGENT root, ordered by start time. A code step is a CHAIN child of the root with no TOOL span under it, whose LLM span wrote a fenced or <code> block, as in a smolagents CodeAgent: it becomes an execute_code call with the code as {"code": ...}, its output.value as the output, and an error status (with the span's status message) when the step failed |
tool name |
tool.name, else the span name |
tool args |
input.value if it is JSON, else tool.parameters, else input.value as a string |
tool status |
tailwise.tool.status if set; otherwise error when the span status is ERROR or the output starts with Error: (any case), else ok |
evidence |
First of: tailwise.evidence.<i> on the root span; documents on RETRIEVER spans; the paragraphs (split on blank lines) of successful tool outputs |
When input.value or output.value holds a JSON object, the text is taken
from a question/input/query/prompt/task key (or answer/output/result
for outputs) when there is one. A tool output shaped as a function response,
{"response": {"result": "..."}}, is unwrapped.
A message recorded by the Google ADK instrumentor,
{"new_message": {"parts": [{"text": "..."}]}} on the way in and
{"content": {"parts": [{"text": "..."}]}} on the way out, gives the text of
its parts, so an ADK agent's own root span works without extra attributes.
A trace is not scorable, and is discarded with a spans discarded log
line, when its root span has no input.value, or has no output.value and
is not an AGENT span. That covers ordinary service spans sent to the engine
by mistake.
An agent run that ended without an answer is scored like any other, with an
empty final answer: its root span either sets output.value to an empty
string, or is an AGENT span with no output.value, as when the run crashed
or ran out of steps. Those runs nearly always failed, so they reach review
instead of being dropped.
Matching what the model was trained on
The model was trained on evidence as bare paragraph text and on the agent's
own tool statuses (ok, not_found, bad_args, ...). Standard spans carry
neither exactly: a tool span holds the tool's rendered output, and its status
is only OK or ERROR. So for a trace read from plain instrumentor spans, the
slice differs a little from the training data. For the test agent, evidence
items keep their [Title] prefix and a search that found nothing shows as
ok with its message as evidence.
An agent can remove the difference by setting two attributes itself:
| Attribute | On | Value |
|---|---|---|
tailwise.evidence.0, tailwise.evidence.1, ... |
the root span | the evidence items, in order |
tailwise.tool.status |
each TOOL span |
the tool call's status |
tailwise.system_prompt |
the root span | the agent's system prompt, when its LLM spans do not record it or record a sub-agent's first |
With both set, a trace sent as spans yields byte for byte the slice the same
trace yields through the JSON API. A test checks this for every fixture, and
scripts/send_samples.py --otlp sends spans this way.
API
POST /v1/traces
Stores a trace in the queue. The 202 is sent only after JetStream has
confirmed the trace, so an accepted trace survives a restart of the engine,
unless the embedded server runs without queue.store_dir.
{
"trace_id": "7f3a91c2e8",
"agent": "support-bot",
"system_prompt": "You answer geography questions. Cite your sources.",
"question": "Which river flows through the capital of France?",
"tool_calls": [{"name": "search", "args": {"query": "capital of France"}, "status": "ok"}],
"evidence": ["Paris is the capital of France.", "The Seine flows through Paris."],
"final_answer": "The Seine"
}questionandfinal_answerare required.final_answermay be an empty string for a run that ended without an answer.trace_idis optional; the engine generates 32 hex characters if it is missing.agentis optional and picks the model (see One model per agent); at most 128 bytes.conversationis optional: the user's and agent's turns, each{"role": "user" | "assistant", "content": "..."}, for the action checks.system_promptis optional: the instructions the agent ran under. A model withscorer.system_prompt: truesees it, cut to 8,000 characters, above the question; otherwise it is ignored and the model sees exactly what it would without one.argsmay be any JSON value.statusdefaults took. A tool call's optionaloutputis what it returned; models withscorer.evidence: grounded, the default, see it.- This is the engine's own JSON API on port 8080. The OTLP receiver also
serves a
/v1/tracespath, on port 4318, with a different body.
| Status | Body | When |
|---|---|---|
| 202 | {"trace_id": "..."} |
Stored in the queue |
| 400 | {"error": "..."} |
Invalid JSON or a missing required field |
| 413 | {"error": "..."} |
Body larger than 8 MiB |
503 + Retry-After |
{"error": "..."} |
Queue at queue.max_messages, or NATS unreachable |
POST /v1/traces:score
Same input, scored synchronously without the trace queue. Meant for testing and demos. It returns as soon as the decision is made; a kept trace is queued for export and reaches every exporter's back end moments later, so a slow back end never holds up the response.
{
"trace_id": "wrong-river-001",
"agent": "",
"decision": "keep",
"p_keep": 0.98,
"rule": "above_threshold",
"reason": "The final answer contradicts the evidence the agent gathered.",
"cause": "contradicts_evidence",
"model": "jev-1.14.0",
"latency_ms": 357.8,
"exported": true,
"answers": {"answer_wrong": 0.98, "unsupported_answer": 0.12},
"flags": ["answer_wrong"],
"severity": 0.98,
"input_tokens": 412
}| Field | Values |
|---|---|
decision |
keep or drop |
p_keep |
The highest answer among the questions that decide (the decision question alone without a decision block); null on fallback and when a keep rule decided |
rule |
Which part of the policy decided: above_threshold, random_floor, below_threshold, fallback or keep_rule (see Heuristic rules) |
reason |
Why the model thinks the final answer is wrong, or that it is right, in plain English (see Reasons) |
cause |
The model's short name for that reason, e.g. contradicts_evidence or correct; omitted when it gave none |
latency_ms |
The model's reported latency; the measured time if it reports none, or on fallback |
exported |
The trace was kept and queued to at least one exporter, which sends it right after |
answers |
Every yes/no answer the model gave, by question; omitted on fallback |
severity |
The deciding questions' average answer, with each signal rule counting 1 or 0: the order to review traces in, highest first (see Any question can keep a trace); 1 when a keep rule decided; null on fallback |
flags |
The questions that reached their threshold, in priority order; omitted when there are none |
rules |
The heuristic rules that fired, each {name, on_fail, reason}; omitted when none did |
input_tokens |
The input tokens the model reported billing for the call; omitted if it reported none |
trimmed |
true when the trace was too long for the model (over 40,000 characters as rendered), so the middle of its longest fields, such as a giant patch, was cut before scoring; omitted otherwise |
POST /v1/otlp/traces:score
Scores one trace sent as OTLP spans, synchronously, and returns the same
response as POST /v1/traces:score. The body is an OTLP/HTTP export request
(application/x-protobuf or application/json, optionally gzipped) holding
the spans of exactly one trace. The trace is built from the spans exactly as
the OTLP receivers build it (see From spans to a decision),
but without the queue or the wait for late spans, so send every span in one
request. It answers 400 for spans of more than one trace, and 422 when no
root span has a question and an answer. Meant for testing and benchmarks:
bench/ scores datasets through it.
API docs: GET /docs and GET /openapi.yaml
/openapi.yaml serves the OpenAPI 3.0 description of this API. /docs renders
it with Swagger UI, where you can also send requests. The UI's scripts load
from a CDN, so /docs needs internet access in the browser; the spec does not.
The spec lives in internal/server/openapi.yaml and is embedded in the binary.
Update it when you change a handler; a test fails if a route, a documented
status code or a field of the decision response goes missing from it.
GET /healthz and GET /readyz
/healthz returns 200 whenever the process is up. /readyz returns 200 only
if the queue is up (the NATS connection, when NATS is used) and the default
model (the scorer section) answers GET /v1/models on its host within 2
seconds, and 503 otherwise. An agent's own model is not checked: when it is
down, only that agent's traces are affected (retried, then dead-lettered),
so it does not take the engine out of a load balancer; watch
tailwise.scorer.duration with error.type per tailwise.model for it.
How a decision is made
- Route. The trace's agent picks its model (see One model per agent).
- Rules. The heuristic rules run. When a keep rule
fires, the trace is kept (rule
keep_rule) and the model is not asked. - Slice.
trace.BuildStaterenders the trace into the text the model reads (see The slice). - Score. The slice and the model's questions (its manifest's, or the
built-in ones) go to the model, in one call. A queued trace that hits a system error goes back to the
queue for redelivery. On the synchronous endpoint, which has no queue,
the engine retries it with
scorer.max_retriesand jitteredscorer.backoff. The two kinds of retry never multiply. - Decide. The trace is kept when any question that decides (the
decision question, and those in the manifest's
decision.any_of) reaches its threshold; those questions are its flags (see Any question can keep a trace).- A question reached its threshold: keep, rule
above_threshold. - Otherwise keep with probability
random_floor, rulerandom_floor. - Otherwise drop, rule
below_threshold.
- A question reached its threshold: keep, rule
- Export. Kept traces are queued to every exporter. Each decision produces one JSON log
line with
trace_id,agent,decision,rule,reason,cause,p_keep,severity,model,latency_ms,answers, every yes/no answer by question,flags, the deciding questions that reached their threshold,rules, the heuristic rules that fired, as in"answers": {"answer_wrong": 0.91, "unsupported_answer": 0.84, ...}, "flags": ["answer_wrong", "unsupported_answer"].
Reasons
Every decision says in plain English why the model thinks the final answer
is wrong. Jev answers with probabilities and choices, not free text, so the
reason comes from a choice question, why_wrong in the built-in questions
(scorer.reason_question names it). Each option is a sentence:
cause |
reason |
|---|---|
correct |
The final answer is correct, complete and supported by the evidence. |
contradicts_evidence |
The final answer contradicts the evidence the agent gathered. |
unsupported |
The final answer makes claims the evidence does not support. |
incomplete |
The final answer leaves out part of what the question asked. |
wrong_lookup |
The agent looked up the wrong thing, so its evidence does not answer the question. |
ignored_tool_failure |
A tool call failed and the agent answered anyway without the information it needed. |
no_answer |
The agent did not give an answer. |
The model picks the option that fits, and its sentence becomes the reason. A manifest can define its own options in the same shape, with sentences that suit your agent. The reason question rides along in the same request as the decision question, so it costs no extra call. If a manifest has no reason question, the reason says only how likely the answer is wrong, and the engine warns at start-up. On fallback it says the model could not score the trace.
When the model cannot score a trace
What happens depends on whose fault it is and which path the trace took.
| Failure | Queued (POST /v1/traces) |
Sync (:score) |
|---|---|---|
| System error: the model unreachable, timing out, 5xx, 401/403/404/429, or an unparseable response | Redelivered, then dead-lettered. No decision is made. | Retried, then a random fallback decision |
| The model rejects the trace: 400, 413 or 422 | Random fallback decision | Random fallback decision |
The random fallback keeps the trace with probability random_floor and drops
it otherwise, with rule fallback either way.
The queue
Where the queues live
The engine has three kinds of queue: the trace work queue with its
dead-letter stream, the store where OTLP spans wait to be assembled, and one
export queue per exporter. queue.url decides where all of them live:
queue.url |
NATS server | Survives a restart | Shared by replicas |
|---|---|---|---|
| empty (the default) | embedded in the engine, no port opened | only with queue.store_dir set |
no, each engine works alone |
nats://... or tls://... (TAILWISE_NATS_URL) |
external | yes | yes, through the durable consumers |
Both run the same JetStream code, so the rules are the same: a bounded
queue (queue.max_messages, exporters[].queue_size), redelivery with
backoff up to a delivery limit, then a dead letter stored in a DLQ stream;
spans assembled after otlp.idle_timeout of quiet or otlp.max_wait. The
embedded server suits trying the engine and single-instance setups; use an
external server to share the work between replicas. With the embedded
server queue.replicas must be 1. Docker Compose sets TAILWISE_NATS_URL,
so the compose stack uses its own NATS container.
The rest of this section applies to both.
- Stream
TAILWISE_TRACESuses work-queue retention on file storage: a trace is removed once a worker acknowledges it. A full stream refuses new traces rather than evicting unscored ones. - Workers pull one trace at a time through the durable consumer
tailwise-workers. Run more engine replicas with the same consumer name to share the work. - Delivery is at least once. If a worker dies after exporting a trace but before acknowledging it, the trace is scored and exported again.
- Payload is the trace JSON, the same shape the API accepts, so another
producer can publish straight to
tailwise.traces. A trace published that way without atrace_idgets one derived from its stream sequence. - A trace assembled from OTLP spans also carries those spans in an
otlp_spansfield (base64 protobuf), so a kept trace can be forwarded as the originals.nats.confraises the NATS message limit to 8 MB for this. A trace still too big is queued without its spans and, if kept, exported as rebuilt spans instead.
NATS names
Every stream, subject and consumer is named from queue.prefix. With the
default, tailwise:
| What | Stream | Subject |
|---|---|---|
| Traces waiting to be scored | TAILWISE_TRACES |
tailwise.traces |
| Traces that failed for good | TAILWISE_TRACES_DLQ |
tailwise.traces.dlq |
| Spans waiting to be assembled | TAILWISE_SPANS |
tailwise.spans.<trace id> |
| One marker per trace awaiting assembly | TAILWISE_PENDING |
tailwise.pending.<trace id> |
| Each exporter's queue | TAILWISE_EXPORTS_<NAME> |
tailwise.exports.<name> |
| Exports given up on | TAILWISE_EXPORTS_DLQ |
tailwise.exports_dlq |
The workers pull from the consumer tailwise-workers, and each exporter's
workers from tailwise-workers-export. Engines with the same prefix share
the work, so give each deployment on one NATS server its own prefix, for
example staging.
NATS in production
The engine is a NATS client; it does not run or configure the server
(nats.conf and the compose service are only for trying it). Against a
secured or clustered NATS, set:
queue:
url: tls://nats.internal:4222
replicas: 3 # every stream the engine creates, kept on 3 servers
auth:
credentials_file: /var/run/secrets/nats/tailwise.creds # or nkey_seed_file, user + password_env, token_env
tls:
ca_file: /var/run/secrets/nats/ca.pem # a private CA
# cert_file / key_file # mutual TLSreplicas is applied to every stream the engine creates (traces, dead
letters, spans, pending markers, each exporter's queue); more than the
cluster has servers fails at start-up. An existing stream is updated to the
new count when the server allows it. Passwords and tokens come from
environment variables (password_env, token_env); credential, seed and
certificate files from paths, relative to the config file, typically mounted
from a Kubernetes secret. The account needs JetStream access to the streams
under queue.* and otlp.* and the right to create durable consumers.
Streams
| Stream | Holds | Removed when |
|---|---|---|
TAILWISE_SPANS |
OTLP spans, one subject per trace ID | The trace is assembled, or after otlp.span_max_age |
TAILWISE_PENDING |
One marker per trace awaiting assembly | The trace is assembled |
TAILWISE_TRACES |
Traces waiting to be scored | A worker acknowledges the trace |
TAILWISE_TRACES_DLQ |
Traces that hit system errors | After queue.dlq_max_age |
TAILWISE_EXPORTS_<NAME> |
Kept traces waiting for one exporter (its name upper-cased, - as _) |
The exporter sends the trace, or gives up on it |
TAILWISE_EXPORTS_DLQ |
Kept traces an exporter gave up on, with headers Tailwise-Exporter, Tailwise-Dead-Error, Tailwise-Deliveries |
After queue.dlq_max_age |
Redelivery and the dead-letter stream
When a worker hits a system error (one call per delivery), it returns the
trace to the queue with a delay from
queue.redelivery_backoff. After queue.max_deliver deliveries it copies the
trace to TAILWISE_TRACES_DLQ and removes it from the work queue. With the
defaults that is 5 deliveries over about 8 minutes.
A message that is not a valid trace is dead-lettered on first sight.
A dead letter's body is the original trace. Its headers say what happened:
| Header | Example |
|---|---|
Tailwise-Trace-Id |
wrong-river-001 |
Tailwise-Dead-Reason |
system_error or invalid_payload |
Tailwise-Dead-Error |
scorer: score trace: model returned HTTP 503: ... |
Tailwise-Deliveries |
5 |
Tailwise-Dead-At |
2026-10-12T08:42:48Z |
Tailwise-Source-Stream, Tailwise-Source-Sequence |
TAILWISE_TRACES, 17 |
Each dead-lettered trace also produces an ERROR log line, trace dead-lettered.
Inspecting and replaying, with the compose stack up:
make dlq # how many dead letters there are
# The NATS CLI, run from a container on the compose network:
alias nats='docker run --rm -i --network tailwise_default natsio/nats-box:latest nats -s nats://nats:4222'
nats stream get TAILWISE_TRACES_DLQ 1 # one dead letter, with headers
# Replay it once Jev is back: post its body to the API again.
nats stream get TAILWISE_TRACES_DLQ 1 --json | jq -r .data | base64 -d \
| curl -X POST localhost:8080/v1/traces -d @-
nats stream purge TAILWISE_TRACES_DLQ # clear the dead lettersNATS monitoring is at http://localhost:8222 (/jsz?streams=true for stream
and consumer counts).
Exporters
Kept traces go to every back end in exporters, over OTLP/HTTP. The default
is the local Phoenix:
exporters:
- name: phoenix
endpoint: http://localhost:6006/v1/traces
resource_attributes:
openinference.project.name: tailwise # the Phoenix projectAdd more entries to send to more back ends. Langfuse, for example, takes
Basic auth built from its public and secret keys
(examples/langfuse.yaml):
- name: langfuse
endpoint: https://cloud.langfuse.com/api/public/otel/v1/traces
headers_env: LANGFUSE_OTLP_HEADERSexport LANGFUSE_OTLP_HEADERS="Authorization=Basic%20$(printf '%s' "pk-lf-...:sk-lf-..." | base64)"Headers. An exporter's headers are its headers, plus those parsed from
the variable headers_env names, which win on a name clash. The variable
uses the OpenTelemetry format: key=value pairs separated by commas, values
percent-encoded where needed (%20 for a space). The engine logs exporter
names and endpoints, never header values, and a header that fails to parse
is reported without its value.
Asynchronous. Exporting never holds up scoring. Each exporter has its own
bounded queue (queue_size) and its own workers (concurrency); a kept
trace is queued to every exporter and the worker moves on. The queue is in
NATS JetStream when queue.url is set (stream TAILWISE_EXPORTS_<NAME>, so
queued exports survive a restart, unless the embedded server runs without
queue.store_dir); see Where the queues live. A slow or unreachable back
end only fills its own queue: past queue_size, traces are skipped for that
back end alone and each skip is reported to the error sinks (kind=full,
exporter=<name>). The decision log's exported field says whether at least
one exporter queued the trace.
Retries. A send returns only once the back end has answered, so a
failure is never silent. A failed send is retried after backoff (default
1s, 5s, 30s), up to max_deliver tries (default 3); each retry is reported.
Then the exporter gives up: the trace is reported as a severe error and
moved to TAILWISE_EXPORTS_DLQ with the exporter's name, from where it can be
replayed. An error a retry cannot fix (corrupt stored spans) is given up at
once. Export failures never send the trace to the trace dead-letter stream:
it was scored, only its delivery failed.
Shutdown. With the embedded server and no queue.store_dir, the engine
first sends what is queued, within 10 seconds, since the queue goes away
with the process. Otherwise it stops after the sends in flight and the rest
stays queued for the next start.
What the back ends receive
The engine writes OpenInference
attributes, which Phoenix reads natively and Langfuse and other OTLP back
ends show as span attributes, plus its own eval.* and tailwise.*
attributes.
A kept trace that arrived over OTLP is forwarded as its own spans: same
trace ID, span IDs, names, timings and attributes. Two things change: the
exporter's resource_attributes are set on every resource (replacing
attributes of the same name), and the root span gains the decision
attributes below. The agent's own service.name is kept.
A kept trace from the JSON API has no spans of its own, so the engine
builds them, as a new trace with a generated trace ID. Resource:
service.name=tailwise plus the exporter's resource_attributes.
A root span agent_run, and one child span per tool call, named after the
tool.
Root span attributes:
| Attribute | Type | Set on | Value |
|---|---|---|---|
openinference.span.kind |
string | rebuilt | AGENT |
input.value |
string | rebuilt | The question |
output.value |
string | rebuilt | The final answer |
tailwise.source_trace_id |
string | rebuilt | The client's trace_id |
tailwise.evidence.<i> |
string | rebuilt | Each evidence item, 0-based, untruncated |
eval.agent |
string | both | The trace's agent; empty if it named none |
eval.decision |
string | both | keep (dropped traces are not exported) |
eval.rule |
string | both | Which part of the policy kept it: above_threshold, random_floor, fallback (the model could not score it), or keep_rule (a heuristic rule) |
eval.reason |
string | both | Why the model thinks the final answer is wrong, in plain English |
eval.cause |
string | both | The model's short name for the reason, e.g. contradicts_evidence; omitted when it gave none |
eval.model |
string | both | The scorer model that decided |
eval.latency_ms |
double | both | Scoring time in milliseconds |
eval.p_keep |
double | both | The highest answer among the questions that decide; omitted on a fallback keep |
eval.flags |
string | both | The deciding questions that reached their threshold, comma-separated in priority order, e.g. answer_wrong,unsupported_answer; omitted when none did |
eval.severity |
double | both | The deciding questions' average answer with the signal rules, the review order; 1 for a keep rule; omitted on fallback |
eval.rules |
string | both | The heuristic rules that fired, comma-separated; omitted when none did |
eval.rules.<name> |
string | both | Each fired rule's reason |
eval.answers.<question> |
double | both | Each yes/no answer, e.g. eval.answers.missing_tool_calls; omitted on a fallback keep |
eval.trimmed |
bool | both | true when the model saw the trace with the middle of its longest fields cut to fit; omitted otherwise |
Tool span attributes (rebuilt traces only; forwarded tool spans are not changed):
| Attribute | Type | Value |
|---|---|---|
openinference.span.kind |
string | TOOL |
tool.name |
string | The tool's name |
input.value |
string | Its arguments, as compact JSON; omitted when there are none |
output.value |
string | Its status |
Resource attributes worth setting per back end:
| Attribute | Back end | Effect |
|---|---|---|
openinference.project.name |
Phoenix | The project the traces are filed under |
deployment.environment |
any | Lets you filter production from staging |
service.version |
any | The agent version the traces came from |
Rebuilt and forwarded spans alike are sent in one request per trace, with a
10-second timeout. A failed send is retried by the export queue only
(exporters[].max_deliver and backoff), not inside the request.
Metrics
With observability.metrics.endpoint set, the engine sends its own
OpenTelemetry metrics over OTLP/HTTP every interval, to a Collector or any
back end that takes OTLP metrics:
observability:
metrics:
endpoint: http://otel-collector:4318/v1/metrics
headers_env: METRICS_OTLP_HEADERS # optional, "key=value,..."
resource_attributes:
deployment.environment: production| Instrument | Kind | Attributes | Measures |
|---|---|---|---|
http.server.request.duration |
histogram (s) | tailwise.server (api, otlp), http.request.method, http.route, http.response.status_code |
JSON API and OTLP/HTTP requests |
rpc.server.call.duration |
histogram (s) | tailwise.server, rpc.system, rpc.method, rpc.grpc.status_code |
OTLP/gRPC calls |
tailwise.scorer.duration |
histogram (s) | tailwise.agent, tailwise.model |
Model calls, retries included |
tailwise.export.duration |
histogram (s) | tailwise.exporter |
Sends to a trace back end |
tailwise.decisions |
counter | tailwise.agent, tailwise.model, tailwise.decision, tailwise.rule, tailwise.cause |
Decided traces, so also what kinds of failure the model finds |
tailwise.errors |
counter | tailwise.component, error.type |
Errors reported to the error sinks |
tailwise.queue.depth |
gauge | tailwise.queue (traces, export:<exporter>) |
Traces waiting: to be scored, or to be sent to a back end. On a shared NATS every pod reports the shared count; on embedded NATS, its own |
tailwise.agent is bounded: an agent with its own model under
agents.models appears by name, a trace naming any other agent as other,
and one naming none as none. Agent names come from the traces, so any
client could otherwise create unlimited metric series. The decision log
uses the same label; the trace's own agent name stays on its exported spans
(eval.agent) and in its /v1/traces:score response, cut to 128 bytes.
A failed request or call also carries error.type: the status code (5xx for
HTTP, any non-OK gRPC code), or the error kind (unavailable, timeout,
rejected, ...) for model calls and sends. An error rate is the count with
error.type over the total count; a latency percentile comes from the
histogram. The resource has service.name=tailwise plus
resource_attributes. As with exporters, the global
OTEL_EXPORTER_OTLP_HEADERS is not read. The last metrics are sent on
shutdown.
Deploying
Run the engine inside your private network (a VPC), next to the agents that send it traces, and control access there: security groups or firewall rules, a Kubernetes NetworkPolicy, and IAM roles or service accounts for whatever the engine itself calls. The engine does not authenticate clients on its JSON API or OTLP receivers, and serves them without TLS; put a load balancer or service mesh in front of it if traffic must be encrypted or authenticated in transit.
What leaves the network is what you configure: exporters (with their own headers), the metrics endpoint, and a hosted scorer such as Jev, which receives every scored trace's text.
On Kubernetes, install with the Helm chart in
deploy/helm/tailwise: the engine, an
optional NATS JetStream cluster, and an optional trace-routing gateway for
several replicas with queues on embedded NATS. Kustomize can install the same chart.
Before a rollout, tailwise -check -config my-config.yaml (or plain
tailwise -check for the built-in defaults) loads and validates the
configuration and its manifests, checks the secrets it names are set, prints
any warnings, and exits: 0 if valid, 1 with the problems if not.
Known limitations
These are open problems in the current version, not design choices to rely on.
- Dead letters are not replayed automatically. A Jev outage longer than
the redelivery window (about 8 minutes with the defaults) sends every queued
trace to
TAILWISE_TRACES_DLQ, and they stay there until someone replays them by hand (see Redelivery and the dead-letter stream). Nothing alerts on the dead-letter count either; watch fortrace dead-letteredlog lines or runmake dlq. - A model that rejects everything causes silent random decisions. A 400,
413 or 422 is treated as that trace's problem and gets the random fallback.
If the model starts answering that way for every request (for example after
a change to its request schema), every trace is decided at random, about
98% dropped with the default
random_floor, and onlyWARNlog lines show it. - Decisions use the model's probabilities as they come. Nothing tunes
them to your agent, and any of six questions can keep a trace, so the
defaults may keep too much at first. Watch the share of traces kept, and
which flags keep them, and adjust
decision.thresholdsor theany_oflist. - Severity ranks traces but nothing acts on it yet. Exporters send kept
traces in the order they are decided, not by severity, and nothing keeps a
fixed share such as the top 5%: the keep decision is still each check's
threshold. Sort by
eval.severityin your back end to review in order. - Every scored trace is a billed call to Jev, and leaves your network.
Nothing caps the spend or redacts fields before sending; only
server.workerslimits the rate. - An exporter can lose kept traces. A trace skipped because the
exporter's queue was full is not sent to that back end. One it gave up on
after
max_delivertries waits inTAILWISE_EXPORTS_DLQ. - The embedded server without
queue.store_dirloses queued work on a crash. Traces waiting to be scored, spans waiting to be assembled and traces waiting to be exported are in a temporary directory. Setqueue.store_dir, or use an external NATS (queue.url), where that matters. - Plain instrumentor spans do not reproduce the training slice exactly.
See Matching what the model was trained on.
The test agent does not set the
tailwise.*attributes yet, so its traces are scored on a slightly different slice than the model saw in training. On the one run compared, the raw score moved from 0.259 to 0.266. - Trace completeness is a guess. A trace is assembled after
otlp.idle_timeoutof silence. Spans that arrive later are assembled as a separate, partial trace, which is normally unscorable and discarded; the first decision is not revisited. An agent run that pauses for longer than the idle timeout between spans is split this way. - The API and the OTLP receivers have no authentication or TLS. Anyone who can reach 8080, 4317 or 4318 can submit traces. This is by design; see Deploying.
- A trace can be scored and exported twice. Delivery is at least once and
nothing deduplicates: a worker that dies between exporting and acknowledging
leaves a duplicate in the back ends. The same happens if a client retries a
POST /v1/tracesthat had in fact succeeded. - The compose file's NATS has no authentication. It is there to try the
engine, bound to loopback. In production, point
queue.urlat a NATS you run or buy, withqueue.auth,queue.tlsandqueue.replicas(see NATS in production).
Shutdown
On SIGINT or SIGTERM the engine stops accepting requests and OTLP exports,
stops pulling from the queue, and lets in-flight traces finish for up to
server.shutdown_timeout. Spans not yet assembled and traces still queued
stay in JetStream for the next start. If the timeout runs out, in-flight model calls are cancelled and those
traces go back to the queue; they are not dead-lettered and get no fallback
decision. Last, the exporters send the traces still queued for them and
shut down (up to 10 seconds in all).
Contributing
See CONTRIBUTING.md for the code layout, tests, the golden
slice files, commit conventions, releases and every make target.