NICo Tracing
How NICo component tracing works, what it covers, how to turn it on and off and what it costs.
TL;DR
-
nico-api (the
carbide-apibinary) is NICoโs primary tracing source and the subject of this document. nico-dns also emits traces, but with a separate simpler opt-in setup. nico-bmc-proxy emits traces for each proxied BMC request when configured (see nico-bmc-proxy tracing) and nico-pxe for each boot request it serves (see nico-pxe tracing). -
nico-api traces are off by default; two things must both be true before any spans are emitted:
-
An OTLP endpoint is configured at startup, either in the nico-api config TOML:
or with
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, which overrides the TOML value. -
Tracing is enabled, either in the same config section with
enabled = true, or at runtime withnico-admin-cli set tracing-enabled truewhentracing.allow_runtime_changes = true.
-
-
Tracing is resource-intensive when on, so turn it on for a debugging session and then off after.
Leaving the OTLP endpoint configured while tracing is disabled costs almost nothing.
-
Transport is OTLP/gRPC, plaintext; nico-api cannot do OTLP/HTTP or originate TLS
-
nico-api propagates W3C trace context at its network boundaries: it reads
traceparent/tracestatefrom inbound REST and gRPC requests and continues that trace, injecting the same headers into its outbound requests. Propagation links traces across services, but does not by itself enable recording (see W3C trace-context propagation). -
NICo REST API services share one OpenTelemetry bootstrap configured through standard
OTEL_*variables. Unlike nico-api, they export over OTLP/HTTP by default and can use TLS. See REST service tracing.
1. How tracing works
Which components emit traces
The following binaries build an OTLP span exporter:
- nico-api (
crates/api-core/src/logging/setup.rs) - the rich, control-plane tracing this document is mostly about, off by default behind endpoint plus enabled-flag configuration - nico-dns (
crates/dns/src/main.rs) - a separate, much simpler opt-in setup. - nico-bmc-proxy (
crates/bmc-proxy/src/setup.rs) - one span per proxied BMC request, off by default behind endpoint plus[tracing] enabled(see nico-bmc-proxy tracing). - nico-pxe (
crates/pxe/src/main.rs) - request spans, off by default unless an OTLP endpoint is configured (see nico-pxe tracing). - nico-dhcp (
crates/dhcp/src/tracing_setup.rs) - the Kea hook library thenico-dhcppods load. One span per discovery or lease expiration, off by default unless an OTLP endpoint is configured (see nico-dhcp tracing). - nico-dhcp-server (
crates/dhcp-server/src/main.rs) - the Rust DHCP server the BlueField DaemonSet runs. One span per packet, configured the same way (see nico-dhcp tracing). - NICo REST API services (
rest-api/common/pkg/otel), off by default until an OTLP endpoint variable is set, plustracing.enabledon nico-rest-api and the workflow workers (see REST service tracing).
The other binaries (nico-hardware-health, nico-ssh-console-rs, and nico-dsx-exchange-consumer) carry the OpenTelemetry crates in the workspace but do not build a span exporter, so they do not emit traces.
Unless noted otherwise, the rest of this document describes nico-api tracing. nico-dns differs as described in nico-dns tracing. NICo REST API services are described separately in REST service tracing.
What operations are covered
nico-api links many library crates in-process and the #[tracing::instrument] spans live in
those crates. When tracing is enabled, the instrumented operations are:
There is also a metric, carbide_api_tracing_spans_open, that reports the number of currently
open spans (exported by the spancounter crate) - useful for spotting span leaks or runaway
trace volume.
These cover the control-plane paths an operator most often needs to debug: machine provisioning/reconcile loops, power control and firmware updates against the BMC/power/rack backends, plus the database work underneath them - which maps directly to the EPICโs โtime on a given state of the machine, nodes stuckโ need.
RMS RPC auditing
NICo configures librms with a caller-owned transport layer that injects W3C trace context
before the V1 or V2 tonic client sends each request. The layer also applies to readiness
probes and connections rebuilt after certificate rotation. The generated unary wrappers
open an rms_rpc span and report decoded request/response bodies, the protobuf method and
message type, numeric grpc_status_code, UTC RFC 3339 request_timestamp and
response_timestamp, and monotonic elapsed_milliseconds. Request time is the start of
the logical call, before lazy connection setup and its readiness retries; response time is
when that call returns. elapsed_milliseconds spans that same interval, so it also includes
NICoโs own request-side redaction and span setup, but not response redaction. These are
client-side call boundaries, not server receive times.
The same fields appear in completion logs under the rms_rpc_audit target at DEBUG level.
The rms_rpc span sets logfmt.suppress, so the logfmt output carries that DEBUG record and
no level=SPAN close line repeating the bodies. The admin UI log stream, when enabled, is a
separate layer and still lists the span with its fields. In nico-api, DEBUG audit logging is on
when the current runtime log filter, including changes made through dynamic settings, allows
rms_rpc_audit at DEBUG; another layer accepting DEBUG does not count. When that filter and
the API runtime tracing flag are both off, payload encoding and redaction are skipped;
transport propagation remains installed. The transport runs without the callerโs span, so
connections it opens do not keep the rms_rpc span open after the call returns, and the
clientโs own events are not recorded.
To turn on the audit log in nico-api, add the target to the serverโs runtime filter:
The required --filter replaces the whole filter, so include the base level (info here).
The optional --expiry accepts a duration such as 30min or 1h, defaults to 1h, and
cannot exceed 60h. After expiry, the server restores its startup filter on the next
15-minute reset check. A filter with span directives cannot be evaluated by target alone,
so nico-api enables payload encoding and redaction
even if the filter ultimately suppresses the audit record. For nico-admin-cli rms, set
RUST_LOG=rms_rpc_audit=debug; the records go to stderr.
Bodies are redacted before being attached to a span or log: credentials, passwords, tokens,
URLs, arbitrary attributes, config values, error text, embedded JSON, and binary fields are
withheld. Only reviewed identifier, address, domain, and version string fields are retained;
new string fields are withheld until reviewed. Numeric, boolean, and enum values remain.
Unknown or malformed messages and bodies exceeding 65,536 bytes before or after JSON
serialization produce a [BODY OMITTED: <reason>] marker, for example
[BODY OMITTED: exceeds 65536 bytes], [BODY OMITTED: unknown message type], or
[BODY OMITTED: invalid protobuf], with no raw-payload fallback. Withheld field values appear
as [REDACTED]. Status messages, status details, and request/response metadata are not
recorded. Transport failures record response_body as the literal null;
cancellation records gRPC CANCELLED (numeric code 1). This is diagnostic
auditing in logs and sampled traces, not a durable audit database.
The APIโs shared clients cover every caller listed in the RMS RPCs row of What operations are covered. Existing RED (rate, errors, and duration) metrics are unchanged. Readiness probes carry trace headers but do not produce their own body audit record. Raw tonic clients and streaming RPCs are outside the decoded observer; RMSโs V1/V2 service definitions contain only unary RPCs.
nico-admin-cli rms installs the same policy and emits DEBUG audit logs through its own
subscriber, but does not export nico-api OTLP spans. Without a configured propagator and
active OpenTelemetry context, transport injection is a no-op. API spans follow the existing
endpoint and enabled flag. An RMS RPC without a local parent is an eligible trace root;
child RPCs inherit their parentโs sampling decision. Rack reconciliation adds
a rack span under the state processorโs existing trace root.
How spans are selected (sampler)
nico-api uses a custom CarbideSpanSampler:
- A root span is recorded only if both are true:
- the in-process
tracing_enabledflag is on, from[tracing] enabled = trueat startup or from the dynamictracing-enabledsetting - the span carries the
carbide.trace_rootmarker attribute, set explicitly on the request span and a few deliberate roots (the state-controller reconcile loops and site-explorer)
- the in-process
- In-process child spans inherit the rootโs decision, so once a trace is sampled the whole call tree beneath it is captured - except tokio spans, which are always dropped (they leak and would exhaust memory).
- For a span parented to a remote (ingress-extracted) trace, the decision stays local: an inbound
sampledflag does not overridetracing_enabled(see W3C trace-context propagation). - The exporter resource is
service.name = carbide-api; the tracer is namedcarbide.
How traces leave nico-api
nico-api pushes spans over OTLP/gRPC to a collector endpoint you configure. It does not
discover or get injected with anything - it simply connects out to the endpoint from
[tracing] otlp_endpoint or, if set, OTEL_EXPORTER_OTLP_TRACES_ENDPOINT. The environment
variable overrides the TOML value. The transport details: gRPC-only, plaintext.
nico-dns tracing (separate, opt-in)
nico-dns has its own tracing setup (crates/dns/src/main.rs), independent of and simpler than
nico-apiโs:
- Off by default. nico-dns builds the span exporter only when an OTLP endpoint is configured
- via
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, the--otlp-endpointCLI flag, or theotlp_endpointconfig field (crates/dns/src/config.rs). With none of those set, no exporter is built and no traces are sent. The env var takes precedence over the CLI flag/config file.
- via
- No hardcoded default endpoint. Earlier versions defaulted to
http://opentelemetry-collector.otel.svc.cluster.local:4317and exported unconditionally, which meant every deployment silently exported traces to that address whether or not a collector was listening there for OTLP/gRPC (see NVBUG 6717563). That default has been removed; set the endpoint explicitly to enable tracing. - Default sampler. It uses the OpenTelemetry SDKโs default sampler (no
CarbideSpanSampler), so it records broadly, filtered only by the log-level directives in itsEnvFilter. It instrumentsretrieve_records, among others. - Resource / output:
service.name = nico-dns; logs are logfmt on stdout, matching nico-api. - Same transport constraints: OTLP/gRPC, plaintext (
with_tonic, notlsfeature)
nico-bmc-proxy tracing
nico-bmc-proxy traces each proxied Redfish request through the BMC credential proxy
(crates/bmc-proxy/src/proxy/). It follows the same W3C propagation model as nico-api
(issue #2438) so a call from nico-api or
DPS stays one trace across the proxy hop (issue
#2355).
- Off by default. Spans are exported only when an OTLP endpoint is configured and
[tracing] enabled = true(or the process is started with--debug). There is no runtime toggle on this binary. - Environment variable override. Tracing can be enabled via environment variable using the
NICO_BMC_PROXY__TRACING__ENABLED=true. The double underscore (__) maps to nested TOML sections, soNICO_BMC_PROXY__TRACING__ENABLEDoverrides[tracing] enabled. This prefix takes precedence over TOML configuration. - Endpoint. Set the standard
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, orOTEL_EXPORTER_OTLP_ENDPOINTto cover every signal at once; the trace-specific variable wins when both are set.[tracing] otlp_endpointin the proxy TOML is the fallback for when neither variable is set. The remaining standard OTLP transport settings (OTEL_EXPORTER_OTLP_TIMEOUT,OTEL_EXPORTER_OTLP_COMPRESSION,OTEL_EXPORTER_OTLP_HEADERS, โฆ) are read by the exporter itself and apply as well. A malformed endpoint is rejected when the exporter is built; the proxy logs a warning and serves BMC traffic without tracing rather than refusing to start. - Ingress. Each proxied request opens a
bmc_proxy_requestspan and adopts any inboundtraceparent/tracestateviatrace_propagation::set_span_parent_from_headers. - Egress to BMC. Upstream Redfish calls use a
reqwest-tracingclient so the active proxy spanโs W3C context is injected on the BMC leg. The inbound headers are dropped before the upstream request is assembled โtrace_propagation::is_propagated_headerasks the configured propagator which headers are its own โ so the BMC parents under the proxyโs span rather than the callerโs. - Egress to nico-api (gRPC). Credential lookup uses the shared
ForgeApiClient, which already wraps the transport withTraceInjectService. - Resource / tracer:
service.name = nico-bmc-proxy, tracer namenico-bmc-proxy. - Span fields: HTTP method and request path, the status the proxy answered its caller with (not
the BMCโs โ a request the proxy rejects never reaches one), BMC target IP (span attribute, not
a Prometheus label), the request class (
bmc_proxy.class) after the request passes its ACL, and the target type (bmc_proxy.target:bmc, orswitch_nvosfor a switchโs NVOS management interface) after its credentials resolve. Only a 5xx sets the span status to error; a 4xx is the callerโs error. A429the proxy answers for want of a slot at the BMC leaves the span ok as well;carbide_bmc_proxy_admission_refused_totalcounts those.
Example config:
Point this at the same collector nico-api uses: spans only join into one trace if every
hopโs exporter reaches the same backend. The components stay distinguishable by their
service.name.
nico-pxe tracing
nico-pxe traces each HTTP request it serves, including the iPXE script, cloud-init and TLS bootstrap
routes. It uses the shared setup in carbide_instrument::otlp_tracing, enabled by that crateโs
otlp-tracing feature.
- Off by default.
nico-pxeexports spans only when an OTLP endpoint is configured. It has no separate enabled flag and no runtime toggle. - Endpoint.
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT(otlpEndpointHelm value) orOTEL_EXPORTER_OTLP_ENDPOINT, which also applies to metrics and logs. TheOTEL_EXPORTER_OTLP_TRACES_ENDPOINTvar takes precedence when both are set. If no endpoint is set, span export stays off andnico-pxelogs atdebug. If the exporter rejects the endpoint, it logs a warning and disables span export. - Span level, separate from the log level.
NICO_TRACES_SPAN_LEVELsets the most verbose span level exported, defaulting toinfo. A spanโs level is set by the macro that creates it, such asinfo_span!or#[instrument(level = "debug")], and decides only whether the exporter receives the span. It acceptsoff,error,warn,info,debug,trace, or0-5. It is independent ofRUST_LOG: set it todebugortraceto export more spans without adding lines to stdout, and changingRUST_LOGdoes not change which spans are exported. If the value is invalid,nico-pxelogs a warning and keeps the default level. - Inbound requests. Each request opens a
requestspan (crates/pxe/src/middleware/logging.rs) and uses any inboundtraceparentortracestateheader as its parent.- A booting node cannot send those headers, so most requests span starts a new trace.
- A request from another traced service continues that serviceโs trace.
- Outbound gRPC to nico-api. Calls use the shared
ForgeTlsClient, which wraps its transport withTraceInjectServiceand sends the trace context on every request. No per-call code is needed. - Resource / tracer:
service.name = nico-pxe, tracer namenico-pxe. - Span fields: the same fields the request log line carries -
span_id, client IP and port, method, path, query, the Host, Content-Length and User-Agent headers when present and the response status. - Sampling. The standard
OTEL_TRACES_SAMPLERandOTEL_TRACES_SAMPLER_ARGvariables are used to configure a sampler. Preferparentbased_traceidratioovertraceidratio. A parent-based sampler applies the ratio only at the service that starts a trace and later services reuse it. Thenico-apihas a different tracing support approach. It does not read these variables, because it installsCarbideSpanSamplerinstead. - Shutdown. On SIGTERM,
nico-pxestops accepting connections, lets in-flight requests finish, then sends the last batch of spans.
nico-dhcp tracing
Two separate programs serve DHCP, and both use the shared setup in
carbide_instrument::otlp_tracing with the same variables as nico-pxe.
-
The Kea hook (
crates/dhcp, built aslibdhcp.so) runs inside thekea-dhcp4andkea-dhcp6processes that thenico-dhcpchart deploys. It opens adhcp_discovery,dhcpv6_discoveryordhcp_lease_expirationspan around each callout that calls nico-api.service.name = nico-dhcp. -
nico-dhcp-server (
crates/dhcp-server, theforge-dhcp-serverbinary) runs on BlueField DPUs throughbluefield/charts/nico-dhcp-server. It opens one span per DHCPv4 and DHCPv6 packet it processes.service.name = nico-dhcp-server. -
Off by default. Both export spans only when an OTLP endpoint is configured.
-
Endpoint.
OTEL_EXPORTER_OTLP_TRACES_ENDPOINTorOTEL_EXPORTER_OTLP_ENDPOINT, with the traces-only variable taking precedence. TheotlpEndpointHelm value sets the first one in thenico-dhcpchart, where it reaches both the DHCPv4 and the DHCPv6 deployment, and in the BlueField chart. -
Span level.
NICO_TRACES_SPAN_LEVELworks as it does for nico-pxe, defaulting toinfo. -
Each packet starts a new trace. A DHCP client sends no
traceparent, so there is no inbound context to continue. Spans opened inside that root span, including the outbound gRPC call, join the same trace. -
Outbound gRPC to nico-api. Calls use the shared
ForgeTlsClient, which sends the trace context on every request, so a DHCP trace continues into nico-api. -
Hook logging is unchanged. The hook logs through Keaโs logger, and installing span export does not add log lines or route them through
RUST_LOG. -
Shutdown. The hook sends its last batch when Kea unloads it. nico-dhcp-server sends its last batch only after its serving loop returns to
main. It does not handle SIGTERM, so spans still buffered when the pod is terminated are lost.
W3C trace-context propagation
nico-api accepts and produces W3C Trace Context headers (traceparent and tracestate) at its
network boundaries, so a request already traced by another service stays one trace as it passes
through nico-api. The standard TraceContextPropagator is installed once at startup
(crates/api-core/src/logging/setup.rs); there is no custom header parsing.
- Ingress (REST + gRPC). The shared per-request layer (
crates/api-core/src/logging/api_logs.rs) extracts any inboundtraceparentortracestateand makes the upstream span the parent of nico-apiโs request span. REST and gRPC flow through this single layer, so both are covered. A missing or malformedtraceparentleaves the request span a fresh root. - Egress. When nico-api makes an outbound call from within a traced request, it injects the
current
traceparentandtracestateso the downstream service can continue the trace. Covered:- gRPC - Forge and NMX-C (
crates/rpc), the NSM and power-shelf (PSM) backends (crates/component-manager), and the NMX-C client pool (crates/libnmxc), through a shared tower layer applied to every request. - gRPC (RMS) - the RMS clients use the same
trace_propagation::TraceInjectService, butlibrmsowns the transport, sorms_observability::configure()(crates/rms-observability) installs it as the caller-supplied transport layer on theRmsClientConfig. nico-api calls it fromcrates/api-core/src/setup.rs. See RMS RPC auditing. - HTTP - the BMC/Redfish handler, machine-identity token exchange, admin-UI OAuth2, NRAS, the MQTT OAuth2 token provider, and firmware downloads.
- gRPC - Forge and NMX-C (
- Interaction with the enable flag.
tracing-enabledis the master switch for what nico-api records: an inboundsampledflag never turns recording on here. Whentracing-enabledis on, the inboundtrace_idis inherited, so nico-apiโs spans join the callerโs trace. - Forwarding vs. recording. Forwarding the context is separate from recording it, but both currently
depend on the exporter being built:
- Exporter built, tracing off: records nothing, yet still forwards the inbound
trace_idandtracestatemarked not sampled (sampled=0). - No endpoint configured (exporter not built): does not forward at all, so the trace breaks at this hop. This is a known limitation.
- Exporter built, tracing off: records nothing, yet still forwards the inbound
- Scope. Trace context only (
traceparentortracestate).
Adding a new network client
Propagation is automatic on ingress but opt-in on egress. Keep the following in mind when adding code:
-
New ingress (a REST route or gRPC method): nothing to do. Every inbound request flows through the shared per-request layer (
crates/api-core/src/logging/api_logs.rs), which extracts the inbound context for you. -
New outbound gRPC client (tonic/hyper): wrap its channel/service with
trace_propagation::TraceInjectServiceat construction. Better yet, build through an existing shared client that already wraps the transport (seecrates/rpc/src/forge_tls_client.rs). -
New outbound HTTP client (
reqwest): build it through thereqwest-tracingmiddleware instead of using a barereqwest::Client. The wrapped client injects the currenttraceparentandtracestateinto every request automatically, so there is no per-call code:See
crates/nras/src/client.rsfor a real example. -
When another crate owns the HTTP call (manual fallback): if the request is built and sent by code you donโt control (for example, the
oauth2client (crates/api-web/src/auth.rs), which owns its ownreqwestrequest) inject into that requestโs headers directly:
Injection is always a no-op when no trace is active, so it is safe to add it unconditionally.
2. How to enable and disable tracing
Enabling tracing has two parts: startup configuration for the exporter endpoint, and an enabled flag that can come from startup config or, when allowed, the runtime switch. An endpoint without the enabled flag emits no traces. The enabled flag without an endpoint also emits no traces because no OTLP exporter is built.
Deploy-time configuration
(a) A traces backend. Anything that accepts OTLP traces: e.g. Tempo, Jaeger, Grafana Cloud, Datadog, Elastic APM or another OTEL collector acting as a gateway.
(b) A collector to receive OTLP from nico-api. nico-api should send to a collector, not straight to the backend - the collector is where you do sampling, batching, attribute normalization and (importantly) TLS for anything leaving the cluster. There are two common ways to give nico-api a collector to talk to:
Option A - a shared collector (Deployment or DaemonSet) that many workloads send to. A minimal
otel-collector traces pipeline:
With Option A, nico-apiโs endpoint is the collectorโs in-cluster Service, e.g.
http://otel-collector.observability.svc.cluster.local:4317.
Option B - a per-pod sidecar collector injected by the OpenTelemetry Operator. If your cluster
runs the OpenTelemetry Operator, you can have it inject a collector container into the nico-api
pod via a pod annotation. nico-api then talks to the collector over localhost (same pod, same network namespace)
The annotation value follows the form <namespace>/<collector-name>:
(c) Point nico-api at the collector. nico-api builds its OTLP span exporter only if an endpoint is configured at startup. If no endpoint is configured, no tracing layer is constructed at all and nothing is ever emitted - regardless of the enabled flag.
Preferred config-file form:
The deployment environment variable form is still supported and takes precedence over the TOML endpoint:
Notes:
OTEL_EXPORTER_OTLP_TRACES_ENDPOINTis the only trace-related setting nico-api reads from the environment. Other standard OTEL env vars are ignored.- The endpoint must be a plaintext gRPC target (
http://โฆ, h2c); 4317 is the default OTLP/gRPC port. Do not point it at a 4318 HTTP receiver and do not usehttps://. - Configuring only the endpoint puts the plumbing in place but does not start emission on its
own.
enabledmust also be true.
Enable / Disable Policy
With the endpoint configured, emission is controlled by [tracing] enabled, which defaults
off:
When allow_runtime_changes = true, toggle tracing live without a restart:
Under the hood this sets the dynamic config ConfigSetting::TracingEnabled, which flips the
in-process tracing_enabled flag that CarbideSpanSampler reads. If
allow_runtime_changes = false, the SetDynamicConfig call is rejected with PermissionDenied;
the startup value from [tracing] enabled remains authoritative until nico-api restarts with a new
config.
Leaving tracing off in steady state is the intended operating mode. If you need startup-only
control, set allow_runtime_changes = false and change [tracing] enabled through the config file
plus a pod roll.
Do I need to restart nico-api?
It depends on which part you are changing:
Why: [tracing] otlp_endpoint, [tracing] enabled, [tracing] allow_runtime_changes, and
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT are read at process startup (crates/api-core/src/logging/setup.rs).
If no endpoint was configured when nico-api started, the OTLP exporter and tracing layer were never
constructed and there is no way to add them at runtime. The runtime switch, when allowed, only flips
an in-process flag and never needs a restart.
Recommendation: set [tracing] otlp_endpoint at deploy time and leave it in place permanently -
the plumbing is cheap while tracing is toggled off. Keep enabled = false and
allow_runtime_changes = true for debug-on-demand environments, or set
allow_runtime_changes = false when the config file should be the only control plane for tracing.
Verifying it works
[tracing] otlp_endpointorOTEL_EXPORTER_OTLP_TRACES_ENDPOINTis set on nico-api and points at the collectorโs gRPC endpoint.- The collector has a
tracespipeline and its logs show the OTLP receiver listening on 4317. [tracing] enabled = trueis configured, ornico-admin-cli set tracing-enabled truehas been run whiletracing.allow_runtime_changes = true.- Exercise a traced operation (e.g. a machine power/firmware action), then look in your backend
for spans with
service.name = carbide-api. - Watch
carbide_api_tracing_spans_opento confirm spans are being opened.
3. Downsides and overhead
Tracing has real cost, which is the reason it defaults off. The cost depends on which of three states nico-api is in:
When tracing is ON
This is the expensive mode the dev team warns about:
- Because a spanโs in-process children inherit its sampling decision, a sampled root span pulls in its entire child subtree (the component-manager, controller, and DB spans beneath it). A single traced operation can therefore produce many spans.
- Costs land in several places: extra CPU and memory on nico-api, added latency on instrumented hot paths, network egress to the collector and storage in the backend.
- Mitigate with
tail_samplingat the collector (keep errors/slow traces, sample the rest) and - most importantly - only enable it during an active investigation, then turn it back off.
When the endpoint is set but tracing is OFF
This is the common steady state if you follow the recommendation to leave the endpoint configured
with [tracing] enabled = false, or after disabling tracing dynamically. The overhead here is
near-zero but not exactly zero:
- At startup, because the endpoint is set, nico-api builds the OTLP exporter, a tracer provider with a batch span processor and installs the OpenTelemetry tracing layer into its subscriber stack. That layer stays present.
- Per span, the layer is invoked on each (non-tokio) instrumented span and does a little bookkeeping/allocation before the sampler returns โdropโ. A background batch task exists but idles.
- What does not happen: no span recording, no attribute serialization, no batches to flush, no network or gRPC export. There is no I/O.
- Net: a small, roughly constant per-span CPU cost - negligible next to the โonโ mode, but not the literal zero you get with the endpoint unset.
Practical guidance
- Leave
[tracing] otlp_endpointconfigured and keep tracing off in steady state - cheap and avoids a pod roll when you need traces. - Treat โonโ as a temporary debugging state. Turn it off when done; watch
carbide_api_tracing_spans_openand nico-api CPU/latency while it is on.
4. How traces are sent (transport & security)
- nico-api speaks OTLP/gRPC only (no OTLP/HTTP).
- nico-api cannot originate TLS or mTLS for traces. The endpoint must be plaintext
- Therefore keep the nico-api โ collector hop local (in-cluster Service, or the in-pod sidecar) and make the collector the TLS boundary for anything leaving the cluster.
- Traces are push-based: nico-api connects out to the collector. There is no scrape/discovery annotation involved for traces
5. Troubleshooting
6. DPU workload tracing
DPU workloads (such as ovnkube-node) can emit OpenTelemetry tracing spans through the DPUโs otelcol-contrib collector. The collector provides a localhost-only OTLP/gRPC receiver that forwards spans to the site-level OpenTelemetry receiver using the DPUโs mTLS credentials.
Endpoint configuration
Workload configuration
Configure your workloadโs OpenTelemetry exporter to send spans to the local collector:
For Kubernetes workloads running on DPUs managed by DPF:
The nico-otelcol DaemonSet runs with hostNetwork: true, so the loopback endpoint
127.0.0.1:4317 is reachable only from the host network namespace. This works for
workloads like ovnkube-node that also use hostNetwork: true. Workloads in pod
network namespaces cannot reach this endpoint.
Security
- The OTLP receiver binds only to loopback (
127.0.0.1), preventing access from outside the node - Loopback does not authenticate callers - any process in the host network namespace can send spans
- Workloads do not need access to DPU mTLS credentials
- The collector authenticates to the site-level receiver using existing mTLS configuration
Resource attributes
Spans exported through this pipeline include:
7. NICo REST API service tracing
NICo REST API services share one OpenTelemetry bootstrap, rest-api/common/pkg/otel, which each
service runs once at startup. It reads the standard OTEL_* environment variables, so changing any
setting needs a pod restart. There is no runtime toggle like nico-apiโs tracing-enabled.
Which REST services export
An OTLP endpoint variable is OTEL_EXPORTER_OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_TRACES_ENDPOINT
with a non-empty value. No Helm chart or Kustomize base sets one, so nothing is exported until you
add it.
A service that does not export still reads an inbound traceparent and tracestate, and forwards
them on its own HTTP, gRPC, and Temporal calls. So a hop that does not export leaves a gap in the
trace rather than splitting it.
What each service traces
Spans are recorded only while a service exports. Apart from the database query hook, the instrumentation stays installed either way, which is what carries trace context through a service that does not export.
A database query span records the SQL statement with ? placeholders, so bound parameter values
are never exported.
Configuration
nico-rest-api and nico-rest-workflow read two tracing keys from their config file:
Every REST service reads these environment variables:
An unknown OTEL_PROPAGATORS name is a bootstrap error. Once a service exports, so are an unknown
protocol and an OTEL_BSP_* value outside its range. nico-rest-api and nico-rest-workflow exit
with failed to initialize tracing. The other services log the error and run without tracing or
trace propagation.
Trace context propagation
The APIโs Echo middleware, the Temporal interceptor, and the gRPC client and server handlers stay
installed whether or not a service exports. With OTEL_PROPAGATORS=none, a service that does not
export drops them. A service that exports keeps recording its own spans, but neither reads nor
sends trace context.
The API reads OpenTracing ot-tracer-* headers only when OTEL_PROPAGATORS includes ottrace,
for example tracecontext,baggage,ottrace.
Baggage also crosses Temporal, where the Temporal SDK writes it into workflow headers. Those headers
are kept in workflow history, so treat baggage as durable and keep sensitive values out of it.
OTEL_PROPAGATORS=tracecontext propagates trace context without baggage. Changing the propagators
while workflows are open is safe: a workflow header the new setting cannot read starts a new trace
instead of failing the workflow task.
Finding a requestโs trace
When nico-rest-api exports, it returns the trace ID in the X-Nico-Trace-Id response header on
every request except /healthz and /readyz. When it does not export, the header only repeats the
trace ID the caller sent.
Helm and Kustomize
The nico-rest-api, nico-rest-workflow, nico-rest-cert-manager, and nico-rest-site-manager
charts take an extraEnv map of names to values and render it into the container environment. The
workflow chart also takes cloudWorker.extraEnv and siteWorker.extraEnv, merged over the shared
map with the workerโs keys winning. extraEnv cannot override a variable the chart sets itself,
which is CONFIG_FILE_PATH and, on the workers, TEMPORAL_NAMESPACE and TEMPORAL_QUEUE.
Rendering fails if it tries.
The other services take the same variables through their own charts or manifests:
nico-rest-site-agenttakes them in itsenvConfigmap.nico-flowtakes a list ofEnvVarentries inextraEnv.flow.nico-ipam,nico-nvswitch-manager, andnico-powershelf-managerread them from their container environment.
The Kustomize bases in rest-api/deploy/kustomize/base/ set tracing.enabled: false in the API and
workflow config maps, and add no OTEL_* variables. To trace a Kustomize deployment, set
tracing.enabled: true in an overlay and add the variables to each workloadโs container env.
Cost
With the default sampler, a service that exports records every request it handles, including a span
for each database query. OTEL_TRACES_SAMPLER=parentbased_traceidratio keeps a fraction of new
traces instead. A slow or unreachable collector costs spans rather than request latency, since a
full queue drops new spans. On shutdown each service flushes the spans still queued.
Verifying REST tracing
- Point every service at the same collector. Spans only join one trace when every hop exports to the same backend.
- Check each serviceโs startup log.
tracing enabled, OTLP tracer provider installedmeans it exports, and the line also shows the resolvedserviceNameandprotocol. - Send an API request that starts a workflow. It should appear as one trace with the API server span, the Temporal client and worker spans, and the database spans beneath them.
- Look a single request up by the
X-Nico-Trace-Idvalue it returned.
Troubleshooting REST tracing
8. References
- NICo core metrics catalogue - includes
carbide_api_tracing_spans_open.