Skip to content
Star

viem OpenTelemetry instrumentation

View as MarkdownOpen in ClaudeOpen in ChatGPT

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: send when the hash comes back, confirm when the receipt does, and one span per JSON-RPC request with traceTransport().
  • 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.

Install the adapter next to viem and the OpenTelemetry API:

Terminal window
npm install @hashspan/viem @opentelemetry/api viem

Any OpenTelemetry SDK setup works. To see spans in the terminal first, this one prints them:

Terminal window
npm install @opentelemetry/sdk-trace-node @opentelemetry/sdk-trace-base
telemetry.ts
import { 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:

send.ts
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 = 42000000000000

The 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 = reverted

The 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 code

The 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).

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.

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 replaced and 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.