OpenTelemetry Tracing for Crypto Trading Bots: End-to-End Tick-to-Trade
A field guide to OpenTelemetry trading bot tracing: span the full tick-to-trade path from Geyser event to landed transaction and attribute every millisecond.
A Geyser plugin fires a ProgramUpdate for the pool you're watching at what your host clock says is 09:41:22.104. Your bot lands the swap 84 milliseconds later. Between those two timestamps sit six or seven hops — plugin fan-out, your ingest socket, a decode step, strategy evaluation, transaction build, signing, submission, and confirmation polling — and if you can't say which of those hops ate 40 of those 84ms, you're tuning blind. OpenTelemetry gives you a spanned, causally-linked record of that entire path, per event, so the answer stops being a guess.
This isn't about pretty dashboards. It's about attributing latency to the exact component that owns it, and doing it under load, when the one time it matters is a congested slot where your median looks fine but your p99 quietly doubled.
Why traces beat logs and metrics for tick-to-trade
Metrics tell you that your submit path got slow. Logs tell you what happened at one point. Neither tells you where in a single request's journey the time went, because a trade touches multiple async tasks and often multiple processes. Distributed tracing stitches those together with a shared trace ID and parent-child span relationships, so one Geyser event becomes one trace you can read top to bottom.
The unit you care about is the span: a named operation with a start time, a duration, and attributes. A trace is a tree of spans sharing a trace ID. For a Solana bot the natural root span is the market event, and the leaves are things like quic.send or rpc.getSignatureStatuses. When you view that tree in Jaeger or Tempo, the gaps between spans are as informative as the spans themselves — a 30ms gap before your build span usually means your strategy task was starved on a busy runtime, not that decoding was slow.
Instrumenting the pipeline
Start with the OpenTelemetry SDK for your language. In Rust, that's the opentelemetry, opentelemetry_sdk, and opentelemetry-otlp crates plus tracing-opentelemetry if you're already on the tracing ecosystem (you should be). The pattern: create a tracer, wrap each stage in a span, and propagate context across task and process boundaries manually, because async runtimes and message queues will not do it for you.
Here's the shape of a root span created the moment a Geyser update lands:
use opentelemetry::trace::{Tracer, SpanKind};
use opentelemetry::{global, KeyValue};
let tracer = global::tracer("bot");
let mut span = tracer
.span_builder("tick_to_trade")
.with_kind(SpanKind::Server)
.with_attributes(vec![
KeyValue::new("slot", update.slot as i64),
KeyValue::new("account", pool_pubkey.to_string()),
KeyValue::new("geyser.recv_ts_us", recv_ts_micros),
])
.start(&tracer);
// carry cx into every downstream stage
let cx = opentelemetry::Context::current_with_span(span);
Each stage opens a child span off that context: decode, strategy.eval, tx.build, tx.sign, submit, confirm. Record the wall-clock timestamp you captured at Geyser ingest as an attribute so you can later compute the true event-to-first-action delta, since your span start won't be exactly the moment the packet hit your NIC.
The gotcha nobody mentions: span start time defaults to when you call .start(), not when the work began. If you build the span after doing 5ms of setup, that 5ms vanishes. Capture timestamps early and pass explicit start times, or open the span first thing.
Crossing the process boundary
If your ingest, strategy, and submission run as separate services — a sane split once you're colocating an ingest node near a validator — the trace has to survive the wire. OpenTelemetry's W3C traceparent format is 55 bytes: version, trace ID, span ID, flags. Inject it into whatever transport you use.
let mut carrier = std::collections::HashMap::new();
global::get_text_map_propagator(|prop| {
prop.inject_context(&cx, &mut carrier);
});
// carrier now holds "traceparent" -> "00-<trace_id>-<span_id>-01"
// ship it alongside the decoded event
On the receiving service, extract it and continue the same trace. This is the difference between three disconnected traces and one coherent picture spanning your whole fleet. Getting the ingest-to-validator hop right is exactly the kind of thing worth measuring precisely, and if you're running your own transaction path, the mechanics in our writeup on TPU client QUIC forwarding to the leader will tell you where the span boundaries should sit.
Spanning the submit path
The submit stage is where tracing earns its keep, because "submission" is rarely one call. If you're doing dual submission — firing the same signed transaction through a normal RPC and a Jito bundle simultaneously — you want a child span per path so you can see which one confirmed and how long the loser took. Model it as two sibling spans under submit, each with an outcome attribute:
submit.rpc→{ status: "landed", slot: 293_004_112 }submit.jito→{ status: "dropped" }
Now a week of traces answers a real question: is Jito winning the race often enough to justify the tip? The tradeoffs there are subtle, and pairing your trace data with the dual-submission analysis for sendTransaction vs sendBundle turns "we think Jito helps" into a number. Similarly, if you route through staked connections, tagging spans with the endpoint lets you compare landed rates across providers — the setup details live in the staked-connections and swQoS guide.
Confirmation is a span too
Don't stop the trace at send. The transaction isn't done until it lands, and confirmation latency — the gap between submit and the slot it landed in — is often your largest and most variable segment. Open a confirm span, poll getSignatureStatuses or subscribe to slot updates, and close it when you see the signature. Attach the landed slot and the slot delta. This is the segment that blows up during congestion, and it's invisible if you only trace up to submission.
Sampling without lying to yourself
You cannot export a full trace for every event at 5,000 events/second without paying for it in CPU and network. But head-based sampling — deciding at the root whether to keep a trace — will throw away the exact slow traces you need. Use tail-based sampling: buffer complete traces in the OpenTelemetry Collector and keep them based on outcome. A sane policy:
- keep 100% of traces where
tick_to_tradeduration > 100ms - keep 100% of traces where any span has
error=true - keep 100% of traces that actually landed a transaction
- keep ~2% of the rest as a baseline
That keeps your export volume low while guaranteeing every pathological trace survives. The Collector's tail_sampling processor does this with a few dozen lines of YAML. Run one Collector per host with an OTLP gRPC exporter to Tempo or Jaeger, and keep the batch timeout tight (200ms) so you're not adding artificial delay to the trace's visibility.
What it actually buys you
The first week you turn this on, you'll find something you didn't expect. Usually it's a serialization point — a Mutex your strategy holds across an await, an RPC client with a connection pool of one, or a tokio task that only gets scheduled after a lower-priority one yields. These are invisible to metrics and obvious in a trace, because you see a span sitting idle while wall-clock time advances.
A few rules that keep the data honest:
- Keep spans coarse enough to matter. You don't need a span around a
HashMaplookup; you need one around each network hop and each decision boundary. - Put high-cardinality values (signatures, pubkeys, slots) in attributes, never in span names. Span names should be a small fixed set.
- Clock discipline matters. If your services run on different hosts, span durations are fine but cross-host gaps inherit clock skew. Pin everything to the same NTP source or, better, colocate.
Instrumentation is infrastructure work, and it pays back every time you chase a latency regression. If you'd rather have the ingest-to-trace pipeline built and tuned for you instead of assembling Collectors and exporters by hand, our infrastructure and data engineering work covers exactly this, and the resulting spans feed straight into a live latency dashboard so the p99 you care about is one glance away.
Once the traces are flowing, the studio's ongoing support and maintenance keeps the sampling policies and alerting sane as your event volume grows — that's where most of the long-term value hides.
Need a bot like this built?
We design, build and run trading bots on Solana, Hyperliquid and Polymarket.
Start a projectMore from the blog
Public vs Paid RPC: How to Run a Fair Benchmark
A fair comparison of public and paid RPC endpoints must account for quotas, workload, freshness and support guarantees.
Read articleRPC Benchmark Percentiles Explained: p50, p95 and p99
Why averages hide the latency spikes that break trading bots, wallets and production blockchain applications.
Read articleHyperliquid REST vs WebSocket Benchmark: What to Measure
A benchmark plan for Hyperliquid market data that separates request latency from streaming freshness and recovery.
Read article