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..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/main/java/org/apache/camel/impl/console/EventConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/EventConsole.java index 0b11c8b2c5165..5acf292dffd40 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 @@ -152,16 +152,7 @@ private static List appendJSonEvents(CamelEvent[] events, int cursor CamelEvent event = events[cursor]; while (pos < capacity) { if (event != null) { - JsonObject jo = new JsonObject(); - jo.put("type", event.getType().toString()); - if (event.getTimestamp() > 0) { - jo.put("timestamp", event.getTimestamp()); - } - if (event instanceof CamelEvent.ExchangeEvent) { - CamelEvent.ExchangeEvent ee = (CamelEvent.ExchangeEvent) event; - jo.put("exchangeId", ee.getExchange().getExchangeId()); - } - jo.put("message", event.toString()); + JsonObject jo = new JsonObject(event.asJSon()); arr.add(jo); } // move to next 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..e8aefb1359e60 --- /dev/null +++ b/core/camel-core/src/test/java/org/apache/camel/impl/event/CamelEventJsonTest.java @@ -0,0 +1,222 @@ +/* + * 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 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.