Timing
std/timing is the scoped-duration primitive for Harn scripts. It replaces
hand-rolled let started_ms = harness.clock.now_ms() subtraction with a
first-class observability span that carries trace identifiers, monotonic
duration, wall-clock start/end, status, attributes, and sub-phase events.
Use a timing segment when duration matters. Use a metric when you want a pre-aggregated numeric series. Use a span attribute when the value belongs on the current span instead of a new one.
import { timed } from "std/timing"
pipeline default() {
let outcome = timed("benchmark.agent_loop", {case_id: "python-add"}, { ->
return repair_agent(task)
})
__io_println("duration_ms=${outcome.timing.duration_ms}")
return outcome.result
}
timed is the docs-forward shape because the callback cannot forget to close.
The imperative pair is still available for flows that cross callbacks,
branches, or async-ish lifecycle boundaries:
import { start_timing, timing_event, end_timing } from "std/timing"
pipeline default() {
let timing = start_timing("provider.preflight", {provider: "ollama"})
timing_event(timing, "credentials.checked", {available: true})
timing_event(timing, "model.warm", {cache: "hit"})
let result = preflight()
let finished = end_timing(timing, {status: "ok"})
return {result: result, duration_ms: finished.duration_ms}
}
TimingSegment shape
The dict returned from timed(...).timing and end_timing(...) looks like:
{
name: "benchmark.agent_loop",
trace_id: "trace_...",
span_id: 42,
parent_span_id: 41, // nil at the trace root
kind: "user_timing",
started_at_ms: 1700000000123, // wall-clock millis (UNIX epoch)
monotonic_started_ms: 12345, // for elapsed checks via elapsed_ms(...)
ended_at_ms: 1700000000188,
duration_ms: 65,
status: "ok", // "ok" | "error" | custom
attributes: {case_id: "python-add"},
metadata: {case_id: "python-add", status: "ok"},
events: [ // present when timing_event(...) ran
{name: "checkpoint", offset_ms: 4, time_unix_ms: 0, attributes: {}}
]
}
duration_ms is always derived from the monotonic clock, so it stays
deterministic under mock_time(...) / advance_time(...). Wall-clock fields
(started_at_ms, ended_at_ms) honor the mock as well, so testbench fixtures
that pin wall time align with timing output.
Sub-phase events
timing_event(handle, name, attrs?) records a sub-phase annotation on the open
span without allocating a child span for every marker. Events ride along with
the parent span — they show up in timing.events after end_timing, in
trace_spans() output, and in OTel exports as native span events. Returns
true when the event was attached, false when the handle is already closed.
Duplicate close is idempotent
Calling end_timing(handle) twice returns the cached segment from the first
close instead of corrupting the active span stack. This lets defensive
wrappers call end_timing without re-checking handle state, and means a
callback path that closes its handle still produces a consistent return value
if a follow-up finally-style block calls end_timing again.
Errors are captured automatically
When timed(...) propagates an exception, it closes the span with
{status: "error", error: "<message>"} before re-throwing. Imperative callers
should pass the same shape explicitly:
import { start_timing, end_timing } from "std/timing"
let timing = start_timing("risky")
try {
risky_work()
end_timing(timing, {status: "ok"})
} catch (e) {
end_timing(timing, {status: "error", error: to_string(e)})
throw e
}
Profile and OTel export
harn run --profile-json includes user timing spans under the
user_timing bucket alongside the existing pipeline / llm_call / tool_call
categories. OTel exporters surface them as INTERNAL spans named after the
name argument, so they do not collide with the GenAI semconv mappings used
by LLM and tool spans. Span attributes flow through the same redaction policy
used for receipts, OTel attributes, and OAuth scopes.
OTel exports keep only low-cardinality harn.* attributes as top-level span
attributes. Stable exact keys include harn.kind, harn.status,
harn.duration_ms, and harn.span_id; runtime-owned namespaces include
harn.llm.*, harn.tool.*, harn.worker.*, harn.step.*,
harn.lifecycle.*, harn.cost.*, harn.token.*, and harn.timing.*.
Other timing metadata is preserved as JSON under harn.meta_json so dynamic
keys such as UUIDs or file paths do not create unbounded OTel attribute names.
When to skip a timing span
- Tight inner loops (per-iteration spans are pure overhead).
- Boundary points where
std/observability::logormetricis enough — pick a metric when you want pre-aggregated numbers, an attribute when the value belongs on the current span, and a timing span when the duration of a discrete piece of work matters.