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-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..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 @@ -16,11 +16,15 @@ */ package org.apache.camel.spi; +import java.util.LinkedHashMap; +import java.util.Map; + import org.apache.camel.CamelContext; import org.apache.camel.Endpoint; 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; /** @@ -94,6 +98,67 @@ enum Type { void setTimestamp(long timestamp); + /** + * 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 + */ + 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.jsonQuote(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.jsonQuote(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 + */ + 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 * 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..0af93aad9eccf --- /dev/null +++ b/core/camel-base/src/main/java/org/apache/camel/impl/event/CamelEventJsonSupport.java @@ -0,0 +1,199 @@ +/* + * 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()); + appendException(jo, redeliveryEvent.getExchange().getException()); + } + 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) { + return; + } + try { + jo.put("exception", MessageHelper.dumpExceptionAsJSonObject(cause).get("exception")); + } catch (Exception e) { + jo.put("exceptionMessage", cause.getMessage()); + } + } + + 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/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" } } }, 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..f270405f7120f --- /dev/null +++ b/core/camel-core/src/test/java/org/apache/camel/impl/event/CamelEventJsonTest.java @@ -0,0 +1,257 @@ +/* + * 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 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 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); + + 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/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); 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 diff --git a/docs/user-manual/modules/ROOT/pages/event-notifier.adoc b/docs/user-manual/modules/ROOT/pages/event-notifier.adoc index bc4057f6ae15e..53bee56f20be5 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: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 exposes this structured JSON in the `details` field of each event entry.