Observability¶
Every decision HarnessWeaver makes is a traced event (RULES R13): each guard that fired, each budget check, each route chosen, each tool allowed or denied, each repair. You've been reading traces since First steps. This chapter reads one closely, then sends the same events to OpenTelemetry in production, without any prompts, outputs or tool arguments in them.
Read a trace¶
Triage a message that holds an e-mail address, now that the org baseline and the extraction profile are in place:
import asyncio
from helpdesk.models import Ticket
import harnessweaver as hw
from harnessweaver.testing import render_trace
async def main() -> None:
async with hw.load("harness.yaml") as harness:
ticket = Ticket(text="I was charged twice for my Pro plan. Refund one? ann@example.com")
result = await harness.run("triage", ticket, principal="ann", tenant="acme")
print(f"{result.status.value}, cost ${result.cost_usd:.6f} (known: {result.cost_known})")
print(render_trace(result))
if __name__ == "__main__":
asyncio.run(main())
$ HARNESSWEAVER_ENV=offline uv run python main.py
completed, cost $0.000076 (known: True)
0 hook run.start harness -> continue
1 note - harness.guard (guard_fired): guardrails.input[base-pii] redact
2 outcome input.before guardrails.input[base-pii] [baseline] -> continue: guardrails.input[base-pii]: found EMAIL
3 outcome input.before guardrails.input[extraction-pii] [platform] -> continue
4 outcome input.before guardrails.input[injection] [app] -> continue
5 hook input.before harness -> continue: guardrails.input[base-pii]: found EMAIL
6 hook model.before harness -> continue
7 note model.after harness.budget: model usage
8 hook model.after harness -> continue
9 outcome output.before guardrails.output[base-secrets] [baseline] -> continue
10 outcome output.before guardrails.output[extraction-secrets] [platform] -> continue
11 outcome output.before validation.rules[bugs-are-urgent] [app] -> continue
12 outcome output.before validation.rules[short-summary] [app] -> continue
13 hook output.before harness -> continue
14 hook run.end harness -> continue
Read it top to bottom. The baseline's PII guard found an e-mail address and redacted it (events 1 and 2), then the profile's PII guard and your injection guard found nothing more. The model saw only [EMAIL]. Its usage was recorded (event 7), then the output passed the baseline's and the profile's secrets checks and your two rules, each in its band.
Each event has a kind:
| Kind | Records |
|---|---|
hook |
A hook point fired, with the combined outcome of its middleware |
outcome |
One middleware's answer, with its band |
failure |
A middleware raised or timed out (its exception type, never its message) |
note |
A framework decision: a guard fired, a budget check, a route, a governor step, a repair |
The trace holds ids, codes, names and numbers, never content (RULES R9). It powers assert_trace in tests, the guard_fired grader and the shadow-guard report in evals, and the comparison in replay.
Send it to OpenTelemetry¶
The core uses only the OpenTelemetry API. If your app already sets up OpenTelemetry, do nothing: the harness's spans join your traces. If it doesn't, install harnessweaver-otel and let the harness set it up. That belongs in production's overlay:
# HARNESSWEAVER_ENV=prod: the production gateway, and telemetry sent to an
# OpenTelemetry collector (needs harnessweaver-otel). Content stays out (R9).
models:
base_url: https://llm-gateway.internal.example
observability:
configure_sdk: true
exporter: otlp
endpoint: http://otel-collector:4318
| Setting | Meaning |
|---|---|
configure_sdk |
Let otel.sdk install the SDK's tracer and meter providers, once, when the harness loads |
exporter |
none, console or otlp |
endpoint |
The OTLP endpoint. The standard OTEL_EXPORTER_OTLP_* variables are read too |
service_name |
The service name on telemetry. The default is metadata.name |
capture_content |
false (the default). Org baselines usually lock it |
If the app already installed SDK providers, otel.sdk changes nothing, because telemetry setup has one owner (RULES R5). In test mode the OTLP exporter isn't installed (R14).
Spans¶
| Span | For |
|---|---|
harness.run |
One run or session |
chat {model} |
A model call, following the GenAI semantic conventions |
execute_tool {tool} |
A tool call |
invoke_agent {agent}, invoke_workflow {name} |
Agent adapters and workflows |
harness.decision, harness.guard, harness.approval |
A routing decision, a guard that fired, an approval |
Spans carry the GenAI attributes (gen_ai.request.model, gen_ai.usage.input_tokens, ...) pinned to semantic conventions 1.42.0, and HarnessWeaver's own (harness.run.id, harness.task, harness.tenant, harness.cost.usd, ...). The metrics are gen_ai.client.token.usage, gen_ai.client.operation.duration and harness.cost.
Content¶
With capture_content: false, prompts, outputs, tool arguments and tool results never reach a span. When a spec turns it on, they are captured after the model.before, model.after, tool.before and tool.after middleware, so every redaction applies first (RULES R9).
Tip
uv run harnessweaver dev harness.yaml --up starts a local stack with an OpenTelemetry collector that forwards traces to Langfuse, so you can see spans while you develop (Gateways and the dev stack).
Logs¶
HarnessWeaver logs through loguru and is silent until your app turns it on:
from loguru import logger
logger.enable("harnessweaver") # the core
logger.enable("harnessweaver_openai") # one plugin
The library never adds a log sink, so where logs go stays your choice. Logs carry ids and error types, never content.
Recap¶
- Every run's trace records every hook, middleware outcome and decision, without content.
- The same events become OpenTelemetry spans and metrics. Set up the SDK yourself, or let
harnessweaver-oteldo it from the spec. - Content stays out of telemetry unless the spec captures it, and then only after redaction.