viem OpenTelemetry instrumentation
Yes, viem has OpenTelemetry instrumentation: @hashspan/viem, an open-source (Apache-2.0) client extension. Add .extend(withHashspan()) to your viem clients, and each sendTransaction or writeContract becomes a send {chainId} span. Each waitForTransactionReceipt becomes a linked confirm {chainId} span with the status, gas, fee and revert reason.
- Spans:
sendwhen the hash comes back,confirmwhen the receipt does, and one span per JSON-RPC request withtraceTransport(). - Setup: one package, any OpenTelemetry SDK, about 10 lines. No change to how you call viem.
- Covers:
sendTransaction,writeContract,sendRawTransaction, their sync forms, ERC-4337 user operations and EIP-5792 call batches.
How do I add OpenTelemetry to viem?
Section titled “How do I add OpenTelemetry to viem?”Install the adapter next to viem and the OpenTelemetry API:
npm install @hashspan/viem @opentelemetry/api viemAny OpenTelemetry SDK setup works. To see spans in the terminal first, this one prints them:
npm install @opentelemetry/sdk-trace-node @opentelemetry/sdk-trace-baseimport { ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
// Prints every span to the terminal. Swap in an OTLP exporter for Jaeger, Tempo or Honeycomb.export const provider = new NodeTracerProvider({ spanProcessors: [new SimpleSpanProcessor(new ConsoleSpanExporter())],});provider.register();Then extend your clients. This sends on a local Anvil chain:
import { provider } from './telemetry.js';import { trace } from '@opentelemetry/api';import { createPublicClient, createWalletClient, http, parseEther } from 'viem';import { privateKeyToAccount } from 'viem/accounts';import { foundry } from 'viem/chains';import { traceTransport, withHashspan } from '@hashspan/viem';
// Anvil's first test account. Its key is public: never use it on a real chain.const account = privateKeyToAccount( '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',);const to = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8';
const hashspan = withHashspan();const wallet = createWalletClient({ account, chain: foundry, transport: traceTransport(http()), // one span per JSON-RPC request}).extend(hashspan);const reader = createPublicClient({ chain: foundry, transport: http() }).extend(hashspan);
await trace.getTracer('my-app').startActiveSpan('pay invoice', async (span) => { const hash = await wallet.sendTransaction({ to, value: parseEther('0.01') }); // send span await reader.waitForTransactionReceipt({ hash }); // confirm span span.end();});
await hashspan.flush();await provider.shutdown();Run anvil in one terminal and npx tsx send.ts in another. Reuse one withHashspan() result for every client, so each confirm span links to its send span. flush() waits for spans that end after your code moved on; long-running services don’t need it.
What does a viem transaction look like as a trace?
Section titled “What does a viem transaction look like as a trace?”From that run (@hashspan/viem 1.1.1, viem 2.57.7, Anvil 1.8.3):
pay invoice├─ send 31337 (76 ms)│ ├─ eth_fillTransaction (61 ms)│ └─ eth_sendRawTransaction (7 ms)├─ eth_getTransactionByHash (3 ms)└─ confirm 31337 (9 ms), links to send blockchain.tx.status = success blockchain.block.number = 1 blockchain.tx.gas.used = 21000 blockchain.tx.fee = 42000000000000The spans nest under whatever span is active when you send: here pay invoice, in an agent the framework’s tool call span. The confirm span is a sibling of the send span and points back to it with a span link.
| Span | Main attributes |
|---|---|
send 31337 |
blockchain.tx.hash, .from, .to, .value |
confirm 31337 |
blockchain.tx.status, blockchain.block.number, blockchain.tx.gas.used, .effective_gas_price, .fee |
eth_* |
rpc.method, rpc.system.name jsonrpc, server.address, blockchain.chain.id |
JSON-RPC spans record no parameters or results, and of the URL only host and port. The eth_getTransactionByHash comes from hashspan: it reads the nonce of a send that passed none, off your call’s path, so a replacement can be found later. Every name is listed in the semantic conventions.
What happens when a writeContract reverts?
Section titled “What happens when a writeContract reverts?”A reverted receipt is a value, not an exception, so your code and its parent span carry on as if nothing failed. The confirm span doesn’t. For a reverted transaction, hashspan replays the call to read the reason:
const hash = await wallet.writeContract({ address: vault, abi: vaultAbi, functionName: 'withdraw', args: [250n], gas: 100_000n, // skip the estimate, so the revert happens on chain});await reader.waitForTransactionReceipt({ hash });In the revert run, the send span recorded blockchain.contract.function.name withdraw and its selector 0x2e1a7d4d. The confirm span ended with error status:
confirm 31337 ✕ error blockchain.tx.status = reverted blockchain.tx.revert.reason = WithdrawalLimitExceeded(100, 250) error.type = revertedThe withdraw span that sent it had no error. Custom errors decode when the ABI is known, as with writeContract. Turn the replay off with withHashspan({ decodeRevertReason: false }). The transaction lifecycle explains why a mined transaction can still fail.
What if my code never calls waitForTransactionReceipt?
Section titled “What if my code never calls waitForTransactionReceipt?”Many agent frameworks send and return the hash. Background confirmation gives every sent transaction a confirm span anyway:
const hashspan = withHashspan({ confirm: { mode: 'background', timeoutMs: 60_000 } });const wallet = createWalletClient({ account, chain, transport: http() }).extend(hashspan);
await wallet.sendTransaction({ to, value }); // no wait in your codeThe adapter polls for the receipt through the sending client and doesn’t delay the send. In the run, the confirm span (blockchain.tx.status success, block 5) landed under the same parent as the send span. Polling adds one receipt request per polling interval, and at most 256 background confirmations poll at once by default (maxBackgroundConfirmations).
Which viem actions are traced?
Section titled “Which viem actions are traced?”| Action | Spans |
|---|---|
sendTransaction, writeContract |
send |
sendRawTransaction |
send, without the sender, which only the signature gives |
sendTransactionSync, writeContractSync, sendRawTransactionSync |
send and confirm |
waitForTransactionReceipt |
confirm |
sendUserOperation, waitForUserOperationReceipt (bundler client) |
send and confirm of an ERC-4337 user operation |
sendCalls, waitForCallsStatus, sendCallsSync |
send and confirm of an EIP-5792 call batch |
deployContract isn’t traced yet (#36). Apply withHashspan() after extensions such as publicActions, which define waitForTransactionReceipt again. Smart account SDKs and the full option list are in the viem adapter reference.
Can I write viem tracing by hand?
Section titled “Can I write viem tracing by hand?”You can. The usual pattern wraps the transport in a span per request, or overrides sendTransaction with client.extend():
.extend((client) => ({ sendTransaction: (args) => tracer.startActiveSpan('sendTransaction', async (span) => { try { return await client.sendTransaction(args); } finally { span.end(); } }),}))That gives you the duration of the call. The span ends when the hash comes back, before anyone reads the receipt. In the run, the receipt said success and no span recorded it. What a send span alone can’t tell you:
- The outcome. Mined, reverted or never mined is only in the receipt, which arrives later and often in another function.
- Replacement. When a sped-up or cancelled transaction replaces yours, viem resolves the wait with another hash’s receipt. hashspan ends the awaited hash as
replacedand records the receipt on the hash that was mined. - Fees. Gas used, effective gas price and, on OP Stack chains, the L1 data fee, taken from the sealed receipt.
- The revert reason. A receipt only says
reverted. The reason needs a replay at the previous block’s state.
Write it yourself if a span around the call is all you need. If you want the outcome in the trace, the adapter already handles those cases.
Does viem have built-in OpenTelemetry support?
viem doesn't ship OpenTelemetry instrumentation itself. @hashspan/viem adds it as a client extension, so your calls and their results stay unchanged.
Does it work with OTLP, Jaeger or Grafana Tempo?
Yes. hashspan only uses the OpenTelemetry API (@opentelemetry/api), so spans go wherever your SDK exports them: an OTLP endpoint, Jaeger, Grafana Tempo, Honeycomb or Langfuse.
Does it record private keys or call arguments?
No keys or signatures. Function arguments are off by default and need recordFunctionArguments: true. JSON-RPC spans keep no parameters, results or URL path.
Does it work with ethers or web3.js?
No. hashspan has adapters for viem, the Coinbase CDP SDK, x402 and MPP (mppx). Another send path can use @hashspan/core, the tracker behind every adapter, directly.
Does tracing slow down viem transactions?
Slightly. On a local chain, @hashspan/viem 1.3.1 added about 0.25 ms at p50 to a send plus receipt wait, about 0.5 ms when viem polls for the receipt, and one eth_getTransactionByHash per send, none when you pass nonce. The tracing overhead benchmark has the method, the data and the caveats.
Which Node.js and viem versions does it need?
Node.js 22.3 or later and viem 2.21.0 or later (the package's peer range). The run on this page used viem 2.57.7.
How much tracing adds per transaction, with the method and data: tracing overhead benchmark. Never sent a transaction before? The x402 tutorial walks through one from an empty wallet. The Quickstart sets up OTLP and Jaeger, and tracing backends covers Tempo, Honeycomb and Langfuse. For a send path without an adapter, see @hashspan/core. The adapter is open source: selimaytac/hashspan. Questions and bug reports go to its issues.