From 9bd21172eaf43ef3a88a5c0c34cd10dd22b10309 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 18:41:20 +0000 Subject: [PATCH 1/8] CAMEL-24377: Add JSON support to CamelEvent - Add asJSon() and toJSon(int) to CamelEvent SPI - Implement structured JSON serialization in CamelEventJsonSupport - Wire JSON methods through abstract event base classes and service events - Use structured JSON in EventConsole instead of ad hoc toString fields - Add CamelEventJsonTest with coverage for context, exchange, route, step, service, and failure event types - Document JSON serialization in event-notifier.adoc Co-authored-by: Cursor Agent --- .../java/org/apache/camel/spi/CamelEvent.java | 18 ++ .../impl/event/AbstractContextEvent.java | 11 + .../impl/event/AbstractExchangeEvent.java | 11 + .../camel/impl/event/AbstractRouteEvent.java | 11 + .../impl/event/CamelEventJsonSupport.java | 193 ++++++++++++++++ .../event/ServiceStartupFailureEvent.java | 11 + .../impl/event/ServiceStopFailureEvent.java | 11 + .../camel/impl/console/EventConsole.java | 23 +- .../camel/impl/event/CamelEventJsonTest.java | 207 ++++++++++++++++++ .../modules/ROOT/pages/event-notifier.adoc | 17 ++ 10 files changed, 506 insertions(+), 7 deletions(-) create mode 100644 core/camel-base/src/main/java/org/apache/camel/impl/event/CamelEventJsonSupport.java create mode 100644 core/camel-core/src/test/java/org/apache/camel/impl/event/CamelEventJsonTest.java diff --git a/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java b/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java index 485ca15839a5b..4052e45d2020d 100644 --- a/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java +++ b/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java @@ -16,6 +16,8 @@ */ package org.apache.camel.spi; +import java.util.Map; + import org.apache.camel.CamelContext; import org.apache.camel.Endpoint; import org.apache.camel.Exchange; @@ -94,6 +96,22 @@ enum Type { void setTimestamp(long timestamp); + /** + * Dumps the full event as a pretty-printed JSON string. + * + * @param indent number of spaces to indent + * @return JSON representation of this event + * @since 4.23 + */ + String toJSon(int indent); + + /** + * The full event as a {@link Map} suitable for JSON serialization, containing structured metadata for this event. + * + * @since 4.23 + */ + Map asJSon(); + /** * This interface is implemented by all events that contain an exception and is used to retrieve the exception in a * universal way. diff --git a/core/camel-base/src/main/java/org/apache/camel/impl/event/AbstractContextEvent.java b/core/camel-base/src/main/java/org/apache/camel/impl/event/AbstractContextEvent.java index cf77d7be8fdd7..a35b45167a7b5 100644 --- a/core/camel-base/src/main/java/org/apache/camel/impl/event/AbstractContextEvent.java +++ b/core/camel-base/src/main/java/org/apache/camel/impl/event/AbstractContextEvent.java @@ -18,6 +18,7 @@ import java.io.Serial; import java.util.EventObject; +import java.util.Map; import org.apache.camel.CamelContext; import org.apache.camel.spi.CamelEvent.CamelContextEvent; @@ -49,4 +50,14 @@ public long getTimestamp() { public void setTimestamp(long timestamp) { this.timestamp = timestamp; } + + @Override + public Map asJSon() { + return CamelEventJsonSupport.asJSon(this); + } + + @Override + public String toJSon(int indent) { + return CamelEventJsonSupport.toJSon(this, indent); + } } diff --git a/core/camel-base/src/main/java/org/apache/camel/impl/event/AbstractExchangeEvent.java b/core/camel-base/src/main/java/org/apache/camel/impl/event/AbstractExchangeEvent.java index 7c7802d07da74..fa95aec838345 100644 --- a/core/camel-base/src/main/java/org/apache/camel/impl/event/AbstractExchangeEvent.java +++ b/core/camel-base/src/main/java/org/apache/camel/impl/event/AbstractExchangeEvent.java @@ -18,6 +18,7 @@ import java.io.Serial; import java.util.EventObject; +import java.util.Map; import org.apache.camel.Exchange; import org.apache.camel.spi.CamelEvent.ExchangeEvent; @@ -50,4 +51,14 @@ public void setTimestamp(long timestamp) { this.timestamp = timestamp; } + @Override + public Map asJSon() { + return CamelEventJsonSupport.asJSon(this); + } + + @Override + public String toJSon(int indent) { + return CamelEventJsonSupport.toJSon(this, indent); + } + } diff --git a/core/camel-base/src/main/java/org/apache/camel/impl/event/AbstractRouteEvent.java b/core/camel-base/src/main/java/org/apache/camel/impl/event/AbstractRouteEvent.java index c169c340316d0..68eb1d1cee38b 100644 --- a/core/camel-base/src/main/java/org/apache/camel/impl/event/AbstractRouteEvent.java +++ b/core/camel-base/src/main/java/org/apache/camel/impl/event/AbstractRouteEvent.java @@ -18,6 +18,7 @@ import java.io.Serial; import java.util.EventObject; +import java.util.Map; import org.apache.camel.CamelContext; import org.apache.camel.Route; @@ -51,4 +52,14 @@ public void setTimestamp(long timestamp) { this.timestamp = timestamp; } + @Override + public Map asJSon() { + return CamelEventJsonSupport.asJSon(this); + } + + @Override + public String toJSon(int indent) { + return CamelEventJsonSupport.toJSon(this, indent); + } + } diff --git a/core/camel-base/src/main/java/org/apache/camel/impl/event/CamelEventJsonSupport.java b/core/camel-base/src/main/java/org/apache/camel/impl/event/CamelEventJsonSupport.java new file mode 100644 index 0000000000000..8d64c0d41f915 --- /dev/null +++ b/core/camel-base/src/main/java/org/apache/camel/impl/event/CamelEventJsonSupport.java @@ -0,0 +1,193 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.impl.event; + +import java.util.Map; + +import org.apache.camel.CamelContext; +import org.apache.camel.Endpoint; +import org.apache.camel.Exchange; +import org.apache.camel.ExchangePropertyKey; +import org.apache.camel.Processor; +import org.apache.camel.Route; +import org.apache.camel.spi.CamelEvent; +import org.apache.camel.support.MessageHelper; +import org.apache.camel.util.ObjectHelper; +import org.apache.camel.util.URISupport; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; + +/** + * Support class for serializing {@link CamelEvent} instances to JSON. + */ +public final class CamelEventJsonSupport { + + private CamelEventJsonSupport() { + } + + public static Map asJSon(CamelEvent event) { + JsonObject jo = new JsonObject(); + jo.put("type", event.getType().name()); + jo.put("eventClass", event.getClass().getSimpleName()); + if (event.getTimestamp() > 0) { + jo.put("timestamp", event.getTimestamp()); + } + jo.put("message", event.toString()); + + if (event instanceof CamelEvent.CamelContextEvent contextEvent) { + appendContext(jo, contextEvent.getContext()); + } + if (event instanceof CamelEvent.RouteEvent routeEvent) { + appendRoute(jo, routeEvent.getRoute()); + } + if (event instanceof CamelEvent.ExchangeEvent exchangeEvent) { + appendExchange(jo, exchangeEvent.getExchange()); + } + if (event instanceof CamelEvent.StepEvent stepEvent) { + jo.put("stepId", stepEvent.getStepId()); + } + if (event instanceof CamelEvent.ExchangeSendingEvent sendingEvent) { + appendEndpoint(jo, "endpointUri", sendingEvent.getEndpoint()); + } + if (event instanceof CamelEvent.ExchangeSentEvent sentEvent) { + appendEndpoint(jo, "endpointUri", sentEvent.getEndpoint()); + jo.put("timeTaken", sentEvent.getTimeTaken()); + } + if (event instanceof CamelEvent.ExchangeRedeliveryEvent redeliveryEvent) { + jo.put("attempt", redeliveryEvent.getAttempt()); + } + if (event instanceof CamelEvent.ExchangeFailureEvent failureEvent) { + appendFailureHandling(jo, failureEvent); + } + if (event instanceof CamelEvent.RouteReloadedEvent reloadedEvent) { + jo.put("index", reloadedEvent.getIndex()); + jo.put("total", reloadedEvent.getTotal()); + } + if (event instanceof CamelEvent.RouteRestartingEvent restartingEvent) { + jo.put("attempt", restartingEvent.getAttempt()); + } + if (event instanceof CamelEvent.RouteRestartingFailureEvent restartingFailureEvent) { + jo.put("attempt", restartingFailureEvent.getAttempt()); + jo.put("exhausted", restartingFailureEvent.isExhausted()); + } + if (event instanceof CamelEvent.ServiceEvent serviceEvent) { + appendService(jo, serviceEvent.getService()); + } + if (event instanceof ServiceStartupFailureEvent startupFailureEvent) { + appendContext(jo, startupFailureEvent.getContext()); + } else if (event instanceof ServiceStopFailureEvent stopFailureEvent) { + appendContext(jo, stopFailureEvent.getContext()); + } + if (event instanceof CamelEvent.FailureEvent failureEvent) { + appendException(jo, failureEvent.getCause()); + } else if (event instanceof CamelEvent.ExchangeFailureEvent) { + Exchange exchange = ((CamelEvent.ExchangeFailureEvent) event).getExchange(); + appendException(jo, exchange.getException()); + } + + return jo; + } + + public static String toJSon(CamelEvent event, int indent) { + JsonObject jo = (JsonObject) asJSon(event); + if (indent > 0) { + return Jsoner.prettyPrint(jo.toJson(), indent); + } + return Jsoner.prettyPrint(jo.toJson()); + } + + private static void appendContext(JsonObject jo, CamelContext context) { + if (context == null) { + return; + } + jo.put("contextName", context.getName()); + } + + private static void appendRoute(JsonObject jo, Route route) { + if (route == null) { + return; + } + jo.put("routeId", route.getRouteId()); + if (ObjectHelper.isNotEmpty(route.getGroup())) { + jo.put("routeGroup", route.getGroup()); + } + if (route.getConsumer() != null && route.getConsumer().getEndpoint() != null) { + jo.put("fromEndpointUri", sanitizeUri(route.getConsumer().getEndpoint().getEndpointUri())); + } + } + + private static void appendExchange(JsonObject jo, Exchange exchange) { + if (exchange == null) { + return; + } + jo.put("exchangeId", exchange.getExchangeId()); + if (exchange.getFromRouteId() != null) { + jo.put("fromRouteId", exchange.getFromRouteId()); + } + String routeId = exchange.getProperty(ExchangePropertyKey.FAILURE_ROUTE_ID, String.class); + if (routeId == null) { + routeId = exchange.getFromRouteId(); + } + if (routeId != null) { + jo.put("routeId", routeId); + } + } + + private static void appendEndpoint(JsonObject jo, String name, Endpoint endpoint) { + if (endpoint != null) { + jo.put(name, sanitizeUri(endpoint.getEndpointUri())); + } + } + + private static void appendFailureHandling(JsonObject jo, CamelEvent.ExchangeFailureEvent failureEvent) { + jo.put("deadLetterChannel", failureEvent.isDeadLetterChannel()); + if (failureEvent.getDeadLetterUri() != null) { + jo.put("deadLetterUri", sanitizeUri(failureEvent.getDeadLetterUri())); + } + Processor failureHandler = failureEvent.getFailureHandler(); + if (failureHandler != null) { + jo.put("failureHandler", failureHandler.getClass().getName()); + } + } + + private static void appendService(JsonObject jo, Object service) { + if (service != null) { + if (service instanceof String stringService) { + jo.put("service", stringService); + } else { + jo.put("service", service.getClass().getName()); + } + } + } + + private static void appendException(JsonObject jo, Throwable cause) { + if (cause != null) { + jo.put("exception", MessageHelper.dumpExceptionAsJSonObject(cause).get("exception")); + } + } + + private static String sanitizeUri(String uri) { + if (uri == null) { + return null; + } + try { + return URISupport.sanitizeUri(uri); + } catch (Exception e) { + return uri; + } + } +} diff --git a/core/camel-base/src/main/java/org/apache/camel/impl/event/ServiceStartupFailureEvent.java b/core/camel-base/src/main/java/org/apache/camel/impl/event/ServiceStartupFailureEvent.java index 8160ac103e4f2..2359093dba6d2 100644 --- a/core/camel-base/src/main/java/org/apache/camel/impl/event/ServiceStartupFailureEvent.java +++ b/core/camel-base/src/main/java/org/apache/camel/impl/event/ServiceStartupFailureEvent.java @@ -18,6 +18,7 @@ import java.io.Serial; import java.util.EventObject; +import java.util.Map; import org.apache.camel.CamelContext; import org.apache.camel.spi.CamelEvent; @@ -61,6 +62,16 @@ public void setTimestamp(long timestamp) { this.timestamp = timestamp; } + @Override + public Map asJSon() { + return CamelEventJsonSupport.asJSon(this); + } + + @Override + public String toJSon(int indent) { + return CamelEventJsonSupport.toJSon(this, indent); + } + @Override public String toString() { return "Service startup failure: " + service + " due to " + cause.getMessage(); diff --git a/core/camel-base/src/main/java/org/apache/camel/impl/event/ServiceStopFailureEvent.java b/core/camel-base/src/main/java/org/apache/camel/impl/event/ServiceStopFailureEvent.java index aeeb118a4e713..a44cad0d5d713 100644 --- a/core/camel-base/src/main/java/org/apache/camel/impl/event/ServiceStopFailureEvent.java +++ b/core/camel-base/src/main/java/org/apache/camel/impl/event/ServiceStopFailureEvent.java @@ -18,6 +18,7 @@ import java.io.Serial; import java.util.EventObject; +import java.util.Map; import org.apache.camel.CamelContext; import org.apache.camel.spi.CamelEvent; @@ -61,6 +62,16 @@ public void setTimestamp(long timestamp) { this.timestamp = timestamp; } + @Override + public Map asJSon() { + return CamelEventJsonSupport.asJSon(this); + } + + @Override + public String toJSon(int indent) { + return CamelEventJsonSupport.toJSon(this, indent); + } + @Override public String toString() { return "Service stop failure: " + service + " due to " + cause.getMessage(); diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/EventConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/EventConsole.java index 2371f8680c642..af8a56eec1452 100644 --- a/core/camel-console/src/main/java/org/apache/camel/impl/console/EventConsole.java +++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/EventConsole.java @@ -39,7 +39,8 @@ public record EventEntry( @Metadata(description = "The event type") String type, @Metadata(description = "Epoch time in milliseconds (only present when known)") Long timestamp, @Metadata(description = "The exchange ID (only present for exchange events)") String exchangeId, - @Metadata(description = "The event's string representation") String message) { + @Metadata(description = "The event's string representation") String message, + @Metadata(description = "Structured event metadata as JSON") Map details) { } public record Response( @@ -160,13 +161,21 @@ private static List appendJSonEvents(CamelEvent[] events, int cursor CamelEvent event = events[cursor]; while (pos < capacity) { if (event != null) { - Long timestamp = event.getTimestamp() > 0 ? event.getTimestamp() : null; - String exchangeId = null; - if (event instanceof CamelEvent.ExchangeEvent) { - CamelEvent.ExchangeEvent ee = (CamelEvent.ExchangeEvent) event; - exchangeId = ee.getExchange().getExchangeId(); + Map json = event.asJSon(); + Long timestamp = null; + Object ts = json.get("timestamp"); + if (ts instanceof Number number && number.longValue() > 0) { + timestamp = number.longValue(); } - arr.add(new EventEntry(event.getType().toString(), timestamp, exchangeId, event.toString())); + String exchangeId = (String) json.get("exchangeId"); + Object type = json.get("type"); + String message = (String) json.get("message"); + arr.add(new EventEntry( + type != null ? type.toString() : event.getType().toString(), + timestamp, + exchangeId, + message, + json)); } // move to next pos++; diff --git a/core/camel-core/src/test/java/org/apache/camel/impl/event/CamelEventJsonTest.java b/core/camel-core/src/test/java/org/apache/camel/impl/event/CamelEventJsonTest.java new file mode 100644 index 0000000000000..af4b28abce56e --- /dev/null +++ b/core/camel-core/src/test/java/org/apache/camel/impl/event/CamelEventJsonTest.java @@ -0,0 +1,207 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.impl.event; + +import java.util.Map; + +import org.apache.camel.ContextTestSupport; +import org.apache.camel.Exchange; +import org.apache.camel.Route; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.mock.MockEndpoint; +import org.apache.camel.spi.CamelEvent; +import org.apache.camel.support.EventNotifierSupport; +import org.apache.camel.util.json.JsonObject; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CamelEventJsonTest extends ContextTestSupport { + + @Test + void testContextStartedEventAsJson() throws Exception { + CamelEvent event = new CamelContextStartedEvent(context); + event.setTimestamp(123456789L); + + Map json = event.asJSon(); + + assertThat(json) + .containsEntry("type", "CamelContextStarted") + .containsEntry("eventClass", "CamelContextStartedEvent") + .containsEntry("timestamp", 123456789L) + .containsEntry("contextName", context.getName()) + .containsKey("message"); + assertThat(event.toJSon(2)).contains("\"type\": \"CamelContextStarted\""); + } + + @Test + void testExchangeFailedEventAsJson() throws Exception { + getMockEndpoint("mock:result").expectedMessageCount(0); + + context.getManagementStrategy().addEventNotifier(new EventNotifierSupport() { + @Override + public void notify(CamelEvent event) { + if (event instanceof CamelEvent.ExchangeFailedEvent failedEvent) { + Map json = failedEvent.asJSon(); + assertThat(json) + .containsEntry("type", "ExchangeFailed") + .containsKey("exchangeId") + .containsKey("routeId") + .containsKey("message"); + assertThat(json.get("exception")).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map exception = (Map) json.get("exception"); + assertThat(exception) + .containsEntry("type", "java.lang.IllegalArgumentException") + .containsEntry("message", "boom"); + } + } + }); + + try { + template.sendBody("direct:fail", "Hello"); + } catch (Exception e) { + // expected + } + + assertMockEndpointsSatisfied(); + } + + @Test + void testExchangeSentEventAsJson() throws Exception { + MockEndpoint mock = getMockEndpoint("mock:result"); + mock.expectedMessageCount(1); + + ExchangeSentEvent event = new ExchangeSentEvent( + template.send("direct:sent", exchange -> exchange.getMessage().setBody("Hello")), + context.getEndpoint("mock:result"), + 42); + event.setTimestamp(999L); + + Map json = event.asJSon(); + + assertThat(json) + .containsEntry("type", "ExchangeSent") + .containsEntry("timestamp", 999L) + .containsEntry("timeTaken", 42L) + .containsKey("exchangeId") + .containsKey("endpointUri"); + } + + @Test + void testExchangeFailureHandlingEventAsJson() throws Exception { + Exchange exchange = createExchangeWithBody("payload"); + exchange.setException(new RuntimeException("failed")); + + ExchangeFailureHandlingEvent event = new ExchangeFailureHandlingEvent( + exchange, + ex -> { + }, + true, + "mock:dead"); + + Map json = event.asJSon(); + + assertThat(json) + .containsEntry("type", "ExchangeFailureHandling") + .containsEntry("deadLetterChannel", true) + .containsEntry("deadLetterUri", "mock:dead") + .containsKey("failureHandler") + .containsKey("exception"); + } + + @Test + void testRouteReloadedEventAsJson() { + Route route = context.getRoute("jsonRoute"); + RouteReloadedEvent event = new RouteReloadedEvent(route, 2, 5); + + Map json = event.asJSon(); + + assertThat(json) + .containsEntry("type", "RouteReloaded") + .containsEntry("routeId", "jsonRoute") + .containsEntry("index", 2) + .containsEntry("total", 5); + } + + @Test + void testRouteRestartingFailureEventAsJson() { + Route route = context.getRoute("jsonRoute"); + RouteRestartingFailureEvent event + = new RouteRestartingFailureEvent(route, 3, new IllegalStateException("restart"), true); + + Map json = event.asJSon(); + + assertThat(json) + .containsEntry("type", "RouteRestartingFailure") + .containsEntry("attempt", 3L) + .containsEntry("exhausted", true) + .containsKey("exception"); + } + + @Test + void testStepFailedEventAsJson() { + Exchange exchange = createExchangeWithBody("step-body"); + exchange.setException(new IllegalStateException("step failed")); + + StepFailedEvent event = new StepFailedEvent(exchange, "myStep"); + + Map json = event.asJSon(); + + assertThat(json) + .containsEntry("type", "StepFailed") + .containsEntry("stepId", "myStep") + .containsKey("exchangeId") + .containsKey("exception"); + } + + @Test + void testServiceStartupFailureEventAsJson() { + ServiceStartupFailureEvent event + = new ServiceStartupFailureEvent(context, "my-service", new RuntimeException("startup failed")); + + Map json = event.asJSon(); + + assertThat(json) + .containsEntry("type", "ServiceStartupFailure") + .containsEntry("service", "my-service") + .containsEntry("contextName", context.getName()) + .containsKey("exception"); + assertThat(json.get("exception")).isInstanceOf(Map.class); + } + + @Test + void testAsJsonReturnsJsonObjectCompatibleMap() { + CamelEvent event = new CamelContextInitializedEvent(context); + + Map json = event.asJSon(); + + assertThat(json).isInstanceOf(JsonObject.class); + assertThat(new JsonObject(json)).containsEntry("type", "CamelContextInitialized"); + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:fail").routeId("jsonRoute").throwException(new IllegalArgumentException("boom")); + from("direct:sent").to("mock:result"); + } + }; + } +} diff --git a/docs/user-manual/modules/ROOT/pages/event-notifier.adoc b/docs/user-manual/modules/ROOT/pages/event-notifier.adoc index bc4057f6ae15e..5f8785f822eb6 100644 --- a/docs/user-manual/modules/ROOT/pages/event-notifier.adoc +++ b/docs/user-manual/modules/ROOT/pages/event-notifier.adoc @@ -147,3 +147,20 @@ Timestamps can be enabled from the CamelContext as follows: ---- context.getManagementStrategy().getEventFactory().setTimestampEnabled(true); ---- + +== Event JSON serialization + +Since Camel 4.23, every `CamelEvent` can be serialized to structured JSON using `asJSon()` and `toJSon(int indent)`. +This provides the same kind of structured metadata access as xref:components::others/backlog-tracer.adoc[Backlog Tracer] and the Error Registry backlog messages, but for live EventNotifier events. + +[source,java] +---- +public void notify(CamelEvent event) throws Exception { + Map json = event.asJSon(); + String pretty = event.toJSon(2); + log.info("Event JSON: {}", pretty); +} +---- + +The JSON map includes common fields such as `type`, `timestamp`, `message`, and type-specific metadata (for example `exchangeId`, `routeId`, `endpointUri`, `exception`, `stepId`). +The developer console event page also uses this structured JSON representation. From ddbf2aaa54c81ba699f13218f60d2958bea0e68a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 18:46:21 +0000 Subject: [PATCH 2/8] CAMEL-24377: Address bugbot review for CamelEvent JSON - Include exception details on ExchangeRedeliveryEvent JSON - Guard exception JSON serialization with defensive fallback - Add redelivery event JSON test coverage Co-authored-by: Cursor Agent --- .../camel/impl/event/CamelEventJsonSupport.java | 8 +++++++- .../camel/impl/event/CamelEventJsonTest.java | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/core/camel-base/src/main/java/org/apache/camel/impl/event/CamelEventJsonSupport.java b/core/camel-base/src/main/java/org/apache/camel/impl/event/CamelEventJsonSupport.java index 8d64c0d41f915..0af93aad9eccf 100644 --- a/core/camel-base/src/main/java/org/apache/camel/impl/event/CamelEventJsonSupport.java +++ b/core/camel-base/src/main/java/org/apache/camel/impl/event/CamelEventJsonSupport.java @@ -69,6 +69,7 @@ public static Map asJSon(CamelEvent event) { } if (event instanceof CamelEvent.ExchangeRedeliveryEvent redeliveryEvent) { jo.put("attempt", redeliveryEvent.getAttempt()); + appendException(jo, redeliveryEvent.getExchange().getException()); } if (event instanceof CamelEvent.ExchangeFailureEvent failureEvent) { appendFailureHandling(jo, failureEvent); @@ -175,8 +176,13 @@ private static void appendService(JsonObject jo, Object service) { } private static void appendException(JsonObject jo, Throwable cause) { - if (cause != null) { + if (cause == null) { + return; + } + try { jo.put("exception", MessageHelper.dumpExceptionAsJSonObject(cause).get("exception")); + } catch (Exception e) { + jo.put("exceptionMessage", cause.getMessage()); } } diff --git a/core/camel-core/src/test/java/org/apache/camel/impl/event/CamelEventJsonTest.java b/core/camel-core/src/test/java/org/apache/camel/impl/event/CamelEventJsonTest.java index af4b28abce56e..e8aefb1359e60 100644 --- a/core/camel-core/src/test/java/org/apache/camel/impl/event/CamelEventJsonTest.java +++ b/core/camel-core/src/test/java/org/apache/camel/impl/event/CamelEventJsonTest.java @@ -184,6 +184,21 @@ void testServiceStartupFailureEventAsJson() { assertThat(json.get("exception")).isInstanceOf(Map.class); } + @Test + void testExchangeRedeliveryEventAsJson() { + Exchange exchange = createExchangeWithBody("payload"); + exchange.setException(new RuntimeException("redelivery cause")); + + ExchangeRedeliveryEvent event = new ExchangeRedeliveryEvent(exchange, 2); + + Map json = event.asJSon(); + + assertThat(json) + .containsEntry("type", "ExchangeRedelivery") + .containsEntry("attempt", 2) + .containsKey("exception"); + } + @Test void testAsJsonReturnsJsonObjectCompatibleMap() { CamelEvent event = new CamelContextInitializedEvent(context); From 6558d8f06836c3b33fc1d886450f0659cc16f3be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 27 Aug 2026 02:43:06 +0000 Subject: [PATCH 3/8] CAMEL-24377: Fix unresolved xref in event-notifier documentation Use manual-local xref:backlog-tracer.adoc link so docs xref-check passes. Co-authored-by: Omar Atie --- docs/user-manual/modules/ROOT/pages/event-notifier.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-manual/modules/ROOT/pages/event-notifier.adoc b/docs/user-manual/modules/ROOT/pages/event-notifier.adoc index 5f8785f822eb6..bbdb9ed5c2d9d 100644 --- a/docs/user-manual/modules/ROOT/pages/event-notifier.adoc +++ b/docs/user-manual/modules/ROOT/pages/event-notifier.adoc @@ -151,7 +151,7 @@ context.getManagementStrategy().getEventFactory().setTimestampEnabled(true); == Event JSON serialization Since Camel 4.23, every `CamelEvent` can be serialized to structured JSON using `asJSon()` and `toJSon(int indent)`. -This provides the same kind of structured metadata access as xref:components::others/backlog-tracer.adoc[Backlog Tracer] and the Error Registry backlog messages, but for live EventNotifier events. +This provides the same kind of structured metadata access as xref:backlog-tracer.adoc[Backlog Tracer] and the Error Registry backlog messages, but for live EventNotifier events. [source,java] ---- From 92d3def5992a9b9e45ba20e93491ab7379087d7a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 18:33:12 +0000 Subject: [PATCH 4/8] CAMEL-24377: Document EventConsole details field for event JSON Co-authored-by: Cursor Agent --- docs/user-manual/modules/ROOT/pages/event-notifier.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-manual/modules/ROOT/pages/event-notifier.adoc b/docs/user-manual/modules/ROOT/pages/event-notifier.adoc index bbdb9ed5c2d9d..53bee56f20be5 100644 --- a/docs/user-manual/modules/ROOT/pages/event-notifier.adoc +++ b/docs/user-manual/modules/ROOT/pages/event-notifier.adoc @@ -163,4 +163,4 @@ public void notify(CamelEvent event) throws Exception { ---- The JSON map includes common fields such as `type`, `timestamp`, `message`, and type-specific metadata (for example `exchangeId`, `routeId`, `endpointUri`, `exception`, `stepId`). -The developer console event page also uses this structured JSON representation. +The developer console event page exposes this structured JSON in the `details` field of each event entry. From ad1b12d9db782d9e969d7921161064c675c05e16 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 19:01:52 +0000 Subject: [PATCH 5/8] CAMEL-24377: Regenerate dev-console metadata for EventEntry details Co-authored-by: Cursor Agent --- .../camel/catalog/dev-consoles-openapi.json | 15 +++++++++++++++ .../apache/camel/catalog/dev-consoles/event.json | 15 +++++++++++++++ .../org/apache/camel/dev-console/event.json | 15 +++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json index 3418a62615326..5a1454ef76ca1 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json @@ -1856,6 +1856,11 @@ "message": { "type": "string", "description": "The event's string representation" + }, + "details": { + "type": "object", + "additionalProperties": true, + "description": "Structured event metadata as JSON" } } }, @@ -1881,6 +1886,11 @@ "message": { "type": "string", "description": "The event's string representation" + }, + "details": { + "type": "object", + "additionalProperties": true, + "description": "Structured event metadata as JSON" } } }, @@ -1906,6 +1916,11 @@ "message": { "type": "string", "description": "The event's string representation" + }, + "details": { + "type": "object", + "additionalProperties": true, + "description": "Structured event metadata as JSON" } } }, diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/event.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/event.json index 0d4154a8b5431..c23b0d82f87f5 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/event.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/event.json @@ -35,6 +35,11 @@ "message": { "type": "string", "description": "The event's string representation" + }, + "details": { + "type": "object", + "additionalProperties": true, + "description": "Structured event metadata as JSON" } } }, @@ -60,6 +65,11 @@ "message": { "type": "string", "description": "The event's string representation" + }, + "details": { + "type": "object", + "additionalProperties": true, + "description": "Structured event metadata as JSON" } } }, @@ -85,6 +95,11 @@ "message": { "type": "string", "description": "The event's string representation" + }, + "details": { + "type": "object", + "additionalProperties": true, + "description": "Structured event metadata as JSON" } } }, diff --git a/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/event.json b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/event.json index 0d4154a8b5431..c23b0d82f87f5 100644 --- a/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/event.json +++ b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/event.json @@ -35,6 +35,11 @@ "message": { "type": "string", "description": "The event's string representation" + }, + "details": { + "type": "object", + "additionalProperties": true, + "description": "Structured event metadata as JSON" } } }, @@ -60,6 +65,11 @@ "message": { "type": "string", "description": "The event's string representation" + }, + "details": { + "type": "object", + "additionalProperties": true, + "description": "Structured event metadata as JSON" } } }, @@ -85,6 +95,11 @@ "message": { "type": "string", "description": "The event's string representation" + }, + "details": { + "type": "object", + "additionalProperties": true, + "description": "Structured event metadata as JSON" } } }, From 6c7b7bcf953cb7c8a94a1a62f2aa7929177bd5ec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 19:25:52 +0000 Subject: [PATCH 6/8] CAMEL-24377: Make CamelEvent JSON methods SPI-compatible defaults Convert asJSon() and toJSon(int) to default methods with a minimal type/timestamp/message fallback so external CamelEvent implementers remain source- and binary-compatible. Document the SPI addition and Event console details field in the 4.23 upgrade guide. Co-authored-by: Cursor Agent --- .../java/org/apache/camel/spi/CamelEvent.java | 47 ++++++++++++++++++- .../pages/camel-4x-upgrade-guide-4_23.adoc | 14 ++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java b/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java index 4052e45d2020d..9c7cc2fd529f0 100644 --- a/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java +++ b/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java @@ -16,6 +16,7 @@ */ package org.apache.camel.spi; +import java.util.LinkedHashMap; import java.util.Map; import org.apache.camel.CamelContext; @@ -23,6 +24,7 @@ import org.apache.camel.Exchange; import org.apache.camel.Processor; import org.apache.camel.Route; +import org.apache.camel.util.StringQuoteHelper; import org.jspecify.annotations.Nullable; /** @@ -103,14 +105,55 @@ enum Type { * @return JSON representation of this event * @since 4.23 */ - String toJSon(int indent); + default String toJSon(int indent) { + Map map = asJSon(); + String indentText = indent > 0 ? " ".repeat(indent) : ""; + StringBuilder sb = new StringBuilder(128); + sb.append('{'); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) { + sb.append(','); + } + first = false; + if (indent > 0) { + sb.append('\n').append(indentText); + } + sb.append(StringQuoteHelper.doubleQuote(entry.getKey())).append(':'); + if (indent > 0) { + sb.append(' '); + } + Object value = entry.getValue(); + if (value instanceof Number || value instanceof Boolean) { + sb.append(value); + } else { + sb.append(StringQuoteHelper.doubleQuote(String.valueOf(value))); + } + } + if (indent > 0) { + sb.append('\n'); + } + sb.append('}'); + return sb.toString(); + } /** * The full event as a {@link Map} suitable for JSON serialization, containing structured metadata for this event. * + * The default implementation returns a minimal map with {@code type}, optional {@code timestamp}, and + * {@code message}. Camel's built-in event classes override this to provide richer structured metadata. + * * @since 4.23 */ - Map asJSon(); + default Map asJSon() { + Map map = new LinkedHashMap<>(); + map.put("type", getType().name()); + if (getTimestamp() > 0) { + map.put("timestamp", getTimestamp()); + } + map.put("message", toString()); + return map; + } /** * This interface is implemented by all events that contain an exception and is used to retrieve the exception in a diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc index 788cc39b2ae9d..ad6885ef3dda1 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc @@ -59,6 +59,20 @@ packages not inferred from the configured protocol. Do not use `*`, as it disables Avro's class-loading protection. +=== CamelEvent JSON serialization + +`CamelEvent` now provides `asJSon()` and `toJSon(int indent)` with default implementations that +return a minimal JSON map (`type`, optional `timestamp`, and `message`). Camel's built-in event +classes override these methods to include structured metadata such as exchange, route, and +exception details. + +Custom `CamelEvent` implementations continue to compile without changes. Override the new methods +only if you need richer JSON output than the default `type`/`timestamp`/`message` map. + +The Event developer console now exposes the full structured JSON payload in the `details` field of +each event entry, while keeping the existing flat `type`, `timestamp`, `exchangeId`, and +`message` fields for backwards compatibility. + === camel-dynamic-router The `dynamic-router-control` endpoint no longer takes the subscription `predicate`, or the From c32249aeb05e85c546b9ae02dc2dab2503487a61 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 17:01:35 +0000 Subject: [PATCH 7/8] CAMEL-24377: JSON-escape default CamelEvent.toJSon output Add StringQuoteHelper.jsonQuote for proper escaping of quotes, backslashes and control characters. Use it in the default CamelEvent toJSon implementation instead of doubleQuote. Add tests for the helper and for a custom CamelEvent using the default SPI path with special characters in toString(). Co-authored-by: Cursor --- .../java/org/apache/camel/spi/CamelEvent.java | 4 +- .../camel/impl/event/CamelEventJsonTest.java | 35 ++++++++++++++++ .../apache/camel/util/StringQuoteHelper.java | 41 +++++++++++++++++++ .../camel/util/StringQuoteHelperTest.java | 7 ++++ 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java b/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java index 9c7cc2fd529f0..763b4901921ba 100644 --- a/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java +++ b/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java @@ -119,7 +119,7 @@ default String toJSon(int indent) { if (indent > 0) { sb.append('\n').append(indentText); } - sb.append(StringQuoteHelper.doubleQuote(entry.getKey())).append(':'); + sb.append(StringQuoteHelper.jsonQuote(entry.getKey())).append(':'); if (indent > 0) { sb.append(' '); } @@ -127,7 +127,7 @@ default String toJSon(int indent) { if (value instanceof Number || value instanceof Boolean) { sb.append(value); } else { - sb.append(StringQuoteHelper.doubleQuote(String.valueOf(value))); + sb.append(StringQuoteHelper.jsonQuote(String.valueOf(value))); } } if (indent > 0) { diff --git a/core/camel-core/src/test/java/org/apache/camel/impl/event/CamelEventJsonTest.java b/core/camel-core/src/test/java/org/apache/camel/impl/event/CamelEventJsonTest.java index e8aefb1359e60..f270405f7120f 100644 --- a/core/camel-core/src/test/java/org/apache/camel/impl/event/CamelEventJsonTest.java +++ b/core/camel-core/src/test/java/org/apache/camel/impl/event/CamelEventJsonTest.java @@ -199,6 +199,41 @@ void testExchangeRedeliveryEventAsJson() { .containsKey("exception"); } + @Test + void testDefaultCamelEventToJsonEscapesSpecialCharacters() { + CamelEvent event = new CamelEvent() { + @Override + public Type getType() { + return Type.Custom; + } + + @Override + public Object getSource() { + return "source"; + } + + @Override + public long getTimestamp() { + return 42; + } + + @Override + public void setTimestamp(long timestamp) { + } + + @Override + public String toString() { + return "Route \"my-route\" failed\nline2"; + } + }; + + String json = event.toJSon(0); + + assertThat(json) + .isEqualTo("{\"type\":\"Custom\",\"timestamp\":42,\"message\":\"Route \\\"my-route\\\" failed\\nline2\"}"); + assertThat(json).doesNotContain("\"my-route\" failed"); + } + @Test void testAsJsonReturnsJsonObjectCompatibleMap() { CamelEvent event = new CamelContextInitializedEvent(context); diff --git a/core/camel-util/src/main/java/org/apache/camel/util/StringQuoteHelper.java b/core/camel-util/src/main/java/org/apache/camel/util/StringQuoteHelper.java index 83ba2bfe3ae5a..2544c8f608fab 100644 --- a/core/camel-util/src/main/java/org/apache/camel/util/StringQuoteHelper.java +++ b/core/camel-util/src/main/java/org/apache/camel/util/StringQuoteHelper.java @@ -34,6 +34,47 @@ public static String doubleQuote(String text) { return quote(text, "\""); } + /** + * Returns the given text as a JSON string literal with proper escaping of quotes, backslashes and control + * characters. + */ + public static String jsonQuote(String text) { + if (text == null) { + return "null"; + } + StringBuilder sb = new StringBuilder(text.length() + 8); + sb.append('"'); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + default: + if (c < 0x20) { + sb.append("\\u"); + sb.append(String.format("%04x", (int) c)); + } else { + sb.append(c); + } + } + } + sb.append('"'); + return sb.toString(); + } + /** * Returns the text wrapped single quotes */ diff --git a/core/camel-util/src/test/java/org/apache/camel/util/StringQuoteHelperTest.java b/core/camel-util/src/test/java/org/apache/camel/util/StringQuoteHelperTest.java index b480035ebc78e..883e2a9e479a5 100644 --- a/core/camel-util/src/test/java/org/apache/camel/util/StringQuoteHelperTest.java +++ b/core/camel-util/src/test/java/org/apache/camel/util/StringQuoteHelperTest.java @@ -21,6 +21,13 @@ public class StringQuoteHelperTest { + @Test + public void testJsonQuoteEscapesSpecialCharacters() { + Assertions.assertEquals("\"He said \\\"hi\\\"\"", StringQuoteHelper.jsonQuote("He said \"hi\"")); + Assertions.assertEquals("\"line1\\nline2\"", StringQuoteHelper.jsonQuote("line1\nline2")); + Assertions.assertEquals("\"a\\\\b\"", StringQuoteHelper.jsonQuote("a\\b")); + } + @Test public void testSplitBeanParametersTrim() throws Exception { String[] arr = StringQuoteHelper.splitSafeQuote("String.class ${body}, String.class Mars", ',', true, true); From a40734bbccd0f5583fb3398eb3f9730956b6c473 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 4 Sep 2026 21:04:53 +0000 Subject: [PATCH 8/8] CAMEL-24377: Document default toJSon scalar-only constraint Clarify in Javadoc that the default toJSon() handles only flat scalar values from asJSon(); custom implementers with nested Map/List values should override toJSon() as well. Co-authored-by: Cursor --- .../src/main/java/org/apache/camel/spi/CamelEvent.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java b/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java index 763b4901921ba..821755ee5f6fc 100644 --- a/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java +++ b/core/camel-api/src/main/java/org/apache/camel/spi/CamelEvent.java @@ -101,6 +101,10 @@ enum Type { /** * Dumps the full event as a pretty-printed JSON string. * + * The default implementation serializes flat scalar values ({@link Number}, {@link Boolean}, and {@link String}) + * from {@link #asJSon()}. If a custom implementer overrides {@link #asJSon()} to include nested {@link Map} or + * {@link List} objects, they should also override this method to produce valid JSON. + * * @param indent number of spaces to indent * @return JSON representation of this event * @since 4.23