Skip to content

Commit 27f6406

Browse files
authored
Merge pull request #3922 from jooby-project/oteljsonrpc
feat(jsonrpc): implement middleware pipeline and OpenTelemetry tracing
2 parents b9cd684 + 0f86623 commit 27f6406

22 files changed

Lines changed: 926 additions & 247 deletions

File tree

docs/asciidoc/modules/json-rpc.adoc

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,90 @@ Supported engines include:
105105

106106
No additional configuration is required. The generated dispatcher automatically hooks into the installed engine using the `JsonRpcParser` and `JsonRpcDecoder` interfaces, ensuring primitive types are strictly validated and parsed.
107107

108+
=== Middleware Pipeline
109+
110+
Jooby provides a dedicated middleware architecture for JSON-RPC using the `JsonRpcInvoker` and `JsonRpcChain` APIs. This allows you to intercept RPC calls to apply cross-cutting concerns like logging, security, metrics, or tracing.
111+
112+
To create an interceptor, implement the `JsonRpcInvoker` interface.
113+
114+
.JSON-RPC
115+
[source,java,role="primary"]
116+
----
117+
import io.jooby.jsonrpc.*;
118+
import java.util.Optional;
119+
120+
public class LoggingInvoker implements JsonRpcInvoker {
121+
122+
@Override
123+
public Optional<JsonRpcResponse> invoke(Context ctx, JsonRpcRequest request, JsonRpcChain next) {
124+
long start = System.currentTimeMillis();
125+
126+
// Proceed down the chain
127+
Optional<JsonRpcResponse> response = next.proceed(ctx, request);
128+
129+
long took = System.currentTimeMillis() - start;
130+
131+
// Inspect the response
132+
response.ifPresent(res -> {
133+
if (res.getError() != null) {
134+
ctx.getLog().warn("RPC {} failed in {}ms", request.getMethod(), took);
135+
} else {
136+
ctx.getLog().info("RPC {} succeeded in {}ms", request.getMethod(), took);
137+
}
138+
});
139+
140+
return response;
141+
}
142+
}
143+
----
144+
145+
.Kotlin
146+
[source,kotlin,role="secondary"]
147+
----
148+
import io.jooby.jsonrpc.*
149+
import java.util.Optional
150+
151+
class LoggingInvoker : JsonRpcInvoker {
152+
153+
override fun invoke(ctx: Context, request: JsonRpcRequest, next: JsonRpcChain): Optional<JsonRpcResponse> {
154+
val start = System.currentTimeMillis()
155+
156+
// Proceed down the chain
157+
val response = next.proceed(ctx, request)
158+
159+
val took = System.currentTimeMillis() - start
160+
161+
// Inspect the response
162+
response.ifPresent { res ->
163+
if (res.error != null) {
164+
ctx.log.warn("RPC {} failed in {}ms", request.method, took)
165+
} else {
166+
ctx.log.info("RPC {} succeeded in {}ms", request.method, took)
167+
}
168+
}
169+
170+
return response
171+
}
172+
}
173+
----
174+
175+
You register invokers fluently when installing the `JsonRpcModule`. You can chain multiple invokers together, and they will execute in the order they are added.
176+
177+
[source,java]
178+
----
179+
install(new JsonRpcModule(new MovieServiceRpc_())
180+
.invoker(new SecurityInvoker())
181+
.invoker(new LoggingInvoker()));
182+
----
183+
184+
==== Safe Exception Handling
185+
Notice that you **do not** need to wrap `next.proceed()` in a `try-catch` block. The final executor in the JSON-RPC pipeline acts as an ultimate safety net. It catches all unhandled exceptions, protocol failures (like Parse Errors), and routing failures, safely transforming them into a standard `JsonRpcResponse` containing an `ErrorDetail`.
186+
187+
To react to failures in your middleware, simply inspect `response.get().getError() != null`.
188+
189+
==== Notifications and Optional Responses
190+
The invocation pipeline returns an `Optional<JsonRpcResponse>`. This is because the JSON-RPC 2.0 specification explicitly dictates that **Notifications** (requests sent without an `id` member) must not receive a response. For these requests, the chain will safely execute the target method but return `Optional.empty()`.
191+
108192
=== Error Mapping
109193

110194
Jooby seamlessly bridges standard Java application exceptions and HTTP status codes into the JSON-RPC 2.0 format using the `JsonRpcErrorCode` mapping. You do not need to throw custom protocol exceptions for standard failures.

docs/asciidoc/modules/opentelemetry.adoc

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,46 @@ import io.jooby.opentelemetry.instrumentation.OtelHikari
284284
}
285285
----
286286

287+
==== JSON-RPC
288+
289+
Provides automatic tracing for your JSON-RPC 2.0 endpoints. By adding the `OtelJsonRcpTracing` middleware to your JSON-RPC pipeline, it generates a dedicated OpenTelemetry span for every RPC invocation.
290+
291+
It automatically records standard semantic attributes (such as `rpc.system`, `rpc.method`, and `rpc.jsonrpc.request_id`). Furthermore, because it hooks directly into the `JsonRpcChain`, it accurately records protocol errors and application failures by inspecting the `JsonRpcResponse` envelope, without relying on thrown exceptions.
292+
293+
.JSON-RPC Integration
294+
[source, java, role = "primary"]
295+
----
296+
import io.jooby.jsonrpc.JsonRpcModule;
297+
import io.jooby.jsonrpc.instrumentation.OtelJsonRcpTracing;
298+
import io.opentelemetry.api.OpenTelemetry;
299+
300+
{
301+
install(new OtelModule());
302+
303+
// Register the JSON-RPC module and attach the tracing middleware
304+
install(new JsonRpcModule(new MovieServiceRpc_())
305+
.invoker(new OtelJsonRcpTracing(require(OpenTelemetry.class)))
306+
);
307+
}
308+
----
309+
310+
.Kotlin
311+
[source, kt, role="secondary"]
312+
----
313+
import io.jooby.jsonrpc.JsonRpcModule
314+
import io.jooby.jsonrpc.instrumentation.OtelJsonRcpTracing
315+
import io.opentelemetry.api.OpenTelemetry
316+
317+
{
318+
install(OtelModule())
319+
320+
// Register the JSON-RPC module and attach the tracing middleware
321+
install(JsonRpcModule(MovieServiceRpc_())
322+
.invoker(OtelJsonRcpTracing(require(OpenTelemetry::class.java)))
323+
)
324+
}
325+
----
326+
287327
==== Log4j2
288328

289329
Seamlessly exports all application logs to your OpenTelemetry backend, automatically correlated with active trace and span IDs using a dynamic appender.

modules/jooby-jsonrpc-avaje-jsonb/src/main/java/io/jooby/internal/jsonrpc/avaje/jsonb/AvajeJsonRpcRequestAdapter.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public JsonRpcRequest fromJson(JsonReader reader) {
3434
JsonRpcRequest invalid = new JsonRpcRequest();
3535
invalid.setMethod(null);
3636
invalid.setBatch(false);
37+
invalid.setJsonrpc(null);
3738
return invalid;
3839
}
3940

@@ -67,10 +68,11 @@ private JsonRpcRequest parseSingle(Object node) {
6768

6869
// 2. Validate JSON-RPC version
6970
Object versionVal = map.get("jsonrpc");
70-
if (!"2.0".equals(versionVal)) {
71+
if (!JsonRpcRequest.JSONRPC.equals(versionVal)) {
7172
req.setMethod(null);
7273
return req;
7374
}
75+
req.setJsonrpc(JsonRpcRequest.JSONRPC);
7476

7577
// 3. Extract Method
7678
Object methodVal = map.get("method");

modules/jooby-jsonrpc-jackson2/src/main/java/io/jooby/internal/jsonrpc/jackson2/JacksonJsonRpcRequestDeserializer.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,13 @@ private JsonRpcRequest parseSingle(JsonNode node) {
6666

6767
// 2. Validate JSON-RPC version
6868
JsonNode versionNode = node.get("jsonrpc");
69-
if (versionNode == null || !versionNode.isTextual() || !"2.0".equals(versionNode.asText())) {
69+
if (versionNode == null
70+
|| !versionNode.isTextual()
71+
|| !JsonRpcRequest.JSONRPC.equals(versionNode.asText())) {
7072
req.setMethod(null); // Triggers -32600 Invalid Request
7173
return req;
7274
}
75+
req.setJsonrpc(JsonRpcRequest.JSONRPC);
7376

7477
// 3. Extract Method
7578
JsonNode methodNode = node.get("method");

modules/jooby-jsonrpc-jackson3/src/main/java/io/jooby/internal/jsonrpc/jackson3/JacksonJsonRpcRequestDeserializer.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public JsonRpcRequest deserialize(JsonParser p, DeserializationContext ctxt) {
3030
JsonRpcRequest invalid = new JsonRpcRequest();
3131
invalid.setMethod(null); // Acts as a flag for Invalid Request
3232
invalid.setBatch(false); // Force single return shape
33+
invalid.setJsonrpc(null);
3334
return invalid;
3435
}
3536

@@ -63,10 +64,13 @@ private JsonRpcRequest parseSingle(JsonNode node) {
6364

6465
// 2. Validate JSON-RPC version
6566
JsonNode versionNode = node.get("jsonrpc");
66-
if (versionNode == null || !versionNode.isString() || !"2.0".equals(versionNode.asString())) {
67+
if (versionNode == null
68+
|| !versionNode.isString()
69+
|| !JsonRpcRequest.JSONRPC.equals(versionNode.asString())) {
6770
req.setMethod(null); // Triggers -32600 Invalid Request
6871
return req;
6972
}
73+
req.setJsonrpc(JsonRpcRequest.JSONRPC);
7074

7175
// 3. Extract Method
7276
JsonNode methodNode = node.get("method");

modules/jooby-jsonrpc/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,11 @@
1919
<version>${jooby.version}</version>
2020
</dependency>
2121

22+
<dependency>
23+
<groupId>io.opentelemetry</groupId>
24+
<artifactId>opentelemetry-api</artifactId>
25+
<version>${opentelemetry.version}</version>
26+
<optional>true</optional>
27+
</dependency>
2228
</dependencies>
2329
</project>
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.internal.jsonrpc;
7+
8+
import java.util.Map;
9+
import java.util.Optional;
10+
11+
import org.jspecify.annotations.NonNull;
12+
import org.slf4j.Logger;
13+
14+
import io.jooby.Context;
15+
import io.jooby.Reified;
16+
import io.jooby.SneakyThrows;
17+
import io.jooby.jsonrpc.*;
18+
19+
/**
20+
* The internal execution engine and "final invoker" for JSON-RPC requests.
21+
*
22+
* <p>This class acts as the terminal end of the {@link JsonRpcChain}. It is responsible for the
23+
* final stages of the JSON-RPC lifecycle:
24+
*
25+
* <ul>
26+
* <li>Validating the parsed request envelope.
27+
* <li>Routing the request to the appropriate {@link JsonRpcService}.
28+
* <li>Executing the target method.
29+
* <li>Acting as the ultimate safety net by catching all exceptions and translating them into
30+
* compliant {@link JsonRpcResponse} objects.
31+
* </ul>
32+
*/
33+
public class JsonRpcExecutor implements JsonRpcChain {
34+
private final Map<String, JsonRpcService> services;
35+
private final Map<Class<?>, Logger> loggers;
36+
private final Exception parseError;
37+
38+
/**
39+
* Constructs a new executor for a single JSON-RPC request.
40+
*
41+
* @param services A map of registered JSON-RPC services keyed by method name.
42+
* @param loggers A map of service loggers keyed by service class.
43+
* @param parseError Any exception that occurred during the initial JSON parsing phase.
44+
*/
45+
public JsonRpcExecutor(
46+
Map<String, JsonRpcService> services, Map<Class<?>, Logger> loggers, Exception parseError) {
47+
this.services = services;
48+
this.loggers = loggers;
49+
this.parseError = parseError;
50+
}
51+
52+
/**
53+
* Executes the JSON-RPC request and returns an optional response.
54+
*
55+
* <p>This method adheres strictly to the JSON-RPC 2.0 specification regarding error handling and
56+
* response generation. It will return {@link Optional#empty()} for Notifications, unless a
57+
* fundamental Parse Error or Invalid Request error occurs, which always require a response.
58+
*
59+
* @param ctx The current HTTP context passed down the chain.
60+
* @param request The incoming JSON-RPC request passed down the chain.
61+
* @return An Optional containing the JSON-RPC response, or empty if the request was a valid
62+
* Notification.
63+
*/
64+
@Override
65+
public @NonNull Optional<JsonRpcResponse> proceed(
66+
@NonNull Context ctx, @NonNull JsonRpcRequest request) {
67+
var log = loggers.get(JsonRpcService.class);
68+
try {
69+
if (parseError != null) {
70+
throw new JsonRpcException(JsonRpcErrorCode.PARSE_ERROR, parseError);
71+
}
72+
if (!request.isValid()) {
73+
throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "Invalid JSON-RPC request");
74+
}
75+
var fullMethod = request.getMethod();
76+
var targetService = services.get(fullMethod);
77+
if (targetService != null) {
78+
log = loggers.get(targetService.getClass());
79+
var result = targetService.execute(ctx, request);
80+
return request.getId() != null
81+
? Optional.of(JsonRpcResponse.success(request.getId(), result))
82+
: Optional.empty();
83+
}
84+
if (request.getId() == null) {
85+
return Optional.empty();
86+
}
87+
throw new JsonRpcException(
88+
JsonRpcErrorCode.METHOD_NOT_FOUND, "Method not found: " + fullMethod);
89+
} catch (Throwable cause) {
90+
return toRpcResponse(ctx, log, request, cause);
91+
}
92+
}
93+
94+
private Optional<JsonRpcResponse> toRpcResponse(
95+
Context ctx, Logger log, JsonRpcRequest request, Throwable ex) {
96+
var code = toErrorCode(ctx, ex);
97+
log(log, request, code, ex);
98+
99+
if (SneakyThrows.isFatal(ex)) {
100+
throw SneakyThrows.propagate(ex);
101+
} else if (ex.getCause() != null && SneakyThrows.isFatal(ex.getCause())) {
102+
throw SneakyThrows.propagate(ex.getCause());
103+
}
104+
105+
if (request.getId() != null) {
106+
return Optional.of(JsonRpcResponse.error(request.getId(), code, ex));
107+
} else if (code == JsonRpcErrorCode.PARSE_ERROR || code == JsonRpcErrorCode.INVALID_REQUEST) {
108+
// must return a valid response even if the request is invalid
109+
return Optional.of(JsonRpcResponse.error(null, code, ex));
110+
}
111+
return Optional.empty();
112+
}
113+
114+
/**
115+
* Logs JSON-RPC errors adaptively based on the error code.
116+
*
117+
* <p>Internal server errors are logged as standard errors. Authorization and routing errors are
118+
* logged at debug level to prevent log flooding. Other application errors are logged as warnings.
119+
*
120+
* @param log The logger instance to use.
121+
* @param request The request that triggered the error.
122+
* @param code The error code.
123+
* @param cause The underlying exception.
124+
*/
125+
private void log(Logger log, JsonRpcRequest request, JsonRpcErrorCode code, Throwable cause) {
126+
var type = code == JsonRpcErrorCode.INTERNAL_ERROR ? "server" : "client";
127+
var message = "JSON-RPC {} error [{} {}] on method '{}' (id: {})";
128+
switch (code) {
129+
case INTERNAL_ERROR ->
130+
log.error(
131+
message,
132+
type,
133+
code.getCode(),
134+
code.getMessage(),
135+
request.getMethod(),
136+
request.getId(),
137+
cause);
138+
case UNAUTHORIZED, FORBIDDEN, NOT_FOUND_ERROR ->
139+
log.debug(
140+
message,
141+
type,
142+
code.getCode(),
143+
code.getMessage(),
144+
request.getMethod(),
145+
request.getId(),
146+
cause);
147+
default -> {
148+
if (cause instanceof JsonRpcException) {
149+
log.warn(
150+
message,
151+
type,
152+
code.getCode(),
153+
code.getMessage(),
154+
request.getMethod(),
155+
request.getId());
156+
} else {
157+
log.warn(
158+
message,
159+
type,
160+
code.getCode(),
161+
code.getMessage(),
162+
request.getMethod(),
163+
request.getId(),
164+
cause);
165+
}
166+
}
167+
}
168+
}
169+
170+
public JsonRpcErrorCode toErrorCode(Context ctx, Throwable cause) {
171+
if (cause instanceof JsonRpcException rpcException) {
172+
return rpcException.getCode();
173+
}
174+
// Attempt to look up any user-defined exception mappings from the registry
175+
Map<Class<?>, JsonRpcErrorCode> customErrorMapping =
176+
ctx.require(Reified.map(Class.class, JsonRpcErrorCode.class));
177+
return customErrorMapping.entrySet().stream()
178+
.filter(entry -> entry.getKey().isInstance(cause))
179+
.findFirst()
180+
.map(Map.Entry::getValue)
181+
.orElseGet(() -> JsonRpcErrorCode.of(ctx.getRouter().errorCode(cause)));
182+
}
183+
}

0 commit comments

Comments
 (0)