Skip to content

Add OpenTelemetry support - #615

Draft
edolstra wants to merge 29 commits into
mainfrom
eelcodolstra/nix-467
Draft

Add OpenTelemetry support#615
edolstra wants to merge 29 commits into
mainfrom
eelcodolstra/nix-467

Conversation

@edolstra

Copy link
Copy Markdown
Collaborator

Motivation

Context

@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

@github-actions
github-actions Bot temporarily deployed to pull request August 31, 2026 16:17 Inactive
Add a vendor-neutral distributed tracing facade (nix::otel) as the
foundation for tracing binary cache requests from the Nix client
through the daemon to FlakeHub Cache.

The facade (nix/util/otel.hh) is flag-independent: no conditionals or
OpenTelemetry includes in the installed header. The implementation is
gated behind a new 'otel' meson feature option; when disabled (or when
enabled but OTEL_EXPORTER_OTLP_ENDPOINT is not set), every operation
is a cheap no-op.

Design notes:

* Spans are copyable shared-handle values, safe to move across threads
  and to end out of stack order, so they can later back FileTransfer
  requests (which complete on the curl worker thread) and Activities.

* Span::injectContext() returns W3C traceparent/tracestate headers for
  propagation to HTTP servers and the daemon;
  startSpanFromRemoteParent() continues a trace on the receiving side.

* The tracer provider is owned by a deliberately leaked global instead
  of opentelemetry's Provider singleton so that no exporter teardown
  can run from static destructors at exit() time (cf. the
  OPENSSL_INIT_NO_ATEXIT note in util.cc); flushing happens only via
  an explicit forceFlushAndShutdown() with bounded timeouts.

* Only the OTLP/HTTP exporter is used (not gRPC, which is heavy and
  fork-unsafe). nixpkgs' opentelemetry-cpp ships no OTLP exporter by
  default, so packaging/dependencies.nix overrides it with
  enableHttp = true.

Nothing calls init() yet; wiring into the CLI, the daemon, and
FileTransfer comes separately.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Wire the nix::otel facade into the CLI entry point:

* otel::init() runs after programName is resolved and before the
  legacy command dispatch, so nix, nix-daemon, build-remote etc. all
  get a provider with the appropriate service.name. It remains a
  no-op unless OTEL_EXPORTER_OTLP_ENDPOINT (or
  OTEL_EXPORTER_OTLP_TRACES_ENDPOINT) is set in the environment.

* A Finally flushes and shuts down the exporter on every exit path,
  including exceptions and legacy commands' early returns.

* Each command invocation gets a root span ("nix <subcommand>"),
  ended by RAII. On failure the span records an error status with the
  ANSI-stripped error message; Exit is rethrown unmarked since it can
  represent a clean exit.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Give every FileTransfer a span, parented under the process root span
and exported as a CLIENT span covering the whole transfer including
retries. On success it records http.response.status_code (and
http.request.resend_count when retries happened); every failure path
funnels through failEx(), which records an error status with the
ANSI-stripped message.

The span's W3C context (traceparent/tracestate) is injected into the
request headers, so any otel-instrumented server (e.g. FlakeHub
Cache) can join its server-side spans to the client's trace.

To parent transfer spans correctly, the facade gains
otel::setRootSpan() / otel::rootSpan(): a process-wide default parent
registered by main.cc. Only a weak reference is kept so the root
span's RAII lifetime is unaffected.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Split the FileTransfer span into two levels: a span covering the
lifetime of the TransferItem (including retries and backoff), and a
child CLIENT span per HTTP request, recreated on every attempt.

Each attempt span records its own http.response.status_code, its
resend count, and its own error status when that attempt fails --
even if the transfer later succeeds on a retry. Since the traceparent
now differs per attempt, the request headers are rebuilt in init()
rather than once in the constructor, so every HTTP request propagates
the context of exactly the attempt that produced it.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Every caller wants this (error messages are formatted for the
terminal), so do it in the facade instead of at each call site.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Add an "open-telemetry" worker protocol feature. When both sides
support it, the client sends the W3C traceparent of its root span
(or an empty string if it is not tracing) right after the handshake
in initConnection(). The daemon then opens a "daemon connection"
SERVER span parented under the client's trace and registers it as
its root span, so everything it does on behalf of the connection --
including binary cache requests once they happen daemon-side --
joins the client's trace.

Since the daemon forks a child per connection and the OpenTelemetry
batch exporter's worker thread does not survive fork(), the facade
gains otel::resetAfterFork(), which discards the inherited provider
without touching it. The connection child calls it, initializes
tracing afresh as "nix-daemon", and flushes before exit.

Feature negotiation keeps old clients and daemons compatible in both
directions: without agreement, no traceparent is exchanged.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Build opentelemetry-cpp with WITH_OTLP_HTTP_COMPRESSION=ON (and
zlib), so that OTEL_EXPORTER_OTLP_COMPRESSION=gzip works instead of
warning and falling back to uncompressed exports.

The flag also enables two gzip HTTP round-trip tests that time out
flakily in the build sandbox (their sibling BasicCurlHttpTests are
already shipped disabled), so exclude them from the check phase.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Instead of creating spans explicitly at instrumented call sites,
derive them from Nix's existing Activity system: an
OpenTelemetryLogger (analogous to JSONLogger) maps activities onto
spans and is added to the global logger via the new
applyExtraLogger(). All OpenTelemetry code moves from libutil to
src/nix (otel-logger.{cc,hh}), along with the build-time dependency;
libutil and libstore are now free of it. Anything that emits an
Activity gets traced for free, which is also how future
instrumentation (e.g. the evaluator) should hook in.

Vendor-neutral additions to libutil's logging interface:

* Logger::getTraceContext(): returns W3C trace context headers for an
  activity, used by FileTransfer to add a traceparent header to HTTP
  requests and by RemoteStore to propagate the trace context to the
  daemon during the handshake (the "open-telemetry" protocol feature
  is unchanged on the wire).

* RemoteLogSource: marks Logger calls that replay messages from
  another process. The client-side handling of activities forwarded
  from the daemon uses it, so that the client does not export
  telemetry for them.

* actFileTransferAttempt / resHttpStatus: FileTransfer now starts a
  child activity per HTTP request (there can be several per transfer
  due to retries) and reports the response status on it. The transfer
  activity is started eagerly in init() so that it exists before the
  request headers are built.

Export ownership: every process exports exactly the activities it
originates. The daemon keeps its own exporter lifecycle; its
connection span (created via processConnection()'s new setupTelemetry
callback) is parented under the client's trace context, and its
activities are exported by the daemon itself while still being
forwarded to the client for display. The client skips forwarded
activities, so nothing is exported twice.

Known limitations: span error status is mostly lossy (stopActivity
carries no status; the root span still records command failures), and
top-level daemon activities parent under the connection span rather
than the client-side operation that triggered them.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
@edolstra
edolstra force-pushed the eelcodolstra/nix-467 branch from 77e6526 to cf65665 Compare September 3, 2026 11:05
@github-actions
github-actions Bot temporarily deployed to pull request September 3, 2026 11:12 Inactive
Turn the global logger into a TeeLogger if it isn't one already, then
unconditionally append the extra logger to it.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
It no longer has any callers: applyExtraLogger() constructs the
TeeLogger directly.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
The JSON payload now carries `method` (e.g. `GET`) and `bodySize` (the
number of body bytes received) alongside `httpStatus`. The
OpenTelemetryLogger maps them onto the `http.request.method` and
`http.response.body.size` span attributes.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Per the OpenTelemetry semantic conventions, HTTP client spans are
named after the request method (e.g. `GET`). Pass the method as a
field of actFileTransferAttempt so the OpenTelemetryLogger can use it
as the span name and the `http.request.method` attribute, and drop it
from resHttpStatus where it is now superfluous.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Return the name of the enum value without the `act` prefix
(e.g. `OptimiseStore`), generated with a macro so the names cannot
drift from the enum. In the future, C++26 reflection can make this
fully generic.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
@github-actions
github-actions Bot temporarily deployed to pull request September 3, 2026 14:19 Inactive
ActivityType is an enum, so adding a new type of activity is
expensive (it requires changing logging.hh) and not self-describing.
Also, activity fields are positional, with their meaning living in
per-type conventions.

So add a new Activity constructor that takes a string activity name
(e.g. `FileTransferAttempt`) and key/value meta-information about the
activity. Metadata keys follow the OpenTelemetry attribute naming
conventions where applicable (e.g. `http.request.method`);
Nix-specific keys use a `nix.` prefix.

This is backwards compatible: the default implementation of the new
name-based Logger::startActivity() reports the activity through the
old ActivityType-based interface as the new type `actStringly`,
discarding the name and metadata. So legacy loggers (including the
daemon's TunnelLogger, and therefore old clients) work unchanged.
Loggers that know about the new interface get the rich form:
JSONLogger emits the name and a `payload` object with the metadata,
and the OpenTelemetryLogger uses the name as the span name and the
metadata as span attributes, without needing per-type knowledge.

FileTransfer's per-HTTP-request activity becomes the first
string-named activity, replacing the actFileTransferAttempt enum
value introduced recently.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
@github-actions
github-actions Bot temporarily deployed to pull request September 3, 2026 15:18 Inactive
Rename the anonymous "evaluating derivation" activity in
InstallableFlake::toDerivedPaths() to `EvaluateFlakeDerivationOutput`,
carrying the installable as metadata. This makes flake evaluation
show up as a properly named span in OpenTelemetry traces.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Handy for finding the trace of a particular Nix invocation in the
tracing backend.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
The anonymous activity in fetchToStore2() becomes `CopySourcePath` or
`HashSourcePath` (depending on the fetch mode), carrying the source
path as metadata.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Also register it as the current activity (via PushActivity), so that
the QueryPathInfo activities triggered by it are parented under it
instead of dangling from the root. (The asio coroutines involved all
run on the calling thread via ctx.run(), so the thread-local current
activity is visible inside them; only the push was missing.)

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Parent the worker's aggregate activities (actBuilds, actCopyPaths)
under its actRealise activity, parent actSubstitute under
actCopyPaths, and parent the actBuild activities (both the local
build and build hook cases) under actBuilds.

The actSubstitute case requires passing the parent activity ID into
the substitution thread explicitly, since the current activity is
thread-local and the substitution runs on a fresh thread.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Previously all daemon activity was parented under a single "daemon
connection" span. Instead, the opcode send is now factored into
WorkerProto::BasicClientConnection::startOp(), which appends the
trace context of the client's current activity when the
"open-telemetry" protocol feature has been negotiated (safe to
redefine since it was never released). On the daemon side, the
traceparent is read centrally in the processConnection() loop, which
wraps each operation in a generic "daemon operation" activity
carrying the numeric opcode as `nix.daemon.op` metadata and the
received trace context as `traceparent` metadata. The
OpenTelemetryLogger treats such a metadata field as a remote parent,
taking precedence over the local parent. Everything the daemon does
on behalf of an operation then nests under the client's span via the
current-activity mechanism.

Note that in the daemon, the per-operation activity must be created
on the global logger rather than the TunnelLogger: sending it only to
the TunnelLogger would forward it to the client (which ignores remote
activities) without ever reaching the daemon's own
OpenTelemetryLogger.

Also add getTraceparent() to extract the traceparent header from
Logger::getTraceContext() results.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
A typical Nix invocation performs thousands of daemon operations, so
emitting a span per "daemon operation" activity is very spammy.
Instead, an activity carrying a `traceparent` metadata field now
produces no span at all: the OpenTelemetryLogger records the remote
context (as a non-recording DefaultSpan) so that child activities
resolve their parent to the client's span, linking the daemon's work
directly under the client activity that initiated the operation.

Also, the daemon only creates the link activity when the client
actually sent a trace context.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Print the trace ID at the end, in flushOtelAndShutdown(), so that
when the user sees it, the trace has actually been uploaded to the
collector.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Add a string-named activity covering the lifetime of a
PathSubstitutionGoal, starting as soon as init() runs. The
substituter queryPathInfo() activities and the actSubstitute
activity (which is left alone, since the progress bar uses it to
indicate that the actual download is happening) are now parented
under it, instead of dangling from actCopyPaths or the root.

Note that the PushActivity around queryPathInfo() is narrowly scoped
to the synchronous part of the call, so that it does not extend
across a coroutine suspension point (the thread-local current
activity would leak into unrelated interleaved coroutines).

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
@edolstra
edolstra force-pushed the eelcodolstra/nix-467 branch from bcddde6 to b3cab19 Compare September 3, 2026 18:43
stopActivity() carries no success/failure status, so spans generally
ended without one. However, if an Activity is destroyed by stack
unwinding (std::uncaught_exceptions() is non-zero in stopActivity(),
which runs on the unwinding thread), we can infer that the activity
failed and set an error status on its span.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Honor the standard OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG
environment variables (which the C++ SDK does not read itself),
supporting the sampler names from the OpenTelemetry specification:
always_on, always_off, traceidratio and their parentbased_* variants.
The default remains parentbased_always_on, i.e. record everything.

The parentbased_* samplers follow the sampling decision of the parent
span, which propagates in the sampled flag of the W3C trace context —
so e.g. the daemon and binary cache servers follow the client's
decision, keeping traces complete or absent as a whole.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Assisted-by: Claude Fable 5 <noreply@anthropic.com>
@github-actions
github-actions Bot temporarily deployed to pull request September 4, 2026 13:40 Inactive
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant