Skip to content

Add OpenTelemetry traces and metrics to peer and orderer - #5535

Draft
dviejokfs wants to merge 4 commits into
hyperledger:mainfrom
dviejokfs:feat/otel-tracing
Draft

Add OpenTelemetry traces and metrics to peer and orderer#5535
dviejokfs wants to merge 4 commits into
hyperledger:mainfrom
dviejokfs:feat/otel-tracing

Conversation

@dviejokfs

Copy link
Copy Markdown
Contributor

What this does

Adds OpenTelemetry traces and metrics to the peer and the orderer, so that a
slow transaction can be attributed to a specific stage rather than inferred from
aggregate metrics.

The existing Prometheus metrics answer how long endorsement takes on this peer.
They cannot answer where the 900ms went for this particular transaction,
because that time is spread across a client, one or more endorsing peers, the
ordering service, and finally a commit on every peer in the channel.

Everything is inert unless an OTLP endpoint is configured. With no OTEL_*
variables set, no provider is installed, all instrumentation falls through to the
OpenTelemetry no-op implementation, and the nodes behave exactly as they do
today.

Spans

EndorsementpreProcess, GetTxSimulator (contention with commit, since
the simulator takes a shared lock on the state DB), ExecuteChaincode,
GetTxSimulationResults, DistributePrivateData, EndorseWithPlugin.

OrderingProcessNormalMsg, WaitReady (consenter backpressure), Order.

CommitValidate, RetrievePvtdata, CommitLegacy.

The design problem worth reviewing

Endorsement and ordering are ordinary gRPC calls, so W3C traceparent
propagates natively via request metadata and those spans join the client's trace
with no changes to transactions.

Commit cannot work that way. A block is cut asynchronously, contains transactions
from many unrelated clients, and commits independently on every peer, so there is
no ambient context to continue and no single parent a block could belong to.
Block commit spans are therefore separate traces related to transactions by
span links
, resolved from either:

  1. What the peer recorded when it endorsed the transaction — bounded in time and
    size, and lossy by design, since a peer that did not endorse never had the
    context.
  2. Trace context the client embedded in the signed transaction. Implemented here
    as protobuf fields 1000/1001 on ChaincodeHeaderExtension — unknown fields
    that stock Fabric preserves and ignores, and which verify normally because the
    client signs the header after adding them.

When neither resolves, the commit is still traced and still carries
fabric.tx_id, so the traces can be joined at query time.

Metrics

Rather than adding new metric definitions, this implements Fabric's existing
metrics.Provider over the OTEL SDK and registers it as a new otel provider
alongside statsd/prometheus/disabled. Every metric Fabric already defines
is then exported over OTLP with no new instrumentation call sites, which is what
keeps the OTLP and Prometheus views from drifting apart.

Note that Fabric's Gauge supports both Set and Add while OTLP carries only
absolute values, so Add is resolved against the previous reading per label set.

Deliberate exclusions

Gossip, the Raft cluster service and health checks are not traced: their volume
scales with cluster size rather than transaction load, and consensus timing is
better served by the existing metrics.

Long-lived streams (Deliver, and AtomicBroadcast at the stream level) are
excluded from automatic instrumentation. A span lasts as long as its RPC, so
wrapping a stream open for hours would hold one span in memory that whole time
and produce a single span covering everything the stream ever carried. Ordering
is traced per message instead.

Status and known limitations

Opening as a draft — this is a cross-cutting change and I would rather agree
the approach before polishing it. Specifically:

  • This has not been run against a live OTLP collector. Unit tests confirm
    spans are recorded and metrics aggregate correctly, but the OTLP exporter
    connects lazily, so nothing here proves data lands in a backend.
  • The client side of envelope-carried trace context is not implemented. The
    peer side reads it and MarshalTraceContextExtension exists as a reference
    implementation, but emitting the fields needs a change in whichever SDK builds
    transactions.
  • Happy to move this to an RFC in hyperledger/fabric-rfcs if that is the
    preferred route for a change of this scope.

Testing

  • peer and orderer both build; go vet clean on all changed packages
  • Tests pass for telemetry, endorser, broadcast, privdata, operations,
    comm
  • 22 tests in the new package, covering the inert path, the enabled path, the
    registry's expiry and size bounds, the metrics provider's Set/Add
    semantics, and a proto round-trip proving a transaction carrying trace context
    still parses and re-serializes correctly on a peer that does not know about it

Notes for reviewers

Design rationale and rebase notes are in docs/source/telemetry.md.

The diff is 316 files, but only 24 are source — the rest is vendor/ from
go mod vendor.

Instrumentation is confined to internal/pkg/telemetry plus seven call sites.
internal/pkg/comm gains a StatsHandlers field because gRPC supports multiple
stats handlers and Fabric only exposed one. context.Context is threaded through
ProcessProposalSuccessfullyOrError, simulateProposal, callChaincode and
ProcessMessage.

Adds distributed tracing so that a slow transaction can be attributed to a
specific stage, rather than inferred from aggregate metrics. Fabric's existing
Prometheus metrics answer how long endorsement takes on a peer; they cannot
answer where the time went for one transaction spread across a client, the
endorsing peers, the ordering service, and a commit on every peer.

Everything is inert unless an OTLP endpoint is configured. With no OTEL_*
variables set no provider is installed, all instrumentation falls through to the
OpenTelemetry no-op implementation, and the nodes behave as stock Fabric.

Traces cover endorsement (simulator acquisition, chaincode execution, rwset
collection, private data distribution, signing), ordering (validation,
consenter backpressure, enqueue) and commit (validation, private data
retrieval, ledger write).

Trace context propagates natively across endorsement and ordering, which are
ordinary gRPC calls. Commit is different: a block is cut asynchronously, holds
transactions from many unrelated clients, and commits independently on every
peer, so there is no ambient context to continue. Block commit spans are
separate traces related to transactions by span links, resolved either from
what the peer recorded when it endorsed, or from trace context the client
embedded in the signed transaction. The peer side of the latter is implemented
as unknown protobuf fields that stock Fabric preserves and ignores; the client
side needs an SDK change and is documented, not implemented.

Metrics are exported by implementing Fabric's metrics.Provider over the OTEL
SDK and registering it as a new "otel" provider. Every metric Fabric already
defines is exported over OTLP with no new metric definitions and no new
instrumentation call sites, so the OTLP and Prometheus views cannot drift.

Gossip, the Raft cluster service and health checks are excluded: their volume
scales with cluster size rather than load. Long-lived streams are excluded from
automatic instrumentation because a span lasts as long as its RPC, and are
traced per message instead.

See docs/source/telemetry.md.

Signed-off-by: David Viejo <dviejo@kungfusoftware.es>
Extends the endorsement spans to identify which chaincode function was
invoked, and adds a span for each callback a chaincode makes back into the
peer.

A contract that issues four hundred state reads and a contract that is simply
slow look identical from the endorsement span alone. Spans for GET_STATE,
PUT_STATE, GET_STATE_BY_RANGE, INVOKE_CHAINCODE and the rest are what tell the
two apart, and chaincode state access is usually where the time actually goes.

The function name is taken from the first argument, by the convention every
Fabric contract API follows. Nothing in the protocol enforces that convention,
so the value arrives attacker-controlled off the wire and is validated before
being recorded: it must be short, valid UTF-8 and free of control characters,
and is otherwise dropped. Only the first argument is read. The remaining
arguments are the transaction's business data and are never recorded, since
they routinely contain identifiers that should not reach a telemetry backend.

Each callback is handled on its own goroutine with nothing but a transaction id
to go on, so there is no ambient context to inherit. The invocation's span
context travels on TransactionParams into the transaction context instead,
which those goroutines already hold. That avoids a shared lock on the callback
path. The same value is carried across chaincode-to-chaincode calls so a
callee's state access stays in the caller's trace.

These spans are on by default because they are usually the answer, with the
sampler as the volume control: they inherit the sampling decision of the
transaction that caused them. FABRIC_TRACE_CHAINCODE_SHIM=false disables them
for the case where one sampled transaction fans out to thousands of reads. The
aggregate view is unaffected either way, since Fabric already meters shim
requests by type, channel and chaincode.

Signed-off-by: David Viejo <dviejo@kungfusoftware.es>
@dviejokfs

Copy link
Copy Markdown
Contributor Author

@pfi79 thoughts? this is important especially to detect performance issues in the peer/orderer in companies using OTEL in their observability stack.

The gRPC stats handler was installed unconditionally, and two feature switches
were read from the environment on hot paths. Neither is free, and both
contradicted the claim that a node without an OTLP endpoint behaves exactly as
it does today.

A stats handler is consulted on every inbound RPC. The peer and the orderer
multiplex gossip and Raft cluster traffic onto the same gRPC servers that carry
transactions, and that traffic scales with cluster size rather than with load,
so this was a steady per-RPC cost on the busiest path in the process paid by
every deployment whether or not anyone had asked for tracing. Initialize
already runs before either server is configured, so the handler is now simply
left out when tracing is off.

The chaincode shim switch was read with os.Getenv on each callback a chaincode
makes into the peer, which for a query-heavy contract is the busiest path
there is, and os.Getenv is a linear scan of the environment. The block link
switches were read per committed block. None of these answers can change while
a node is running, so they are resolved once during Initialize instead, before
tracing is switched on so that no hot path can observe tracing live with its
switches unset.

Signed-off-by: David Viejo <dviejo@kungfusoftware.es>
@pfi79

pfi79 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

@pfi79 thoughts? this is important especially to detect performance issues in the peer/orderer in companies using OTEL in their observability stack.

In my memory, simply inserting telemetry into the code led to serious degradation of the programs.
I would like to understand how much the work will deteriorate, with telemetry enabled, under high load.

@dviejokfs

Copy link
Copy Markdown
Contributor Author

@pfi79 thoughts? this is important especially to detect performance issues in the peer/orderer in companies using OTEL in their observability stack.

In my memory, simply inserting telemetry into the code led to serious degradation of the programs. I would like to understand how much the work will deteriorate, with telemetry enabled, under high load.

was OTEl the telemetry inserted into the code? do you have a PR or a reference to not repeat the same mistake?

I think telemetry needs to be enabled per component I agree, and I think the bottleneck can be the network rather than cpu, since the OTEL library sends the traces to the collector, nothing else.

So we could have env vars to enable telemetry in the chaincode, peer, gossip, etc

For the companies using fabric, the most important part is to be able to instrument the chaincode invocations IMHO.

Span attributes were passed to Start, whose options are built before the
sampler is consulted. Every transaction therefore paid to construct attributes
for spans that were about to be discarded, which meant lowering the sampling
ratio bought far less than it appeared to.

The chaincode callback span is the most frequent span the peer produces, and
the commit span was worse still: its links are built by unmarshalling the
header of every transaction in the block, all of it discarded for a span that
was never going to be recorded. Both now attach their attributes, and the
commit span its links, only after checking IsRecording.

Measured on the callback span with four attributes and a sampler that never
samples, which is the path almost every transaction takes under a low ratio:

  before   368 ns/op   696 B/op   6 allocs/op
  after    191 ns/op   144 B/op   2 allocs/op

For a contract issuing five hundred state reads that is a drop from roughly
350 KB of garbage per transaction to 70 KB, none of which was ever going to be
exported. The benchmarks are kept alongside the code so the claim can be
rechecked.

Signed-off-by: David Viejo <dviejo@kungfusoftware.es>
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.

2 participants