Status, 2026-07-24. Read this section before acting on anything below.
Part State Root spans on both Rust surfaces, act spans, the field convention + its gate shipped (PR #528) Inbound traceparentextraction onto the root spanshipped (PR #529), measured in production One logging-init seam for all five binaries shipped (PR #533) OTLP exporter — traces to any OTLP/HTTP backend shipped (PR #535), verified against an in-memory exporter through the real router. Not yet run against a live vendor. Span links post-auth shipped (this PR) — every authentication gate on both Rust surfaces. Outbound traceparentinjection, metrics taxonomynot shipped. Goal steps 5 and 6. So the operator steps below are real: set the variables and traces will arrive. What has not
happened yet is anyone pointing it at a live account, so the first person to do so should expect
to shake out an auth-header or endpoint-suffix detail — and is the one who can finally answer the
open question further down about Vercel's own infra spans.
Vercel forwards distributed tracing data to a configured OTLP/HTTP-compatible endpoint. The
destination must be an HTTPS endpoint capable of receiving OTLP/HTTP requests.
If your app uses manual OpenTelemetry SDK configuration without the usage of @vercel/otel, you
will not be able to use [Session Tracing] or [Trace Drains].
@vercel/otel feeds Vercel's drain throughglobalThis[Symbol.for("@vercel/request-context")].telemetry.reportSpans(...) — a JavaScriptservice.name: vercel-function,scope.name: vercel shape in their drain reference) still reach a configured drain for a Rustapi/axum.rs, look — and it is not something@vercel/otel 2.1.3's source contains a second mechanism: when VERCEL_OTEL_ENDPOINTS is set ithttp://localhost:4318/v1/traces — runtime-agnostic, reachable019f943a §5e. ┌─────────────────────────────┐
request ──traceparent──▶│ Vercel edge (forwards it) │
└──────────────┬──────────────┘
▼
┌─────────────────────────────┐
│ Rust function │
│ root span (roots its own │──── OTLP/HTTP+protobuf ────▶ your backend
│ trace; links the caller's) │ direct, over the
│ flush before freeze │ public internet
└─────────────────────────────┘traceparent, on any019f95ff-e216-7dd1-b2aa-a49d20b1cd6c, summarized in crates/temper-telemetry/src/lib.rs's modulesampled flag is recorded, never obeyed. Honoring it would let anyone set -01 onspawn_blocking (soBatchSpanProcessorspan export on). The signal to watch is a warn: "span flush exceeded its budget; spans may beRUST_LOG=debug samples flush_ms per requestflush_ms, a field of its own. It is deliberately not folded intolatency_ms: the flush can only happen after the request span closes (until then there is nothinglatency_ms + flush_ms. An earlier version of this guide named latency_ms alone as the meter for the(trace_id, span_id), and something has to have sent those idstraceparent on its own outbound callscrates/temper-telemetry/src/propagate.rs), so a link in your backend resolves to a real span rathertracestate is omitted rather than sent empty, since W3C makes it optional and a valueless header ontracestate today: nothing readsSpanContext it builds carries| Variable | Purpose |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | Where spans go — the base, to which the SDK appends /v1/traces. Use this one; the vendor examples below assume it. |
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT | The trace endpoint verbatim — nothing is appended. Copying a vendor's base URL into this variable POSTs to / and 404s. |
OTEL_EXPORTER_OTLP_HEADERS | key=value,key=value. This is where vendor auth lives — which is what makes the setup vendor-agnostic. |
OTEL_SERVICE_NAME | Which deployable this is — for the Node hops. The Rust functions now name themselves in code (temper-api / temper-mcp / temper-internal, via temper_telemetry::set_service_name), which the SDK ranks above this variable, so on a project that also runs Node lambdas (temper-cloud) this is free to name the Node half without colliding with the Rust spans. See below. |
OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG | Sampling, from our config only. |
OTEL_SDK_DISABLED | Turn the exporter off without a deploy. |
localhost:4318. The OTLP spec defaults the endpoint to aOTEL_SDK_DISABLED works because temper implements it. opentelemetry_sdk 0.32 contains zerotrue (case-insensitive) disables export; 1 and yes deliberately do not, so a typo cannotRUST_LOG does not control export in either direction — including RUST_LOG=off. Both stacksRUST_LOG, the export layer carries its own fixedRUST_LOG=debug safe to use on a live deploymentRUST_LOG=off temper … stillOTEL_SDK_DISABLED=true andTimeUnixNano{low, high} encoding is mishandled by some collectors — @vercel/otel's own sourceBatchSpanProcessor exports from a dedicated OS thread with no Tokio reactor, so the asyncwarn from temper's own flush path. Being inside a runtime at the call site does notcrates/temper-telemetry/tests/live_export_client.rs, which posts to a real local socket; it fails ontemper binary can export too, but it needs a second switch:| Variable | Purpose |
|---|---|
TEMPER_CLI_TRACE | true (case-insensitive; nothing else counts) lets the CLI export. Off by default. |
OTEL_EXPORTER_OTLP_COMPRESSION=gzip). Worth stating because it was notgzip-http feature the exporter does not ignore that variable, it fails toOTEL_EXPORTER_OTLP_ENDPOINT is often already exported in a developer's shell for an unrelatedtemper should not start shipping your vault activity to a collector you configured formain, on the success and failure paths. The failure armstd::process::exit, which runs no destructors, so a flush placed after a successful runwarn default whileinfo independently, so stdout stays clean for temper … | jq andRUST_LOG=info temper … still opts into verbose logging without changing what is019f943d-f2f0) lands next: traces, metrics,api/axum.rs, api/mcp.rs, api/internal.rs.service.name for the Rust functions is set in code, not by OTEL_SERVICE_NAME. The temper-cloudapi/axum.rs, api/mcp.rs,api/internal.rs) and eight Node lambdas — all reading the same project-scoped OTEL_SERVICE_NAME.@vercel/otel itsservice.name and the cross-service waterfalltemper_telemetry::set_service_name (temper-api / temper-mcp / temper-internal), andOTEL_SERVICE_NAME. The net effect: setOTEL_SERVICE_NAME to whatever the Node half should be called; it does not touch the Rust spans.temper-apitemper-api in code (like its Vercel twin), so OTEL_SERVICE_NAME will nottemper-api. To filter local from production, lean on thedeployment.environment.name / service.instance.id resource attributes instead. Simplest, and ittasker-core repo has working Grafana/Tempo composeOTEL_* variables set at all, temper logs to stdout as it does today and exports nothing.crates/temper-telemetry/tests/flush_budget.rs is what keeps thetemper_telemetry::init owns how every temper process logs, in two variants:init_server_logging() — JSON on stdout, default info. Used by api/axum.rs, api/mcp.rs,api/internal.rs, and temper-api's main. Their stdout is the log stream.init_cli_logging() — human-readable on stderr, default warn. Used by the tempertemper … | jq stays clean;ort's INFO chatter on embed paths would otherwise interleave with command output and breakRUST_LOG overrides either default. An unparseable RUST_LOG falls back to the default rather thancrates/temper-telemetry/src/init.rs's tests hold thattracing_subscriber::fmt() builder each main used to call and through the stack that replaced it,traces_spanmetrics_calls_total, traces_spanmetrics_latency) are a derivedsum(rate(…error)) / sum(rate(…all)) is a binary op between two instant vectors, so PromQL# Ungrouped — vector(0) carries no labels, and none are needed.
(sum(rate(calls{…, status_code="STATUS_CODE_ERROR"}[$__range])) or vector(0))
/ sum(rate(calls{…}[$__range]))
# Grouped — vector(0) has no `service` label to match on, so zero-fill off the DENOMINATOR,
# which carries exactly the grouping labels the numerator is missing.
( sum by(service) (rate(calls{…, status_code="STATUS_CODE_ERROR"}[$__rate_interval]))
or
sum by(service) (rate(calls{…}[$__rate_interval])) * 0 )
/ sum by(service) (rate(calls{…}[$__rate_interval]))NaN (a gap), and that is left alone deliberately: an error$__rate_interval over a 6h range resolves to about that same 120s, which sitsrate() is flaky. Set minStep: 5m on any rate() panelSPAN_KIND_INTERNAL never reaches span metrics — and we are not changing thatCLIENT, SERVER, CONSUMER and PRODUCER. Confirmed by labellabel_values(traces_spanmetrics_calls_total, span_kind) returns those four and noSPAN_KIND_INTERNAL. So no INTERNAL span can appear in any RED panel or Prometheus alert —execute_tool <toolname> (the agents' MCP tool calls), region_dispatch / region_jobembed_dispatch.execute_tool's span_name cardinality; thatpackages/agent-workflows/steward/agent/lib/tool-allowlists.ts), 24 named tools, of which 13 wereexecute_tool. The rest come from eve and the AI SDK, and several are genuinely unbounded —invoke_agent minimax/minimax-m3 interpolates the model id, which rotates withSTEWARD_MODEL_FALLBACKS; step.execute <stepName> and workflow.run <id> interpolate from aspan_name allowlist living{resource.service.name="temper-steward" && name=~"execute_tool.*" && status=error} | rate() by (name)execute_tool temper__invocation_open at 1.02e-3/s, temper__steward_advance_watermark at2.31e-4/s. Grafana accepts this as an alert query: a rule carrying it as queryType: "traceql"GET <mcp endpoint>; we serve Streamable HTTP405. @opentelemetry/instrumentation-undici marks every 4xx an error,clients/temper-telemetry-ts/src/mcp-negotiation.ts resets the status of exactly thathttp.response.status_code=405 all survive, so the per-tool-call round trip stays countable inexecute_tool spans/day it is not amortized across a session — it is paid at roughly the rate tool019fce6a-75a5-7012-99cb-ca71fb2e7711 observed) spends something like 18s in negotiation alone.| Concern | Location |
|---|---|
| Logging init, both variants | crates/temper-telemetry/src/init.rs |
Inbound trace-context extraction, ROOT_TRACE_FIELDS | crates/temper-telemetry/src/lib.rs |
| Outbound trace-context injection (the mirror of the link) | crates/temper-telemetry/src/propagate.rs; called from temper-client's outbound span |
| Keeping credentials out of span attributes | crates/temper-telemetry/src/redact.rs — a stopgap for one route family; goal 019f99dd-dc9c-79f1-947c-e61bde2148a9 owns the real registry |
| The flush budget | export::flush_within_budget, gated by tests/flush_budget.rs |
| Root span construction (HTTP) | crates/temper-api/src/routes.rs, apply_transport_layers |
| Root span construction (MCP) | crates/temper-mcp/src/router.rs |
| Act-grain span fields | temper_services::backend::ACT_SPAN_FIELDS, declared by #[act_span] (crates/temper-macros) |
| Joining a trusted caller's trace (the link) | crates/temper-telemetry/src/link.rs; called from each auth gate |
| What is enforced, and why | internal/development/span-field-conventions.md, gated by tests/e2e/tests/logging_test.rs |
| MCP negotiation 405 status reset | clients/temper-telemetry-ts/src/mcp-negotiation.ts, gated by its sibling test; wired via initTelemetry({ mcpEndpoint }) |
| Service Traces Overview dashboard | Grafana jc7b67n — not in this repo; the query shapes it depends on are above |
| The trust decision | 019f95ff-e216-7dd1-b2aa-a49d20b1cd6c |
| Platform findings behind this guide | research 019f943a §5 |