Skip to content

Enable tracing support for RPC stream#219

Merged
fracek merged 3 commits into
mainfrom
fra/tracing
May 4, 2026
Merged

Enable tracing support for RPC stream#219
fracek merged 3 commits into
mainfrom
fra/tracing

Conversation

@fracek

@fracek fracek commented May 4, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@coderabbitai

coderabbitai Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR adds comprehensive OpenTelemetry tracing to the Apibara SDK. It introduces tracer factories across packages, instruments core RPC and data streaming functions with spans, adds Docker-based local tracing infrastructure (Collector and Jaeger), updates dependencies, and refactors tests to validate span emission instead of result equality.

Changes

OpenTelemetry Tracing Infrastructure

Layer / File(s) Summary
Infrastructure & Dependencies
develop/docker-compose.yml, develop/otel-collector-config.yml, examples/evm-client/package.json, packages/evm-rpc/package.json, packages/protocol/package.json
Docker Compose now defines otel-collector (OpenTelemetry Collector v0.121.0) and jaeger (v1.66.0) services with OTLP gRPC/HTTP receivers and Jaeger UI on port 16686. OpenTelemetry packages (@opentelemetry/api, sdk-trace-*, exporter-trace-otlp-grpc) added to evm-rpc, protocol, and example dependencies.
Tracer Factory
packages/evm-rpc/src/otel.ts, packages/protocol/src/rpc/otel.ts
New createTracer() functions return tracer instances scoped to @apibara/evm-rpc and @apibara/protocol respectively, using OpenTelemetry's trace.getTracer() API.
EVM-RPC Core Instrumentation
packages/evm-rpc/src/log-fetcher.ts
fetchLogsForRange, mergedGetLogsCalls, and standardGetLogsCalls are wrapped in startActiveSpan, recording input attributes (fromBlock, toBlock, mergeGetLogs, filter counts), result metrics (blockCount, logCount, call counts), and exceptions in finally.
Stream Config Instrumentation
packages/evm-rpc/src/stream-config.ts
Five RPC methods (fetchCursor, fetchCursorRange, fetchBlockRange, fetchHeaderByHash, fetchBlockHeaderByNumberWithRetry) are instrumented with active spans, input/output attributes, exception recording, and guaranteed span termination while preserving existing control flow and retry behavior.
Protocol Data Stream Instrumentation
packages/protocol/src/rpc/data-stream.ts
dataStreamLoop per-iteration logic refactored into startActiveSpan callback; constructs attributes map (cursor, head, finalized keys, action flags), accumulates messages in array before yielding, records exceptions, and ensures span cleanup.
Test Infrastructure & Refactoring
packages/evm-rpc/tests/otel-helper.ts, packages/evm-rpc/tests/log-fetcher.test.ts
New setupTestTracer(), resetSpans(), and getFinishedSpans() helpers manage in-memory span collection via BasicTracerProvider and InMemorySpanExporter. Log-fetcher test cases refactored from result-equality assertions to span attribute validation, checking for expected outer/nested spans and their attributes (e.g., mergeGetLogs, filter.logs.length, result.* counts).
Example Integration
examples/evm-client/src/main-rpc.ts
New --trace CLI argument conditionally initializes NodeTracerProvider, OTLPTraceExporter, and BatchSpanProcessor, registering the provider globally when tracing is enabled.
Change Metadata
change/@apibara-evm-rpc-*.json, change/@apibara-protocol-*.json
Patch-level change entries document "evm-rpc: add traces" and "protocol: add tracing" updates.

Sequence Diagram

sequenceDiagram
    participant App as Application
    participant Tracer as OpenTelemetry Tracer
    participant Collector as OTLP Collector
    participant Jaeger as Jaeger UI

    App->>Tracer: createTracer()
    activate Tracer
    Tracer->>App: tracer instance
    deactivate Tracer

    App->>Tracer: startActiveSpan("operation")
    activate Tracer
    Tracer->>Tracer: create span
    Tracer->>App: span
    
    App->>App: set span attributes
    App->>App: perform RPC call
    App->>Tracer: span.recordException (on error)
    
    App->>Tracer: span.end()
    Tracer->>Collector: export span (OTLP gRPC/HTTP)
    deactivate Tracer

    activate Collector
    Collector->>Jaeger: forward trace
    deactivate Collector
    
    activate Jaeger
    Jaeger->>Jaeger: visualize trace
    deactivate Jaeger
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • protocol: rpc stream client #192: Main PR directly instruments the exact modules and functions added/used in this PR (packages/evm-rpc/src/stream-config.ts, log-fetcher.ts, packages/protocol/src/rpc/data-stream.ts).
  • Enable debug logs for RPC stream #206: Both PRs modify packages/protocol/src/rpc/data-stream.ts and packages/evm-rpc/src/stream-config.ts, adding observability via spans versus debug logs to the same code paths.
  • Reduce the number of RPC calls #203: Both PRs modify packages/evm-rpc/src/stream-config.ts in the same fetchCursorRange and fetchBlockRange methods; one adds tracing, the other modifies behavior/signatures.

Poem

🐰 A rabbit's trace through streams so bright,
Spans emitted into the night,
Jaeger shines on every call,
Now the EVM's watched by all!
Telemetry hops without a fright. 🌟

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive No description was provided by the author, making it impossible to assess relevance to the changeset. Add a pull request description explaining the tracing implementation, its purpose, and any relevant testing or migration notes.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding OpenTelemetry tracing support to RPC stream operations across multiple packages.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fra/tracing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
packages/evm-rpc/tests/otel-helper.ts (1)

8-18: 💤 Low value

Consider exporting a teardown function to guard against duplicate registration.

trace.setGlobalTracerProvider throws "Attempted duplicate registration of API: trace" on a second invocation in the same process. If setupTestTracer() is ever called more than once in the same Vitest worker (e.g., nested describe + beforeAll, or if isolate is disabled), the second call throws; the module-level exporter gets replaced with a fresh instance that never receives spans, so getFinishedSpans() silently returns [].

Vitest's default per-file worker isolation prevents this today, but an exported teardown makes the contract explicit:

♻️ Suggested teardown export
 export function setupTestTracer() {
   exporter = new InMemorySpanExporter();
   provider = new BasicTracerProvider();
   provider.addSpanProcessor(new SimpleSpanProcessor(exporter));
   trace.setGlobalTracerProvider(provider);
 
   return { exporter, provider };
 }
+
+export async function teardownTestTracer() {
+  await provider?.shutdown();
+  provider = undefined;
+  exporter = undefined;
+}

Consumers call this in afterAll:

afterAll(() => teardownTestTracer());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/evm-rpc/tests/otel-helper.ts` around lines 8 - 18, Add an exported
teardown function to avoid duplicate registration of the global tracer: when
setupTestTracer() constructs a new BasicTracerProvider and calls
trace.setGlobalTracerProvider, provide a teardownTestTracer() that resets or
unregisters the provider and clears the module-level exporter/provider variables
so subsequent setupTestTracer() calls won’t call trace.setGlobalTracerProvider
twice; ensure teardownTestTracer() stops and shuts down the provider (or calls
provider.shutdown/forceFlush if available), sets exporter and provider to
undefined, and document callers should invoke teardownTestTracer() in afterAll
to prevent the "Attempted duplicate registration of API: trace" error and lost
spans from exporter.
examples/evm-client/src/main-rpc.ts (1)

59-61: 💤 Low value

trace.setGlobalTracerProvider(provider) is redundant after provider.register().

NodeTracerProvider.register() already calls trace.setGlobalTracerProvider(this) internally (plus sets up the context manager and propagator). The explicit call on Line 61 is therefore a no-op at best and, because @opentelemetry/api guards against duplicate registration, it can emit an "Attempted duplicate registration of API: trace" error at runtime.

🧹 Suggested cleanup
      provider.addSpanProcessor(new BatchSpanProcessor(exporter));
      provider.register();

-     trace.setGlobalTracerProvider(provider);
-
      consola.info("OpenTelemetry tracing enabled:", args.trace);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/evm-client/src/main-rpc.ts` around lines 59 - 61, The call to
trace.setGlobalTracerProvider(provider) is redundant and can cause
duplicate-registration errors because provider.register() already sets the
global tracer; remove the explicit trace.setGlobalTracerProvider(provider)
invocation (i.e., delete the line that calls
trace.setGlobalTracerProvider(provider)) and keep only provider.register() to
register the NodeTracerProvider.
packages/protocol/src/rpc/data-stream.ts (1)

124-206: ⚡ Quick win

Span attributes are never set on error paths — error spans will have zero context.

The attributes object captures cursor, head, finalized, and the actionXxx flags, but span.setAttributes(attributes) is only called at Line 197, which is on the success path. If any awaited operation inside the try block throws (e.g., backfillFinalizedBlocks, fetchCursor), the span records the exception but has no attributes — making it impossible to tell which cursor position, head, or finalized block the stream was at when the error occurred.

Setting the stable, upfront context attributes immediately after the span is created (before the first await) resolves this:

🔧 Suggested fix
     const messages = await tracer.startActiveSpan(
       "data-stream.loop",
       async (span) => {
         try {
           const { cursor, chainTracker } = state;

-          const attributes: Record<string, string | number | boolean> = {
-            cursor: cursor.orderKey.toString(),
-            head: chainTracker.head().orderKey.toString(),
-            finalized: chainTracker.finalized().orderKey.toString(),
-            actionSendHeartbeat: false,
-            actionRefreshFinalized: false,
-            actionBackfillFinalized: false,
-            actionWaitForHeadChange: false,
-            actionProduceLiveBlocks: false,
-          };
+          // Set positional context eagerly so error spans still carry it.
+          span.setAttributes({
+            cursor: cursor.orderKey.toString(),
+            head: chainTracker.head().orderKey.toString(),
+            finalized: chainTracker.finalized().orderKey.toString(),
+          });

           const messages: StreamDataResponse<TBlock>[] = [];

           if (shouldSendHeartbeat(state)) {
             state.lastHeartbeat = Date.now();
-            attributes.actionSendHeartbeat = true;
+            span.setAttribute("actionSendHeartbeat", true);
             messages.push({ _tag: "heartbeat" });
           }

           if (shouldRefreshFinalized(state)) {
-            attributes.actionRefreshFinalized = true;
+            span.setAttribute("actionRefreshFinalized", true);
             // ...existing finalized-refresh logic...
           }

           // ...
           if (cursor.orderKey < finalized.orderKey) {
-            attributes.actionBackfillFinalized = true;
+            span.setAttribute("actionBackfillFinalized", true);
             // ...
           } else {
             if (isAtHead(state)) {
-              attributes.actionWaitForHeadChange = true;
+              span.setAttribute("actionWaitForHeadChange", true);
               // ...
             } else {
-              attributes.actionProduceLiveBlocks = true;
+              span.setAttribute("actionProduceLiveBlocks", true);
               // ...
             }
           }

-          span.setAttributes(attributes);
           return messages;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/protocol/src/rpc/data-stream.ts` around lines 124 - 206, The span's
attributes are only set on the success path, so exceptions leave spans without
context; after you build the initial attributes object (the variable named
attributes that includes cursor, head, finalized and action flags) call
span.setAttributes(attributes) immediately (right after the attributes are
constructed and before any await) so the span always has the basic context even
if an awaited call throws; if you later mutate attributes (e.g., set actionXxx
flags) call span.setAttributes(attributes) again to update the span. Ensure this
change is made inside the tracer.startActiveSpan callback that uses span, state,
cursor and chainTracker.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@examples/evm-client/src/main-rpc.ts`:
- Around line 49-64: The NodeTracerProvider created in run (variable provider)
isn't shut down, so buffered spans from BatchSpanProcessor may be dropped;
update run to call provider.shutdown() (or provider?.shutdown()) when the
streaming loop ends and register process signal handlers (e.g., SIGTERM/SIGINT)
to await provider.shutdown() before exiting so spans are flushed; locate the
NodeTracerProvider instantiation and the BatchSpanProcessor usage to add the
shutdown calls and the signal handlers referencing provider in their callbacks.

In `@packages/evm-rpc/tests/log-fetcher.test.ts`:
- Around line 77-90: The test currently performs a no-op assertion by comparing
span.attributes.mergeGetLogs to itself; replace that with an explicit boolean
check per span so the two emitted spans are validated distinctly: retrieve spans
via getFinishedSpans(), then for each span assert span.attributes.mergeGetLogs
equals the expected value for that position (e.g., use an expected array like
[true, false] or check spans[0] and spans[1] individually) using
expect(span.attributes.mergeGetLogs).toBe(<expectedBoolean>), and keep the other
attribute assertions (fromBlock, toBlock, "filter.logs.length") unchanged.

---

Nitpick comments:
In `@examples/evm-client/src/main-rpc.ts`:
- Around line 59-61: The call to trace.setGlobalTracerProvider(provider) is
redundant and can cause duplicate-registration errors because
provider.register() already sets the global tracer; remove the explicit
trace.setGlobalTracerProvider(provider) invocation (i.e., delete the line that
calls trace.setGlobalTracerProvider(provider)) and keep only provider.register()
to register the NodeTracerProvider.

In `@packages/evm-rpc/tests/otel-helper.ts`:
- Around line 8-18: Add an exported teardown function to avoid duplicate
registration of the global tracer: when setupTestTracer() constructs a new
BasicTracerProvider and calls trace.setGlobalTracerProvider, provide a
teardownTestTracer() that resets or unregisters the provider and clears the
module-level exporter/provider variables so subsequent setupTestTracer() calls
won’t call trace.setGlobalTracerProvider twice; ensure teardownTestTracer()
stops and shuts down the provider (or calls provider.shutdown/forceFlush if
available), sets exporter and provider to undefined, and document callers should
invoke teardownTestTracer() in afterAll to prevent the "Attempted duplicate
registration of API: trace" error and lost spans from exporter.

In `@packages/protocol/src/rpc/data-stream.ts`:
- Around line 124-206: The span's attributes are only set on the success path,
so exceptions leave spans without context; after you build the initial
attributes object (the variable named attributes that includes cursor, head,
finalized and action flags) call span.setAttributes(attributes) immediately
(right after the attributes are constructed and before any await) so the span
always has the basic context even if an awaited call throws; if you later mutate
attributes (e.g., set actionXxx flags) call span.setAttributes(attributes) again
to update the span. Ensure this change is made inside the tracer.startActiveSpan
callback that uses span, state, cursor and chainTracker.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 50392970-8ba9-4696-b5a4-c9f7fc7058cb

📥 Commits

Reviewing files that changed from the base of the PR and between 2f7bed9 and 5b161c3.

⛔ Files ignored due to path filters (2)
  • packages/evm-rpc/tests/__snapshots__/log-fetcher.test.ts.snap is excluded by !**/*.snap
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (15)
  • change/@apibara-evm-rpc-e273dad4-6676-4dc1-b525-a8f8949cefd6.json
  • change/@apibara-protocol-7963b847-a986-44cc-a679-3c6a33da87a2.json
  • develop/docker-compose.yml
  • develop/otel-collector-config.yml
  • examples/evm-client/package.json
  • examples/evm-client/src/main-rpc.ts
  • packages/evm-rpc/package.json
  • packages/evm-rpc/src/log-fetcher.ts
  • packages/evm-rpc/src/otel.ts
  • packages/evm-rpc/src/stream-config.ts
  • packages/evm-rpc/tests/log-fetcher.test.ts
  • packages/evm-rpc/tests/otel-helper.ts
  • packages/protocol/package.json
  • packages/protocol/src/rpc/data-stream.ts
  • packages/protocol/src/rpc/otel.ts

Comment on lines 49 to +64
async run({ args }) {
if (args.trace) {
const provider = new NodeTracerProvider({
resource: new Resource({
"service.name": "example-evm-rpc-client",
}),
});

const exporter = new OTLPTraceExporter({ url: args.trace });
provider.addSpanProcessor(new BatchSpanProcessor(exporter));
provider.register();

trace.setGlobalTracerProvider(provider);

consola.info("OpenTelemetry tracing enabled:", args.trace);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Missing provider.shutdown() — buffered spans will be dropped on exit.

BatchSpanProcessor queues spans in memory and exports them in background batches. When the for await streaming loop finishes (or the process receives SIGTERM/SIGINT), Node.js exits without giving the processor a chance to flush, silently dropping any spans still in the buffer. This undermines the example's goal of demonstrating end-to-end trace visibility.

🔧 Suggested fix — flush on process exit
+    let tracerProvider: NodeTracerProvider | undefined;
     if (args.trace) {
-      const provider = new NodeTracerProvider({
+      tracerProvider = new NodeTracerProvider({
         resource: new Resource({
           "service.name": "example-evm-rpc-client",
         }),
       });

       const exporter = new OTLPTraceExporter({ url: args.trace });
-      provider.addSpanProcessor(new BatchSpanProcessor(exporter));
-      provider.register();
-
-      trace.setGlobalTracerProvider(provider);
+      tracerProvider.addSpanProcessor(new BatchSpanProcessor(exporter));
+      tracerProvider.register();

       consola.info("OpenTelemetry tracing enabled:", args.trace);
     }

Then at the end of run:

+    // Flush and shut down the tracer provider so buffered spans are exported.
+    await tracerProvider?.shutdown();

Or register a signal handler so Ctrl+C also flushes:

for (const sig of ["SIGTERM", "SIGINT"]) {
  process.once(sig, async () => {
    await tracerProvider?.shutdown();
    process.exit(0);
  });
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/evm-client/src/main-rpc.ts` around lines 49 - 64, The
NodeTracerProvider created in run (variable provider) isn't shut down, so
buffered spans from BatchSpanProcessor may be dropped; update run to call
provider.shutdown() (or provider?.shutdown()) when the streaming loop ends and
register process signal handlers (e.g., SIGTERM/SIGINT) to await
provider.shutdown() before exiting so spans are flushed; locate the
NodeTracerProvider instantiation and the BatchSpanProcessor usage to add the
shutdown calls and the signal handlers referencing provider in their callbacks.

Comment on lines +77 to +90
// Empty filter returns early so only the outer span is emitted.
const spans = getFinishedSpans();
expect(spans.map((s) => s.name)).toEqual([
"evm-rpc.fetchLogsForRange",
"evm-rpc.fetchLogsForRange",
]);
for (const span of spans) {
expect(span.attributes).toMatchObject({
fromBlock: fromBlock.toString(),
toBlock: toBlock.toString(),
mergeGetLogs: span.attributes.mergeGetLogs as boolean,
"filter.logs.length": 0,
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

mergeGetLogs assertion is a no-op — it compares the attribute to itself.

mergeGetLogs: span.attributes.mergeGetLogs as boolean,

This is equivalent to expect(x).toBe(x) and always passes regardless of the actual value, meaning neither false (standard call) nor true (merged call) is verified.

The test also relies on toMatchObject ordering within the loop rather than distinguishing the two spans, so both attribute values go unchecked. The pattern used in the sibling tests (lines 122–129) is the correct approach:

🔧 Suggested fix
-    for (const span of spans) {
-      expect(span.attributes).toMatchObject({
-        fromBlock: fromBlock.toString(),
-        toBlock: toBlock.toString(),
-        mergeGetLogs: span.attributes.mergeGetLogs as boolean,
-        "filter.logs.length": 0,
-      });
-    }
+    const standardSpan = spans.find(
+      (s) => s.name === "evm-rpc.fetchLogsForRange" && !s.attributes.mergeGetLogs,
+    );
+    const mergedSpan = spans.find(
+      (s) => s.name === "evm-rpc.fetchLogsForRange" && s.attributes.mergeGetLogs,
+    );
+
+    expect(standardSpan).toBeDefined();
+    expect(standardSpan!.attributes).toMatchObject({
+      fromBlock: fromBlock.toString(),
+      toBlock: toBlock.toString(),
+      mergeGetLogs: false,
+      "filter.logs.length": 0,
+    });
+
+    expect(mergedSpan).toBeDefined();
+    expect(mergedSpan!.attributes).toMatchObject({
+      fromBlock: fromBlock.toString(),
+      toBlock: toBlock.toString(),
+      mergeGetLogs: true,
+      "filter.logs.length": 0,
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/evm-rpc/tests/log-fetcher.test.ts` around lines 77 - 90, The test
currently performs a no-op assertion by comparing span.attributes.mergeGetLogs
to itself; replace that with an explicit boolean check per span so the two
emitted spans are validated distinctly: retrieve spans via getFinishedSpans(),
then for each span assert span.attributes.mergeGetLogs equals the expected value
for that position (e.g., use an expected array like [true, false] or check
spans[0] and spans[1] individually) using
expect(span.attributes.mergeGetLogs).toBe(<expectedBoolean>), and keep the other
attribute assertions (fromBlock, toBlock, "filter.logs.length") unchanged.

@fracek fracek merged commit 4bb7c79 into main May 4, 2026
2 checks passed
@fracek fracek deleted the fra/tracing branch May 4, 2026 10:06
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