diff --git a/docs/superpowers/plans/2026-03-16-diagram-execution-overlay-correctness.md b/docs/superpowers/plans/2026-03-16-diagram-execution-overlay-correctness.md deleted file mode 100644 index 5ae72df..0000000 --- a/docs/superpowers/plans/2026-03-16-diagram-execution-overlay-correctness.md +++ /dev/null @@ -1,1395 +0,0 @@ -# Diagram-Execution Overlay Correctness Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fix 6 gaps in the route diagram-to-execution mapping system so that every user-defined processor maps bidirectionally between the static diagram and runtime execution tree. - -**Architecture:** The fixes follow the spec's implementation order (foundational first, research-dependent last). Each fix is a self-contained task with TDD. Model changes in `cameleer-common` are made early since both agent and server consume them. All work happens on a `fix/overlay-correctness` branch. - -**Tech Stack:** Java 17, Apache Camel 4.10, JUnit 5, Mockito, Maven - -**Spec:** `docs/superpowers/specs/2026-03-16-diagram-execution-overlay-correctness-design.md` - ---- - -## File Structure - -**Files to modify:** -- `cameleer-common/src/main/java/com/cameleer/common/model/ProcessorExecution.java` — add `errorHandlerType`, `loopIndex`, `loopSize`, `multicastIndex` fields -- `cameleer-agent/src/main/java/com/cameleer/agent/notifier/CameleerInterceptStrategy.java` — synthetic ID for null processorIds -- `cameleer-agent/src/main/java/com/cameleer/agent/collector/ExecutionCollector.java` — loop/multicast tracking, error handler tagging, debug logging -- `cameleer-agent/src/main/java/com/cameleer/agent/diagram/RouteModelExtractor.java` — URI normalization in processTo() - -**Files to modify (tests):** -- `cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionCollectorTest.java` — new tests for loop, multicast, error handler, null processorId -- `cameleer-agent/src/test/java/com/cameleer/agent/diagram/RouteModelExtractorTest.java` — new tests for onException mapping, circuitBreaker fallback mapping, URI normalization - ---- - -## Chunk 1: Setup + Fix 1 (Null ProcessorId) + Fix 4 (URI Normalization) - -### Task 0: Branch Setup and Gitea Issues - -**Files:** None (git/Gitea operations only) - -- [ ] **Step 0.1: Create feature branch** - -```bash -git checkout -b fix/overlay-correctness -``` - -- [ ] **Step 0.2: Create Gitea issues for all 6 fixes** - -Create issues via the Gitea MCP tool with these titles and descriptions: -1. "Fix: Null processorId passes silently through InterceptStrategy" — H3 from spec. Synthetic ID generation + debug logging for unmapped processors. -2. "Fix: onException handler processors may miss diagram node mapping" — H2 from spec. Verify mapping + add errorHandlerType field. -3. "Fix: Loop iteration tracking not implemented" — H1 from spec. Add loopIteration wrappers mirroring split pattern. -4. "Fix: Cross-route edge URI mismatch with query parameters" — M4 from spec. Normalize direct:/seda: URIs on CROSS_ROUTE edges. -5. "Fix: Inline multicast branch tracking" — M5 from spec. Add multicastBranch wrappers for parallelProcessing() multicasts. -6. "Fix: CircuitBreaker fallback processor mapping untested" — M6 from spec. Verify and test fallback mapping. - -- [ ] **Step 0.3: Commit spec and plan** - -```bash -git add docs/superpowers/specs/2026-03-16-diagram-execution-overlay-correctness-design.md -git add docs/superpowers/plans/2026-03-16-diagram-execution-overlay-correctness.md -git commit -m "docs: add overlay correctness spec and implementation plan" -``` - -### Task 1: Fix Null ProcessorId (H3) - -Ref: Gitea issue #1 from step 0.2 - -**Files:** -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/notifier/CameleerInterceptStrategy.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/collector/ExecutionCollector.java` -- Modify: `cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionCollectorTest.java` - -- [ ] **Step 1.1: Write failing test — synthetic ID for null processorId** - -Add to `ExecutionCollectorTest.java`: - -```java -@Test -void onProcessorStart_nullProcessorId_syntheticIdUsed() { - setupExchangeProperties(exchange); - when(exchange.getExchangeId()).thenReturn("ex-null-id"); - when(exchange.getFromRouteId()).thenReturn("test-route"); - when(message.getHeader("X-Cameleer-CorrelationId", String.class)).thenReturn("corr-1"); - - collector.onExchangeCreated(exchange); - - // Simulate a processor with null ID (synthetic/internal Camel processor) - collector.onProcessorStart(exchange, null, "pipeline"); - collector.onProcessorComplete(exchange, null, 100_000L); - collector.onExchangeCompleted(exchange); - - ArgumentCaptor captor = ArgumentCaptor.forClass(RouteExecution.class); - verify(exporter).exportExecution(captor.capture()); - - RouteExecution execution = captor.getValue(); - assertEquals(1, execution.getProcessors().size()); - ProcessorExecution proc = execution.getProcessors().get(0); - // Should have synthetic processorId, NOT null - assertNotNull(proc.getProcessorId(), "processorId should not be null"); - assertEquals("pipeline", proc.getProcessorType()); - // No diagram mapping for synthetic processors - assertNull(proc.getDiagramNodeId()); -} -``` - -- [ ] **Step 1.2: Run test to verify it fails** - -```bash -mvn test -pl cameleer-common,cameleer-agent -Dtest=ExecutionCollectorTest#onProcessorStart_nullProcessorId_syntheticIdUsed -DfailIfNoTests=false -``` - -Expected: FAIL — null processorId gets stored as-is. - -- [ ] **Step 1.3: Implement synthetic ID in InterceptStrategy** - -In `CameleerInterceptStrategy.java`, add an `AtomicInteger` counter and generate synthetic IDs: - -```java -package com.cameleer.agent.notifier; - -import com.cameleer.agent.collector.ExecutionCollector; -import org.apache.camel.AsyncCallback; -import org.apache.camel.CamelContext; -import org.apache.camel.Exchange; -import org.apache.camel.NamedNode; -import org.apache.camel.Processor; -import org.apache.camel.spi.InterceptStrategy; -import org.apache.camel.support.processor.DelegateAsyncProcessor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.concurrent.atomic.AtomicInteger; - -public class CameleerInterceptStrategy implements InterceptStrategy { - - private static final Logger LOG = LoggerFactory.getLogger(CameleerInterceptStrategy.class); - private final ExecutionCollector collector; - private final AtomicInteger syntheticIdCounter = new AtomicInteger(0); - - public CameleerInterceptStrategy(ExecutionCollector collector) { - this.collector = collector; - } - - @Override - public Processor wrapProcessorInInterceptors(CamelContext context, NamedNode definition, - Processor target, Processor nextTarget) throws Exception { - String processorId = definition.getId(); - String processorType = definition.getShortName(); - - // Generate synthetic ID for processors without Camel-assigned IDs - // Do NOT mutate the Camel definition — use local variable only - if (processorId == null) { - processorId = processorType + "-synthetic-" + syntheticIdCounter.incrementAndGet(); - } - - final String finalProcessorId = processorId; - return new DelegateAsyncProcessor(target) { - @Override - public boolean process(Exchange exchange, AsyncCallback callback) { - collector.onProcessorStart(exchange, finalProcessorId, processorType); - long startNanos = System.nanoTime(); - - return super.process(exchange, new InterceptCallback( - exchange, callback, startNanos, collector, finalProcessorId)); - } - }; - } - - private static class InterceptCallback implements AsyncCallback { - private final Exchange exchange; - private final AsyncCallback original; - private final long startNanos; - private final ExecutionCollector collector; - private final String processorId; - - InterceptCallback(Exchange exchange, AsyncCallback original, long startNanos, - ExecutionCollector collector, String processorId) { - this.exchange = exchange; - this.original = original; - this.startNanos = startNanos; - this.collector = collector; - this.processorId = processorId; - } - - @Override - public void done(boolean doneSync) { - long durationNanos = System.nanoTime() - startNanos; - Exception exception = exchange.getException(); - if (exception != null) { - collector.onProcessorFailed(exchange, processorId, exception); - } else { - collector.onProcessorComplete(exchange, processorId, durationNanos); - } - original.done(doneSync); - } - } -} -``` - -- [ ] **Step 1.4: Add debug logging for unmapped processors in ExecutionCollector** - -In `ExecutionCollector.onProcessorStart()`, after the diagramNodeId mapping lookup (around line 209), add: - -```java -// Resolve diagramNodeId from route mappings -String routeId = exchange.getFromRouteId(); -if (routeId != null) { - Map mapping = routeMappings.get(routeId); - if (mapping != null) { - processorExec.setDiagramNodeId(mapping.get(processorId)); - } -} - -// Log unmapped processors at DEBUG level for diagnostics -if (processorExec.getDiagramNodeId() == null) { - LOG.debug("Cameleer: Unmapped processor: id={}, type={}, routeId={}", - processorId, processorType, routeId); -} -``` - -- [ ] **Step 1.5: Run test to verify it passes** - -```bash -mvn test -pl cameleer-common,cameleer-agent -Dtest=ExecutionCollectorTest#onProcessorStart_nullProcessorId_syntheticIdUsed -DfailIfNoTests=false -``` - -Expected: PASS - -- [ ] **Step 1.6: Run full test suite to verify no regressions** - -```bash -mvn test -pl cameleer-common,cameleer-agent -``` - -Expected: All existing tests pass. - -- [ ] **Step 1.7: Commit** - -```bash -git add cameleer-agent/src/main/java/com/cameleer/agent/notifier/CameleerInterceptStrategy.java -git add cameleer-agent/src/main/java/com/cameleer/agent/collector/ExecutionCollector.java -git add cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionCollectorTest.java -git commit -m "fix: generate synthetic IDs for null processorIds and log unmapped processors - -Closes #" -``` - -### Task 2: Fix Cross-Route Edge URI Normalization (M4) - -Ref: Gitea issue #4 from step 0.2 - -**Files:** -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/diagram/RouteModelExtractor.java` -- Modify: `cameleer-agent/src/test/java/com/cameleer/agent/diagram/RouteModelExtractorTest.java` - -- [ ] **Step 2.1: Write failing test — edge target normalized** - -Add to `RouteModelExtractorTest.java`: - -```java -@Test -void extractRoute_crossRouteEdge_stripsQueryParams() throws Exception { - RouteDefinition route = addRouteAndGet(new RouteBuilder() { - @Override - public void configure() { - from("direct:input").routeId("test-uri-normalize") - .to("direct:target?timeout=5000&bridgeEndpoint=true"); - } - }, "test-uri-normalize"); - - RouteGraph graph = extractor.extractRoute(route); - - // Node should keep full URI - RouteNode directNode = graph.getNodes().stream() - .filter(n -> n.getType() == NodeType.DIRECT && n.getLabel().contains("target")) - .findFirst().orElseThrow(); - assertEquals("direct:target?timeout=5000&bridgeEndpoint=true", directNode.getEndpointUri()); - - // CROSS_ROUTE edge target should be normalized (no query params) - var crossRouteEdge = graph.getEdges().stream() - .filter(e -> e.getEdgeType() == EdgeType.CROSS_ROUTE) - .findFirst().orElseThrow(); - assertEquals("direct:target", crossRouteEdge.getTarget(), - "CROSS_ROUTE edge target should have query params stripped"); -} -``` - -- [ ] **Step 2.2: Run test to verify it fails** - -```bash -mvn test -pl cameleer-common,cameleer-agent -Dtest=RouteModelExtractorTest#extractRoute_crossRouteEdge_stripsQueryParams -DfailIfNoTests=false -``` - -Expected: FAIL — edge target contains `?timeout=5000&bridgeEndpoint=true`. - -- [ ] **Step 2.3: Implement URI normalization in processTo()** - -In `RouteModelExtractor.processTo()`, change the CROSS_ROUTE edge creation: - -```java -// Cross-route reference for direct/seda — emit CROSS_ROUTE edge -if (type == NodeType.DIRECT || type == NodeType.SEDA) { - node.addProperty("crossRoute", "true"); - // Strip query params for edge target — safe for direct:/seda: (no ? in path) - String targetRouteEndpoint = uri.split("\\?")[0]; - graph.addEdge(new RouteEdge(node.getId(), targetRouteEndpoint, uri, EdgeType.CROSS_ROUTE)); -} -``` - -The only change is adding `.split("\\?")[0]` to `targetRouteEndpoint`. - -- [ ] **Step 2.4: Run test to verify it passes** - -```bash -mvn test -pl cameleer-common,cameleer-agent -Dtest=RouteModelExtractorTest#extractRoute_crossRouteEdge_stripsQueryParams -DfailIfNoTests=false -``` - -Expected: PASS - -- [ ] **Step 2.5: Write additional test — seda: endpoint normalization** - -```java -@Test -void extractRoute_crossRouteEdge_sedaStripsQueryParams() throws Exception { - RouteDefinition route = addRouteAndGet(new RouteBuilder() { - @Override - public void configure() { - from("direct:input").routeId("test-seda-normalize") - .to("seda:queue?concurrentConsumers=5"); - } - }, "test-seda-normalize"); - - RouteGraph graph = extractor.extractRoute(route); - - var crossRouteEdge = graph.getEdges().stream() - .filter(e -> e.getEdgeType() == EdgeType.CROSS_ROUTE) - .findFirst().orElseThrow(); - assertEquals("seda:queue", crossRouteEdge.getTarget()); -} -``` - -- [ ] **Step 2.6: Run both URI tests** - -```bash -mvn test -pl cameleer-common,cameleer-agent -Dtest="RouteModelExtractorTest#extractRoute_crossRouteEdge_stripsQueryParams+extractRoute_crossRouteEdge_sedaStripsQueryParams" -DfailIfNoTests=false -``` - -Expected: PASS - -- [ ] **Step 2.7: Run full test suite** - -```bash -mvn test -pl cameleer-common,cameleer-agent -``` - -Expected: All tests pass. - -- [ ] **Step 2.8: Commit** - -```bash -git add cameleer-agent/src/main/java/com/cameleer/agent/diagram/RouteModelExtractor.java -git add cameleer-agent/src/test/java/com/cameleer/agent/diagram/RouteModelExtractorTest.java -git commit -m "fix: normalize cross-route edge URIs by stripping query parameters - -Closes #" -``` - ---- - -## Chunk 2: Fix 2 (onException Mapping) + Fix 6 (CircuitBreaker Fallback) - -### Task 3: Fix onException Handler Mapping (H2) - -Ref: Gitea issue #2 from step 0.2 - -**Files:** -- Modify: `cameleer-common/src/main/java/com/cameleer/common/model/ProcessorExecution.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/collector/ExecutionCollector.java` -- Modify: `cameleer-agent/src/test/java/com/cameleer/agent/diagram/RouteModelExtractorTest.java` -- Modify: `cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionCollectorTest.java` - -- [ ] **Step 3.1: Write failing test — onException processor IDs in mapping** - -Add to `RouteModelExtractorTest.java`: - -```java -@Test -void extractRoute_onException_processorsInMapping() throws Exception { - camelContext.setAutoStartup(true); - camelContext.addRoutes(new RouteBuilder() { - @Override - public void configure() { - onException(Exception.class) - .handled(true) - .log("Exception handled") - .to("mock:error"); - - from("direct:input").routeId("test-onexception-mapping") - .log("Processing") - .process(exchange -> { - throw new RuntimeException("test error"); - }); - } - }); - camelContext.start(); - - RouteDefinition route = ((ModelCamelContext) camelContext).getRouteDefinition("test-onexception-mapping"); - RouteGraph graph = extractor.extractRoute(route); - - Map mapping = graph.getProcessorNodeMapping(); - assertFalse(mapping.isEmpty(), "processorNodeMapping should not be empty"); - - // Verify onException node exists in the graph - assertTrue(graph.getNodes().stream() - .anyMatch(n -> n.getType() == NodeType.ON_EXCEPTION), - "Should have ON_EXCEPTION node"); - - // Verify all mapped nodeIds exist in graph - for (String nodeId : mapping.values()) { - assertTrue(graph.getNodes().stream().anyMatch(n -> n.getId().equals(nodeId)), - "Mapped nodeId " + nodeId + " should exist in graph nodes"); - } -} -``` - -- [ ] **Step 3.2: Run test** - -```bash -mvn test -pl cameleer-common,cameleer-agent -Dtest=RouteModelExtractorTest#extractRoute_onException_processorsInMapping -DfailIfNoTests=false -``` - -This test may pass already if `processChildren()` is correctly adding mappings. If it passes, good — we have coverage. If it fails, we fix the extractor. - -- [ ] **Step 3.3: Add errorHandlerType field to ProcessorExecution** - -In `ProcessorExecution.java`, add the field after `splitDepth`: - -```java -private int splitDepth; -private String errorHandlerType; -private List children; -``` - -Add getter/setter after the `splitDepth` getter/setter: - -```java -public String getErrorHandlerType() { return errorHandlerType; } -public void setErrorHandlerType(String errorHandlerType) { this.errorHandlerType = errorHandlerType; } -``` - -- [ ] **Step 3.4: Write failing test — errorHandlerType set on execution** - -Add to `ExecutionCollectorTest.java`: - -```java -@Test -void onProcessorStart_errorHandlerContext_setsErrorHandlerType() { - setupExchangeProperties(exchange); - when(exchange.getExchangeId()).thenReturn("ex-err-handler"); - when(exchange.getFromRouteId()).thenReturn("test-route"); - when(message.getHeader("X-Cameleer-CorrelationId", String.class)).thenReturn("corr-err"); - - collector.onExchangeCreated(exchange); - - // Simulate a normal processor failing - collector.onProcessorStart(exchange, "process1", "process"); - collector.onProcessorFailed(exchange, "process1", new RuntimeException("boom")); - - // Simulate Camel setting FAILURE_HANDLED + EXCEPTION_CAUGHT and running onException handler processors - exchange.setProperty(Exchange.FAILURE_HANDLED, Boolean.TRUE); - exchange.setProperty(Exchange.EXCEPTION_CAUGHT, new RuntimeException("boom")); - collector.onProcessorStart(exchange, "log-handler", "log"); - collector.onProcessorComplete(exchange, "log-handler", 100_000L); - - collector.onExchangeCompleted(exchange); - - ArgumentCaptor captor = ArgumentCaptor.forClass(RouteExecution.class); - verify(exporter).exportExecution(captor.capture()); - - RouteExecution execution = captor.getValue(); - // Find the error handler processor - ProcessorExecution handlerProc = execution.getProcessors().stream() - .filter(p -> "log-handler".equals(p.getProcessorId())) - .findFirst().orElse(null); - - // If not at top level, search children - if (handlerProc == null) { - for (ProcessorExecution proc : execution.getProcessors()) { - handlerProc = proc.getChildren().stream() - .filter(p -> "log-handler".equals(p.getProcessorId())) - .findFirst().orElse(null); - if (handlerProc != null) break; - } - } - - assertNotNull(handlerProc, "Should find the error handler processor"); - assertEquals("onException", handlerProc.getErrorHandlerType()); -} -``` - -- [ ] **Step 3.5: Run test to verify it fails** - -```bash -mvn test -pl cameleer-common,cameleer-agent -Dtest=ExecutionCollectorTest#onProcessorStart_errorHandlerContext_setsErrorHandlerType -DfailIfNoTests=false -``` - -Expected: FAIL — `errorHandlerType` is never set. - -- [ ] **Step 3.6: Implement error handler detection in ExecutionCollector** - -In `ExecutionCollector.onProcessorStart()`, after the diagramNodeId debug logging (added in step 1.4), add: - -```java -// Detect error handler context. -// Note: Exchange.FAILURE_HANDLED may persist after the onException handler finishes. -// Camel also sets Exchange.EXCEPTION_CAUGHT when an exception is being handled. -// We check both: FAILURE_HANDLED=true AND EXCEPTION_CAUGHT is non-null. -// After the handler completes and normal flow resumes, EXCEPTION_CAUGHT is typically -// cleared. If this proves unreliable at runtime, we should track error handler -// entry/exit state explicitly (a known limitation to revisit). -Boolean failureHandled = exchange.getProperty(Exchange.FAILURE_HANDLED, Boolean.class); -Exception caughtException = exchange.getProperty(Exchange.EXCEPTION_CAUGHT, Exception.class); -if (Boolean.TRUE.equals(failureHandled) && caughtException != null) { - processorExec.setErrorHandlerType("onException"); -} -``` - -- [ ] **Step 3.7: Run test to verify it passes** - -```bash -mvn test -pl cameleer-common,cameleer-agent -Dtest=ExecutionCollectorTest#onProcessorStart_errorHandlerContext_setsErrorHandlerType -DfailIfNoTests=false -``` - -Expected: PASS - -- [ ] **Step 3.8: Run full test suite** - -```bash -mvn test -pl cameleer-common,cameleer-agent -``` - -Expected: All tests pass. - -- [ ] **Step 3.9: Commit** - -```bash -git add cameleer-common/src/main/java/com/cameleer/common/model/ProcessorExecution.java -git add cameleer-agent/src/main/java/com/cameleer/agent/collector/ExecutionCollector.java -git add cameleer-agent/src/test/java/com/cameleer/agent/diagram/RouteModelExtractorTest.java -git add cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionCollectorTest.java -git commit -m "fix: verify onException mapping and tag error handler executions - -Adds errorHandlerType field to ProcessorExecution. -Detects FAILURE_HANDLED exchange property to tag onException context. -Closes #" -``` - -### Task 4: Fix CircuitBreaker Fallback Mapping (M6) - -Ref: Gitea issue #6 from step 0.2 - -**Files:** -- Modify: `cameleer-agent/src/test/java/com/cameleer/agent/diagram/RouteModelExtractorTest.java` -- Possibly modify: `cameleer-agent/src/main/java/com/cameleer/agent/diagram/RouteModelExtractor.java` - -- [ ] **Step 4.1: Write test — circuitBreaker fallback processor IDs in mapping** - -Note: `circuitBreaker()` requires `camel-resilience4j` on classpath. Check if it's already a test dependency. If not, this test must use a mock/stub approach instead. If the dependency is not available, write the test to verify extraction structure only (without starting context, so no processor IDs). - -Add to `RouteModelExtractorTest.java`: - -```java -@Test -void extractRoute_circuitBreakerFallback_nodesInGraph() throws Exception { - RouteDefinition route = addRouteAndGet(new RouteBuilder() { - @Override - public void configure() { - from("direct:input").routeId("test-cb-fallback") - .circuitBreaker() - .to("mock:service") - .onFallback() - .log("Fallback triggered") - .to("mock:fallback") - .end(); - } - }, "test-cb-fallback"); - - RouteGraph graph = extractor.extractRoute(route); - - // Verify circuitBreaker node exists - assertTrue(graph.getNodes().stream() - .anyMatch(n -> n.getType() == NodeType.EIP_CIRCUIT_BREAKER), - "Should have CIRCUIT_BREAKER node"); - - // Verify fallback node exists - assertTrue(graph.getNodes().stream() - .anyMatch(n -> n.getLabel().contains("onFallback")), - "Should have onFallback node"); - - // Verify BRANCH edge from circuitBreaker to fallback - RouteNode cbNode = graph.getNodes().stream() - .filter(n -> n.getType() == NodeType.EIP_CIRCUIT_BREAKER) - .findFirst().orElseThrow(); - assertTrue(graph.getEdges().stream() - .anyMatch(e -> e.getSource().equals(cbNode.getId()) - && e.getEdgeType() == EdgeType.BRANCH - && "fallback".equals(e.getLabel())), - "Should have BRANCH edge with 'fallback' label from circuit breaker"); - - // Verify fallback children (log, to) are in the graph - long logCount = graph.getNodes().stream() - .filter(n -> n.getType() == NodeType.LOG && n.getLabel().contains("Fallback")) - .count(); - assertTrue(logCount >= 1, "Should have fallback log node"); -} -``` - -- [ ] **Step 4.2: Run test** - -```bash -mvn test -pl cameleer-common,cameleer-agent -Dtest=RouteModelExtractorTest#extractRoute_circuitBreakerFallback_nodesInGraph -DfailIfNoTests=false -``` - -This should PASS (extraction already handles circuitBreaker). If it fails, fix the extraction. - -- [ ] **Step 4.3: Write test — fallback processor IDs in mapping (requires context start)** - -If `camel-resilience4j` is available as test dependency: - -```java -@Test -void extractRoute_circuitBreakerFallback_processorIdsInMapping() throws Exception { - camelContext.setAutoStartup(true); - camelContext.addRoutes(new RouteBuilder() { - @Override - public void configure() { - from("direct:input").routeId("test-cb-mapping") - .circuitBreaker() - .to("mock:service") - .onFallback() - .log("Fallback") - .to("mock:fallback") - .end(); - } - }); - camelContext.start(); - - RouteDefinition route = ((ModelCamelContext) camelContext).getRouteDefinition("test-cb-mapping"); - RouteGraph graph = extractor.extractRoute(route); - - Map mapping = graph.getProcessorNodeMapping(); - assertFalse(mapping.isEmpty(), "processorNodeMapping should not be empty"); - - // All mapped nodeIds should exist in graph - for (String nodeId : mapping.values()) { - assertTrue(graph.getNodes().stream().anyMatch(n -> n.getId().equals(nodeId)), - "Mapped nodeId " + nodeId + " should exist in graph nodes"); - } -} -``` - -If `camel-resilience4j` is NOT available, skip this test and note it as a known gap in the commit message. - -- [ ] **Step 4.4: Run tests** - -```bash -mvn test -pl cameleer-common,cameleer-agent -Dtest="RouteModelExtractorTest#extractRoute_circuitBreakerFallback*" -DfailIfNoTests=false -``` - -Expected: PASS - -- [ ] **Step 4.5: Run full test suite** - -```bash -mvn test -pl cameleer-common,cameleer-agent -``` - -Expected: All tests pass. - -- [ ] **Step 4.6: Commit** - -```bash -git add cameleer-agent/src/test/java/com/cameleer/agent/diagram/RouteModelExtractorTest.java -git commit -m "test: verify circuitBreaker fallback processor mapping - -Closes #" -``` - ---- - -## Chunk 3: Fix 3 (Loop Iteration Tracking) - -### Task 5: Add Loop Fields to ProcessorExecution Model - -Ref: Gitea issue #3 from step 0.2 - -**Files:** -- Modify: `cameleer-common/src/main/java/com/cameleer/common/model/ProcessorExecution.java` - -- [ ] **Step 5.1: Add loopIndex and loopSize fields** - -In `ProcessorExecution.java`, add after `errorHandlerType`: - -```java -private String errorHandlerType; -private Integer loopIndex; -private Integer loopSize; -private List children; -``` - -Add getter/setter: - -```java -public Integer getLoopIndex() { return loopIndex; } -public void setLoopIndex(Integer loopIndex) { this.loopIndex = loopIndex; } -public Integer getLoopSize() { return loopSize; } -public void setLoopSize(Integer loopSize) { this.loopSize = loopSize; } -``` - -- [ ] **Step 5.2: Verify compile** - -```bash -mvn compile -pl cameleer-common -``` - -Expected: BUILD SUCCESS - -- [ ] **Step 5.3: Commit model change** - -```bash -git add cameleer-common/src/main/java/com/cameleer/common/model/ProcessorExecution.java -git commit -m "feat: add loopIndex and loopSize fields to ProcessorExecution" -``` - -### Task 6: Implement Loop Iteration Tracking in ExecutionCollector - -**Files:** -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/collector/ExecutionCollector.java` -- Modify: `cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionCollectorTest.java` - -- [ ] **Step 6.1: Write failing test — counted loop produces iteration wrappers** - -Add to `ExecutionCollectorTest.java`: - -```java -@Test -void loopExecution_producesIterationWrappers() { - setupExchangeProperties(exchange); - when(exchange.getExchangeId()).thenReturn("loop-parent"); - when(exchange.getFromRouteId()).thenReturn("test-loop-route"); - when(message.getHeader("X-Cameleer-CorrelationId", String.class)).thenReturn("corr-loop"); - - collector.onExchangeCreated(exchange); - - // Loop processor starts - collector.onProcessorStart(exchange, "loop1", "loop"); - - // Simulate 3 loop iterations — loop reuses the same exchange - // Camel sets CamelLoopIndex and CamelLoopSize on the exchange - for (int i = 0; i < 3; i++) { - exchange.setProperty("CamelLoopIndex", i); - exchange.setProperty("CamelLoopSize", 3); - - collector.onProcessorStart(exchange, "log-in-loop", "log"); - collector.onProcessorComplete(exchange, "log-in-loop", 500_000L); - } - - // Clear loop properties when loop completes - exchange.setProperty("CamelLoopIndex", null); - exchange.setProperty("CamelLoopSize", null); - - // Loop processor completes - collector.onProcessorComplete(exchange, "loop1", 5_000_000L); - - collector.onExchangeCompleted(exchange); - - ArgumentCaptor captor = ArgumentCaptor.forClass(RouteExecution.class); - verify(exporter).exportExecution(captor.capture()); - - RouteExecution execution = captor.getValue(); - assertEquals(1, execution.getProcessors().size()); - ProcessorExecution loopProc = execution.getProcessors().get(0); - assertEquals("loop", loopProc.getProcessorType()); - - // Loop should have 3 iteration wrapper children - List iterations = loopProc.getChildren(); - assertEquals(3, iterations.size()); - - for (int i = 0; i < 3; i++) { - ProcessorExecution iterWrapper = iterations.get(i); - assertEquals("loopIteration", iterWrapper.getProcessorType()); - assertEquals(i, iterWrapper.getLoopIndex()); - assertEquals(3, iterWrapper.getLoopSize()); - - // Each iteration should contain the log processor - assertEquals(1, iterWrapper.getChildren().size()); - assertEquals("log", iterWrapper.getChildren().get(0).getProcessorType()); - } -} -``` - -- [ ] **Step 6.2: Run test to verify it fails** - -```bash -mvn test -pl cameleer-common,cameleer-agent -Dtest=ExecutionCollectorTest#loopExecution_producesIterationWrappers -DfailIfNoTests=false -``` - -Expected: FAIL — no loop iteration handling exists. - -- [ ] **Step 6.3: Implement loop iteration tracking** - -In `ExecutionCollector.java`: - -1. Add constants and data structure after existing fields: - -```java -// Loop iteration tracking constants -private static final String LOOP_INDEX_KEY = "CamelLoopIndex"; -private static final String LOOP_SIZE_KEY = "CamelLoopSize"; - -// Active loop state: exchangeId → LoopState -private final Map activeLoops = new ConcurrentHashMap<>(); - -private static class LoopState { - final ProcessorExecution loopProcessor; - final int stackDepthAtEntry; - int currentIndex = -1; - ProcessorExecution currentIterationWrapper; - - LoopState(ProcessorExecution loopProcessor, int stackDepthAtEntry) { - this.loopProcessor = loopProcessor; - this.stackDepthAtEntry = stackDepthAtEntry; - } -} -``` - -2. In `onProcessorStart()`, after the split sub-exchange detection block (after the `if (splitIndex != null)` block around line 202), add loop detection: - -```java -// Detect loop iteration -Integer loopIndex = exchange.getProperty(LOOP_INDEX_KEY, Integer.class); -Integer loopSize = exchange.getProperty(LOOP_SIZE_KEY, Integer.class); -``` - -3. After the stack is obtained (`computeIfAbsent` around line 226) and after the split iteration wrapper block (ending around line 252), add loop iteration wrapper logic: - -```java -// If inside a loop and iteration index changed, create iteration wrapper. -// IMPORTANT: Unlike split (which uses separate sub-exchanges with separate stacks), -// loop reuses the same exchange and same stack. Old iteration wrappers must be -// explicitly popped before pushing the new one. -// -// Stack state across iterations: -// Iteration 0 entry: [loop] (size = depth+1) → push iter-0 → [loop, iter-0] -// Iteration 0 body: [loop, iter-0, log] → log completes → [loop, iter-0] -// Iteration 1 entry: [loop, iter-0] (size = depth+2) → pop iter-0, push iter-1 -// ... -// Loop completes: pop last iter wrapper, then pop loop processor -if (loopIndex != null) { - LoopState loopState = activeLoops.get(exchangeId); - if (loopState != null) { - // Compute expected stack depth at the loop's direct child level: - // - First iteration (no existing wrapper): depth + 1 (just the loop processor) - // - Subsequent iterations (old wrapper on stack): depth + 2 - int expectedDepth = loopState.stackDepthAtEntry + 1; - if (loopState.currentIterationWrapper != null) { - expectedDepth = loopState.stackDepthAtEntry + 2; - } - if (stack.size() == expectedDepth && loopIndex != loopState.currentIndex) { - // Pop and complete previous iteration wrapper if exists - if (loopState.currentIterationWrapper != null) { - if (loopState.currentIterationWrapper.getStatus() == ExecutionStatus.RUNNING) { - loopState.currentIterationWrapper.complete(); - } - // Pop the old wrapper — brings stack back to [loop] (depth+1) - stack.pop(); - } - // Create new iteration wrapper - ProcessorExecution iterWrapper = new ProcessorExecution( - loopState.loopProcessor.getProcessorId() + "-iter-" + loopIndex, "loopIteration"); - iterWrapper.setLoopIndex(loopIndex); - iterWrapper.setLoopSize(loopSize); - loopState.loopProcessor.addChild(iterWrapper); - loopState.currentIndex = loopIndex; - loopState.currentIterationWrapper = iterWrapper; - stack.push(iterWrapper); - } - } -} -``` - -4. After the "Track active split processors" block (around line 281), add loop processor tracking: - -```java -// Track active loop processors -if ("loop".equals(processorType)) { - activeLoops.put(exchangeId, new LoopState(processorExec, stack.size() - 1)); -} -``` - -5. In `onProcessorComplete()`, the loop processor is popped from the stack by the normal `stack.pop()` at the top of `onProcessorComplete()`. But the last iteration wrapper may still be on the stack above it. To handle this correctly, **before the normal pop**, check if we're completing a loop and pop/complete the last iteration wrapper first. Add this BEFORE the existing `ProcessorExecution processorExec = stack.pop();` line, guarded by a check: - -```java -// If completing a loop processor, pop the last iteration wrapper first -LoopState completingLoop = activeLoops.get(exchangeId); -if (completingLoop != null && completingLoop.currentIterationWrapper != null - && stack.peek() == completingLoop.currentIterationWrapper) { - ProcessorExecution lastIter = stack.pop(); - if (lastIter.getStatus() == ExecutionStatus.RUNNING) { - lastIter.complete(); - } -} -``` - -Then, after the normal pop + complete, add the cleanup: - -```java -// Clean up loop state -if ("loop".equals(processorExec.getProcessorType())) { - activeLoops.remove(exchangeId); -} -``` - -6. In `onProcessorFailed()`, add cleanup: - -```java -if ("loop".equals(processorExec.getProcessorType())) { - activeLoops.remove(exchangeId); -} -``` - -7. In `evictStaleEntries()`, add cleanup: - -```java -activeLoops.keySet().retainAll(activeExecutions.keySet()); -``` - -- [ ] **Step 6.4: Run test to verify it passes** - -```bash -mvn test -pl cameleer-common,cameleer-agent -Dtest=ExecutionCollectorTest#loopExecution_producesIterationWrappers -DfailIfNoTests=false -``` - -Expected: PASS - -- [ ] **Step 6.5: Write test — multiple processors per loop iteration** - -```java -@Test -void loopExecution_multipleProcessorsPerIteration_singleWrapper() { - setupExchangeProperties(exchange); - when(exchange.getExchangeId()).thenReturn("loop-multi"); - when(exchange.getFromRouteId()).thenReturn("test-loop-multi"); - when(message.getHeader("X-Cameleer-CorrelationId", String.class)).thenReturn("corr-lm"); - - collector.onExchangeCreated(exchange); - collector.onProcessorStart(exchange, "loop1", "loop"); - - for (int i = 0; i < 2; i++) { - exchange.setProperty("CamelLoopIndex", i); - exchange.setProperty("CamelLoopSize", 2); - - // Two processors per iteration - collector.onProcessorStart(exchange, "log1", "log"); - collector.onProcessorComplete(exchange, "log1", 100_000L); - collector.onProcessorStart(exchange, "setHeader1", "setHeader"); - collector.onProcessorComplete(exchange, "setHeader1", 100_000L); - } - - exchange.setProperty("CamelLoopIndex", null); - exchange.setProperty("CamelLoopSize", null); - collector.onProcessorComplete(exchange, "loop1", 5_000_000L); - collector.onExchangeCompleted(exchange); - - ArgumentCaptor captor = ArgumentCaptor.forClass(RouteExecution.class); - verify(exporter).exportExecution(captor.capture()); - - RouteExecution execution = captor.getValue(); - ProcessorExecution loopProc = execution.getProcessors().get(0); - assertEquals(2, loopProc.getChildren().size(), "Should have 2 iteration wrappers"); - - for (ProcessorExecution iter : loopProc.getChildren()) { - assertEquals("loopIteration", iter.getProcessorType()); - assertEquals(2, iter.getChildren().size(), "Each iteration should have 2 processors"); - } -} -``` - -- [ ] **Step 6.6: Write test — doWhile loop with null loopSize** - -```java -@Test -void loopExecution_doWhile_nullLoopSize() { - setupExchangeProperties(exchange); - when(exchange.getExchangeId()).thenReturn("loop-dowhile"); - when(exchange.getFromRouteId()).thenReturn("test-dowhile"); - when(message.getHeader("X-Cameleer-CorrelationId", String.class)).thenReturn("corr-dw"); - - collector.onExchangeCreated(exchange); - collector.onProcessorStart(exchange, "loop1", "loop"); - - // doWhile: CamelLoopSize is NOT set - for (int i = 0; i < 2; i++) { - exchange.setProperty("CamelLoopIndex", i); - // No CamelLoopSize for doWhile - - collector.onProcessorStart(exchange, "log1", "log"); - collector.onProcessorComplete(exchange, "log1", 100_000L); - } - - exchange.setProperty("CamelLoopIndex", null); - collector.onProcessorComplete(exchange, "loop1", 3_000_000L); - collector.onExchangeCompleted(exchange); - - ArgumentCaptor captor = ArgumentCaptor.forClass(RouteExecution.class); - verify(exporter).exportExecution(captor.capture()); - - RouteExecution execution = captor.getValue(); - ProcessorExecution loopProc = execution.getProcessors().get(0); - assertEquals(2, loopProc.getChildren().size()); - - for (ProcessorExecution iter : loopProc.getChildren()) { - assertEquals("loopIteration", iter.getProcessorType()); - assertNull(iter.getLoopSize(), "doWhile loop should have null loopSize"); - } -} -``` - -- [ ] **Step 6.7: Write test — zero iterations (loop body never executes)** - -```java -@Test -void loopExecution_zeroIterations_noWrappers() { - setupExchangeProperties(exchange); - when(exchange.getExchangeId()).thenReturn("loop-zero"); - when(exchange.getFromRouteId()).thenReturn("test-loop-zero"); - when(message.getHeader("X-Cameleer-CorrelationId", String.class)).thenReturn("corr-zero"); - - collector.onExchangeCreated(exchange); - collector.onProcessorStart(exchange, "loop1", "loop"); - - // Loop with 0 iterations — loop processor starts and immediately completes - // No CamelLoopIndex is ever set - collector.onProcessorComplete(exchange, "loop1", 100_000L); - collector.onExchangeCompleted(exchange); - - ArgumentCaptor captor = ArgumentCaptor.forClass(RouteExecution.class); - verify(exporter).exportExecution(captor.capture()); - - RouteExecution execution = captor.getValue(); - ProcessorExecution loopProc = execution.getProcessors().get(0); - assertEquals("loop", loopProc.getProcessorType()); - assertTrue(loopProc.getChildren().isEmpty(), "Zero-iteration loop should have no children"); -} -``` - -- [ ] **Step 6.8: Write test — loop with error in iteration N** - -```java -@Test -void loopExecution_errorInIteration_partialWrappers() { - setupExchangeProperties(exchange); - when(exchange.getExchangeId()).thenReturn("loop-err"); - when(exchange.getFromRouteId()).thenReturn("test-loop-err"); - when(message.getHeader("X-Cameleer-CorrelationId", String.class)).thenReturn("corr-lerr"); - - collector.onExchangeCreated(exchange); - collector.onProcessorStart(exchange, "loop1", "loop"); - - // Iteration 0 succeeds - exchange.setProperty("CamelLoopIndex", 0); - exchange.setProperty("CamelLoopSize", 3); - collector.onProcessorStart(exchange, "log1", "log"); - collector.onProcessorComplete(exchange, "log1", 100_000L); - - // Iteration 1 fails - exchange.setProperty("CamelLoopIndex", 1); - collector.onProcessorStart(exchange, "process1", "process"); - collector.onProcessorFailed(exchange, "process1", new RuntimeException("error in iteration 1")); - - // Loop processor fails - exchange.setProperty("CamelLoopIndex", null); - exchange.setProperty("CamelLoopSize", null); - collector.onProcessorFailed(exchange, "loop1", new RuntimeException("loop failed")); - - collector.onExchangeFailed(exchange); - - ArgumentCaptor captor = ArgumentCaptor.forClass(RouteExecution.class); - verify(exporter).exportExecution(captor.capture()); - - RouteExecution execution = captor.getValue(); - ProcessorExecution loopProc = execution.getProcessors().get(0); - // Should have 2 iteration wrappers (0 succeeded, 1 failed) - assertEquals(2, loopProc.getChildren().size()); - assertEquals("loopIteration", loopProc.getChildren().get(0).getProcessorType()); - assertEquals("loopIteration", loopProc.getChildren().get(1).getProcessorType()); -} -``` - -- [ ] **Step 6.9: Run all loop tests** - -```bash -mvn test -pl cameleer-common,cameleer-agent -Dtest="ExecutionCollectorTest#loopExecution*" -DfailIfNoTests=false -``` - -Expected: All PASS - -- [ ] **Step 6.10: Run full test suite** - -```bash -mvn test -pl cameleer-common,cameleer-agent -``` - -Expected: All tests pass. - -- [ ] **Step 6.11: Commit** - -```bash -git add cameleer-agent/src/main/java/com/cameleer/agent/collector/ExecutionCollector.java -git add cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionCollectorTest.java -git commit -m "feat: add loop iteration tracking with loopIteration wrappers - -Mirrors split iteration pattern. Supports counted loop() and loopDoWhile(). -Uses CamelLoopIndex/CamelLoopSize exchange properties for detection. -Stack depth tracking prevents false iteration boundaries from nested processors. -Closes #" -``` - ---- - -## Chunk 4: Fix 5 (Multicast Branch Tracking) - -### Task 7: Research Multicast Properties and Implement - -Ref: Gitea issue #5 from step 0.2 - -**Files:** -- Modify: `cameleer-common/src/main/java/com/cameleer/common/model/ProcessorExecution.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/collector/ExecutionCollector.java` -- Modify: `cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionCollectorTest.java` - -- [ ] **Step 7.1: Research Camel multicast exchange properties** - -Check Camel 4.x source for multicast sub-exchange properties. Look for: -- `Exchange.MULTICAST_INDEX` or `CamelMulticastIndex` constant -- Properties set by `MulticastProcessor` on sub-exchanges -- Whether `CamelParentExchangeId` is set on multicast sub-exchanges - -```bash -# Search Camel source/docs for multicast index property -cd cameleer-agent && mvn dependency:sources -Dartifact=org.apache.camel:camel-core-processor 2>/dev/null -# Then grep for multicast in Camel source JARs -``` - -Alternatively, use the Context7 MCP tool to look up Camel 4.x documentation for `MulticastProcessor` exchange properties. - -**If `CamelMulticastIndex` IS set by Camel 4.x (at least with parallelProcessing):** proceed to step 7.2. - -**If `CamelMulticastIndex` is NOT set:** skip to step 7.7 (document limitation and commit). - -- [ ] **Step 7.2: Add multicastIndex field to ProcessorExecution** - -In `ProcessorExecution.java`, add: - -```java -private Integer loopSize; -private Integer multicastIndex; -private List children; -``` - -Add getter/setter: - -```java -public Integer getMulticastIndex() { return multicastIndex; } -public void setMulticastIndex(Integer multicastIndex) { this.multicastIndex = multicastIndex; } -``` - -- [ ] **Step 7.3: Write failing test — multicast with parallelProcessing** - -```java -@Test -void multicastExecution_parallelProcessing_producesBranchWrappers() { - setupExchangeProperties(exchange); - when(exchange.getExchangeId()).thenReturn("mc-parent"); - when(exchange.getFromRouteId()).thenReturn("test-multicast-route"); - when(message.getHeader("X-Cameleer-CorrelationId", String.class)).thenReturn("corr-mc"); - - collector.onExchangeCreated(exchange); - - // Multicast processor starts - collector.onProcessorStart(exchange, "multicast1", "multicast"); - - // Two multicast branches as sub-exchanges - for (int i = 0; i < 2; i++) { - Exchange subExchange = mock(Exchange.class); - Message subMessage = mock(Message.class); - setupExchangeProperties(subExchange); - when(subExchange.getExchangeId()).thenReturn("mc-sub-" + i); - when(subExchange.getFromRouteId()).thenReturn("test-multicast-route"); - when(subExchange.getMessage()).thenReturn(subMessage); - lenient().when(subMessage.getHeaders()).thenReturn(new HashMap<>()); - - Map subProps = new HashMap<>(); - subProps.put("CamelMulticastIndex", i); - subProps.put("CameleerRouteExecution", exchange.getProperty("CameleerRouteExecution")); - subProps.put(Exchange.CORRELATION_ID, "mc-parent"); - - lenient().when(subExchange.getProperty(anyString())).thenAnswer(inv -> subProps.get(inv.getArgument(0))); - lenient().when(subExchange.getProperty(anyString(), any(Class.class))).thenAnswer(inv -> subProps.get(inv.getArgument(0))); - lenient().doAnswer(inv -> { - subProps.put(inv.getArgument(0), inv.getArgument(1)); - return null; - }).when(subExchange).setProperty(anyString(), any()); - - collector.onProcessorStart(subExchange, "log-branch-" + i, "log"); - collector.onProcessorComplete(subExchange, "log-branch-" + i, 500_000L); - } - - collector.onProcessorComplete(exchange, "multicast1", 5_000_000L); - collector.onExchangeCompleted(exchange); - - ArgumentCaptor captor = ArgumentCaptor.forClass(RouteExecution.class); - verify(exporter).exportExecution(captor.capture()); - - RouteExecution execution = captor.getValue(); - assertEquals(1, execution.getProcessors().size()); - ProcessorExecution mcProc = execution.getProcessors().get(0); - assertEquals("multicast", mcProc.getProcessorType()); - - // Should have 2 branch wrapper children - List branches = mcProc.getChildren(); - assertEquals(2, branches.size()); - - for (int i = 0; i < 2; i++) { - ProcessorExecution branch = branches.get(i); - assertEquals("multicastBranch", branch.getProcessorType()); - assertEquals(i, branch.getMulticastIndex()); - assertEquals(1, branch.getChildren().size()); - } -} -``` - -- [ ] **Step 7.4: Run test to verify it fails** - -```bash -mvn test -pl cameleer-common,cameleer-agent -Dtest=ExecutionCollectorTest#multicastExecution_parallelProcessing_producesBranchWrappers -DfailIfNoTests=false -``` - -Expected: FAIL - -- [ ] **Step 7.5: Implement multicast branch tracking** - -In `ExecutionCollector.java`: - -1. Add constants and data structure: - -```java -private static final String MULTICAST_INDEX_KEY = "CamelMulticastIndex"; -private final Map activeMulticasts = new ConcurrentHashMap<>(); -private final Map> multicastSubExchanges = new ConcurrentHashMap<>(); -``` - -2. In `onProcessorStart()`, after the loop detection block, add multicast detection. This mirrors the split sub-exchange pattern: - -```java -// Detect multicast sub-exchange (parallel processing only) -Integer multicastIndex = exchange.getProperty(MULTICAST_INDEX_KEY, Integer.class); -if (multicastIndex != null && splitIndex == null) { - // This is a multicast sub-exchange, not a split sub-exchange - processorExec.setMulticastIndex(multicastIndex); -} -``` - -3. In the split iteration wrapper creation block, extend to also handle multicast. After the existing `if (splitIndex != null && stack.isEmpty())` block, add: - -```java -// If this is a multicast sub-exchange and the stack is empty, create a branch wrapper -if (multicastIndex != null && splitIndex == null && stack.isEmpty()) { - ProcessorExecution branchWrapper = new ProcessorExecution( - processorId + "-branch-" + multicastIndex, "multicastBranch"); - branchWrapper.setMulticastIndex(multicastIndex); - - String parentExchangeId = findParentExchangeId(exchange); - if (parentExchangeId != null) { - ProcessorExecution parentMulticast = activeMulticasts.get(parentExchangeId); - if (parentMulticast != null) { - parentMulticast.addChild(branchWrapper); - } - multicastSubExchanges.computeIfAbsent(parentExchangeId, k -> ConcurrentHashMap.newKeySet()).add(exchangeId); - } - - stack.push(branchWrapper); -} -``` - -4. After the split tracking block, add multicast tracking: - -```java -if ("multicast".equals(processorType)) { - activeMulticasts.put(exchangeId, processorExec); -} -``` - -5. In `onProcessorComplete()`, after the loop cleanup block, add multicast cleanup: - -```java -if ("multicast".equals(processorExec.getProcessorType())) { - for (ProcessorExecution child : processorExec.getChildren()) { - if ("multicastBranch".equals(child.getProcessorType()) - && child.getStatus() == ExecutionStatus.RUNNING) { - child.complete(); - } - } - Set subExIds = multicastSubExchanges.remove(exchangeId); - if (subExIds != null) { - for (String subExId : subExIds) { - processorStacks.remove(subExId); - } - } - activeMulticasts.remove(exchangeId); -} -``` - -6. In `onProcessorFailed()`, add cleanup: - -```java -if ("multicast".equals(processorExec.getProcessorType())) { - activeMulticasts.remove(exchangeId); -} -``` - -7. In `evictStaleEntries()`, add cleanup: - -```java -activeMulticasts.keySet().retainAll(activeExecutions.keySet()); -multicastSubExchanges.keySet().retainAll(activeExecutions.keySet()); -``` - -8. In `onExchangeCreated()`, skip multicast sub-exchanges alongside split: - -```java -if (exchange.getProperty(Exchange.SPLIT_INDEX) != null - || exchange.getProperty(MULTICAST_INDEX_KEY) != null) { - return; -} -``` - -- [ ] **Step 7.6: Run test to verify it passes** - -```bash -mvn test -pl cameleer-common,cameleer-agent -Dtest=ExecutionCollectorTest#multicastExecution_parallelProcessing_producesBranchWrappers -DfailIfNoTests=false -``` - -Expected: PASS - -- [ ] **Step 7.7: Run full test suite** - -```bash -mvn test -pl cameleer-common,cameleer-agent -``` - -Expected: All tests pass. Pay special attention to existing split tests — the new multicast sub-exchange detection must not interfere with split detection (guarded by `splitIndex == null` check). - -- [ ] **Step 7.8: Commit** - -```bash -git add cameleer-common/src/main/java/com/cameleer/common/model/ProcessorExecution.java -git add cameleer-agent/src/main/java/com/cameleer/agent/collector/ExecutionCollector.java -git add cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionCollectorTest.java -git commit -m "feat: add multicast branch tracking for parallelProcessing multicasts - -Uses CamelMulticastIndex exchange property to detect multicast sub-exchanges. -Creates multicastBranch wrappers mirroring split's splitIteration pattern. -Sequential inline multicasts remain untracked (known Camel limitation). -Closes #" -``` - ---- - -## Chunk 5: Final Verification - -### Task 8: Full Build and Push - -- [ ] **Step 8.1: Run full project build with tests** - -```bash -mvn clean verify -``` - -Expected: BUILD SUCCESS with all tests passing. - -- [ ] **Step 8.2: Push branch** - -```bash -git push -u origin fix/overlay-correctness -``` - -- [ ] **Step 8.3: Update Gitea issues** - -Link commits to their respective issues. Verify all 6 issues reference the correct commits. diff --git a/docs/superpowers/plans/2026-03-27-agent-feature-pack.md b/docs/superpowers/plans/2026-03-27-agent-feature-pack.md deleted file mode 100644 index f474704..0000000 --- a/docs/superpowers/plans/2026-03-27-agent-feature-pack.md +++ /dev/null @@ -1,1105 +0,0 @@ -# Agent Feature Pack Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Implement 5 agent features: error classification, sampling, circuit breaker detection, startup self-test + health + events, and OTel hybrid mode. - -**Architecture:** Features 1–3 are independent model/collector changes. Feature 4 adds health infrastructure and a new `AgentEvent` export type. Feature 5 (OTel) adds a shaded SDK and bridge layer. All features are additive — no breaking changes to existing behavior. - -**Tech Stack:** Java 17, Apache Camel 4.10, Mockito, JUnit 5, OpenTelemetry SDK 1.x, `com.sun.net.httpserver` - -**Spec:** `docs/superpowers/specs/2026-03-27-agent-feature-pack-design.md` - ---- - -## File Map - -### New Files -| File | Responsibility | -|------|---------------| -| `common/model/ErrorClassifier.java` | Exception classification + root cause extraction | -| `common/model/AgentEvent.java` | Agent lifecycle event model | -| `agent/health/StartupReport.java` | Startup checks + structured log output | -| `agent/health/HealthEndpoint.java` | HTTP `/cameleer/health` endpoint | -| `agent/otel/OtelBridge.java` | OTel span creation / trace correlation | - -### Modified Files -| File | Changes | -|------|---------| -| `common/model/RouteExecution.java` | +4 error fields, +2 OTel fields | -| `common/model/ProcessorExecution.java` | +4 error fields, +2 CB fields | -| `common/model/ApplicationConfig.java` | +`routeSamplingRates` field | -| `agent/CameleerAgentConfig.java` | +sampling fields, +health config, +otel config | -| `agent/collector/ExecutionCollector.java` | +sampling check, +CB state read, +OTel hooks | -| `agent/command/DefaultCommandHandler.java` | +sampling wiring, +CONFIG_APPLIED event | -| `agent/export/Exporter.java` | +`exportEvent()` default method | -| `agent/export/HttpExporter.java` | +event queue + flush | -| `agent/notifier/CameleerEventNotifier.java` | +route lifecycle events to server | -| `agent/instrumentation/CameleerHookInstaller.java` | +startup report, +health endpoint, +OTel init | -| `agent/metrics/PrometheusEndpoint.java` | +expose HttpServer for health reuse | -| `agent/pom.xml` | +OTel SDK dependencies + shade relocation | - -### Test Files -| File | Tests | -|------|-------| -| `common/test/ErrorClassifierTest.java` | **NEW** — classification categories, root cause chain | -| `agent/test/collector/ExecutionCollectorTest.java` | +sampling, +CB state detection | -| `agent/test/command/DefaultCommandHandlerTest.java` | +sampling rate config-update | -| `agent/test/health/StartupReportTest.java` | **NEW** — check pass/warn/fail logic | -| `agent/test/health/HealthEndpointTest.java` | **NEW** — JSON response, aggregate status | -| `agent/test/export/HttpExporterTest.java` | +event export batching | -| `agent/test/otel/OtelBridgeTest.java` | **NEW** — correlate mode, traceparent parsing | - ---- - -## Task 1: Error Classification - -**Files:** -- Create: `cameleer-common/src/main/java/com/cameleer/common/model/ErrorClassifier.java` -- Create: `cameleer-common/src/test/java/com/cameleer/common/model/ErrorClassifierTest.java` -- Modify: `cameleer-common/src/main/java/com/cameleer/common/model/RouteExecution.java:54-69` -- Modify: `cameleer-common/src/main/java/com/cameleer/common/model/ProcessorExecution.java:79-99` - -- [ ] **Step 1: Write ErrorClassifier tests** - -```java -package com.cameleer.common.model; - -import org.junit.jupiter.api.Test; -import java.net.ConnectException; -import java.net.SocketTimeoutException; -import java.sql.SQLException; -import java.io.FileNotFoundException; -import static org.junit.jupiter.api.Assertions.*; - -class ErrorClassifierTest { - - @Test - void classify_timeoutException() { - assertEquals(ErrorClassifier.Category.TIMEOUT, - ErrorClassifier.classify(new SocketTimeoutException("Read timed out"))); - } - - @Test - void classify_connectionException() { - assertEquals(ErrorClassifier.Category.CONNECTION, - ErrorClassifier.classify(new ConnectException("Connection refused"))); - } - - @Test - void classify_validationException() { - assertEquals(ErrorClassifier.Category.VALIDATION, - ErrorClassifier.classify(new IllegalArgumentException("bad input"))); - } - - @Test - void classify_securityException() { - assertEquals(ErrorClassifier.Category.SECURITY, - ErrorClassifier.classify(new SecurityException("access denied"))); - } - - @Test - void classify_resourceException() { - assertEquals(ErrorClassifier.Category.RESOURCE, - ErrorClassifier.classify(new SQLException("too many connections"))); - } - - @Test - void classify_resourceFileException() { - assertEquals(ErrorClassifier.Category.RESOURCE, - ErrorClassifier.classify(new FileNotFoundException("/missing"))); - } - - @Test - void classify_unknownException() { - assertEquals(ErrorClassifier.Category.UNKNOWN, - ErrorClassifier.classify(new RuntimeException("something"))); - } - - @Test - void classify_wrappedCause_deepestMatchWins() { - // Outer: RuntimeException (UNKNOWN), Inner: SocketTimeoutException (TIMEOUT) - RuntimeException outer = new RuntimeException("wrapper", - new SocketTimeoutException("Read timed out")); - assertEquals(ErrorClassifier.Category.TIMEOUT, - ErrorClassifier.classify(outer)); - } - - @Test - void getRootCause_singleException() { - Exception e = new RuntimeException("only"); - assertSame(e, ErrorClassifier.getRootCause(e)); - } - - @Test - void getRootCause_chain() { - Exception root = new SocketTimeoutException("root"); - Exception middle = new RuntimeException("middle", root); - Exception outer = new RuntimeException("outer", middle); - assertSame(root, ErrorClassifier.getRootCause(outer)); - } - - @Test - void getRootCause_circularProtection() { - // Shouldn't infinite-loop on malicious circular cause chains - Exception e = new RuntimeException("self"); - // Can't easily create a circular chain, but verify no NPE on null cause - assertSame(e, ErrorClassifier.getRootCause(e)); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `mvn test -pl cameleer-common -Dtest=ErrorClassifierTest -f C:/Users/Hendrik/Documents/projects/cameleer/pom.xml` -Expected: Compilation failure (ErrorClassifier doesn't exist yet) - -- [ ] **Step 3: Implement ErrorClassifier** - -```java -package com.cameleer.common.model; - -import java.util.List; - -public class ErrorClassifier { - - public enum Category { - TIMEOUT, CONNECTION, VALIDATION, SECURITY, RESOURCE, UNKNOWN - } - - private static final List RULES = List.of( - new ClassificationRule(Category.TIMEOUT, - "TimeoutException", "TimedOutException"), - new ClassificationRule(Category.CONNECTION, - "ConnectException", "UnknownHostException", "NoRouteToHostException", - "ConnectionException", "ConnectionRefused"), - new ClassificationRule(Category.VALIDATION, - "IllegalArgumentException", "ValidationException", - "ParseException", "NumberFormatException"), - new ClassificationRule(Category.SECURITY, - "AccessDenied", "SecurityException", - "AuthenticationException", "AuthorizationException", "Unauthorized"), - new ClassificationRule(Category.RESOURCE, - "OutOfMemoryError", "StackOverflowError", - "FileNotFoundException", "NoSuchFileException", - "DiskQuotaExceededException", "SQLException") - ); - - /** - * Classify an exception by walking the cause chain. - * Deepest matching cause wins. Returns UNKNOWN if no pattern matches. - */ - public static Category classify(Throwable t) { - Category deepest = Category.UNKNOWN; - Throwable current = t; - int depth = 0; - while (current != null && depth < 50) { - String className = current.getClass().getName(); - for (ClassificationRule rule : RULES) { - if (rule.matches(className)) { - deepest = rule.category; - break; - } - } - current = current.getCause(); - depth++; - } - return deepest; - } - - /** - * Walk the cause chain to find the root cause. - * Guards against circular cause chains (max 50 depth). - */ - public static Throwable getRootCause(Throwable t) { - Throwable root = t; - int depth = 0; - while (root.getCause() != null && root.getCause() != root && depth < 50) { - root = root.getCause(); - depth++; - } - return root; - } - - private record ClassificationRule(Category category, String... patterns) { - boolean matches(String className) { - for (String pattern : patterns) { - if (className.contains(pattern)) return true; - } - return false; - } - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `mvn test -pl cameleer-common -Dtest=ErrorClassifierTest -f C:/Users/Hendrik/Documents/projects/cameleer/pom.xml` -Expected: All 10 tests PASS - -- [ ] **Step 5: Add error fields to RouteExecution and ProcessorExecution** - -Add 4 fields + getters/setters to both classes: `errorType`, `errorCategory`, `rootCauseType`, `rootCauseMessage`. - -Update `fail(Throwable)` in both classes to populate the new fields: - -```java -// Add after existing errorMessage/errorStackTrace assignment in fail(): -this.errorType = exception.getClass().getName(); -this.errorCategory = ErrorClassifier.classify(exception).name(); -Throwable root = ErrorClassifier.getRootCause(exception); -if (root != exception) { - this.rootCauseType = root.getClass().getName(); - this.rootCauseMessage = root.getMessage(); -} -``` - -- [ ] **Step 6: Run full test suite to verify no regressions** - -Run: `mvn clean verify -f C:/Users/Hendrik/Documents/projects/cameleer/pom.xml` -Expected: All existing tests pass. Error fields are populated on failures but are `@JsonInclude(NON_NULL)` so null values are omitted. - -- [ ] **Step 7: Commit** - -```bash -git add cameleer-common/src/main/java/com/cameleer/common/model/ErrorClassifier.java \ - cameleer-common/src/test/java/com/cameleer/common/model/ErrorClassifierTest.java \ - cameleer-common/src/main/java/com/cameleer/common/model/RouteExecution.java \ - cameleer-common/src/main/java/com/cameleer/common/model/ProcessorExecution.java -git commit -m "feat: add error classification with type, category, and root cause" -``` - ---- - -## Task 2: Sampling - -**Files:** -- Modify: `cameleer-common/src/main/java/com/cameleer/common/model/ApplicationConfig.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/CameleerAgentConfig.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/collector/ExecutionCollector.java:128-186` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/command/DefaultCommandHandler.java:93-151` -- Modify: `cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionCollectorTest.java` -- Modify: `cameleer-agent/src/test/java/com/cameleer/agent/command/DefaultCommandHandlerTest.java` - -- [ ] **Step 1: Write sampling tests in ExecutionCollectorTest** - -Add tests that verify: -1. When `samplingRate = 1.0`, all exchanges are recorded (existing behavior) -2. When `samplingRate = 0.0`, no exchanges are recorded -3. Per-route rate overrides global rate -4. Sampled-out exchanges produce no RouteExecution - -Key mock setup: -```java -// For sampling = 0.0 test: -when(config.getRouteSamplingRate("test-route")).thenReturn(0.0); -// Verify exporter.exportExecution() is never called - -// For per-route override test: -when(config.getRouteSamplingRate("high-volume-route")).thenReturn(0.0); -when(config.getRouteSamplingRate("important-route")).thenReturn(1.0); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `mvn test -pl cameleer-agent -Dtest=ExecutionCollectorTest -f C:/Users/Hendrik/Documents/projects/cameleer/pom.xml` -Expected: Compilation failure (`getRouteSamplingRate` doesn't exist) - -- [ ] **Step 3: Add `routeSamplingRates` to ApplicationConfig** - -Add field, getter, setter in `ApplicationConfig.java`: -```java -private Map routeSamplingRates; - -public Map getRouteSamplingRates() { return routeSamplingRates; } -public void setRouteSamplingRates(Map routeSamplingRates) { - this.routeSamplingRates = routeSamplingRates; -} -``` - -- [ ] **Step 4: Add sampling to CameleerAgentConfig** - -Add fields: -```java -private volatile double samplingRate = 1.0; -private volatile Map routeSamplingRates = Map.of(); -``` - -Add `getRouteSamplingRate(String routeId)`: -```java -public double getRouteSamplingRate(String routeId) { - Double perRoute = routeSamplingRates.get(routeId); - return perRoute != null ? perRoute : samplingRate; -} -``` - -In `reload()`: read `cameleer.sampling.rate` system property. - -In `applyServerConfig()`: apply `samplingRate` and `routeSamplingRates` from ApplicationConfig. - -In `applyServerConfigWithDiff()`: add diff tracking for both fields. - -- [ ] **Step 5: Add sampling check in ExecutionCollector.onExchangeCreated()** - -After the route-recording check (line 153) and before the correlation ID logic (line 156): - -```java -// Sampling — skip exchanges based on configured rate -double rate = config.getRouteSamplingRate(routeId); -if (rate < 1.0 && ThreadLocalRandom.current().nextDouble() >= rate) { - LOG.trace("Cameleer: Sampled out exchange [{}] route={} (rate={})", - exchangeId, routeId, rate); - return; -} -``` - -Add `import java.util.concurrent.ThreadLocalRandom;` - -- [ ] **Step 6: Wire sampling in DefaultCommandHandler.handleConfigUpdate()** - -After `config.applyServerConfigWithDiff(appConfig)` already handles `samplingRate` via the diff method. - -For `routeSamplingRates`, add after the existing tap handling (line 135): - -```java -if (appConfig.getRouteSamplingRates() != null) { - Map newRates = appConfig.getRouteSamplingRates(); - if (!newRates.equals(config.getRouteSamplingRates())) { - changes.add(new ConfigChange("routeSamplingRates", - config.getRouteSamplingRates().toString(), newRates.toString())); - config.setRouteSamplingRates(newRates); - } -} -``` - -- [ ] **Step 7: Run full test suite** - -Run: `mvn clean verify -f C:/Users/Hendrik/Documents/projects/cameleer/pom.xml` -Expected: All tests pass - -- [ ] **Step 8: Commit** - -```bash -git add cameleer-common/src/main/java/com/cameleer/common/model/ApplicationConfig.java \ - cameleer-agent/src/main/java/com/cameleer/agent/CameleerAgentConfig.java \ - cameleer-agent/src/main/java/com/cameleer/agent/collector/ExecutionCollector.java \ - cameleer-agent/src/main/java/com/cameleer/agent/command/DefaultCommandHandler.java \ - cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionCollectorTest.java \ - cameleer-agent/src/test/java/com/cameleer/agent/command/DefaultCommandHandlerTest.java -git commit -m "feat: add exchange sampling with global and per-route rates" -``` - ---- - -## Task 3: Circuit Breaker State Detection - -**Files:** -- Modify: `cameleer-common/src/main/java/com/cameleer/common/model/ProcessorExecution.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/collector/ExecutionCollector.java:464-581` -- Modify: `cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionCollectorTest.java` - -- [ ] **Step 1: Write CB state detection tests** - -Add tests in `ExecutionCollectorTest`: -1. CB processor completes successfully → `circuitBreakerState = "CLOSED"`, `fallbackTriggered = null` -2. CB processor short-circuited → `circuitBreakerState = "OPEN"`, `fallbackTriggered = true` -3. Non-CB processor → both fields null -4. CB properties absent → both fields null (graceful degradation) - -Key mock setup: -```java -// Simulate CB exchange properties on the exchange: -when(exchange.getProperty("CamelCircuitBreakerResponseSuccessfulExecution", Boolean.class)) - .thenReturn(Boolean.TRUE); -when(exchange.getProperty("CamelCircuitBreakerResponseFromFallback", Boolean.class)) - .thenReturn(null); -when(exchange.getProperty("CamelCircuitBreakerResponseShortCircuited", Boolean.class)) - .thenReturn(null); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `mvn test -pl cameleer-agent -Dtest=ExecutionCollectorTest -f C:/Users/Hendrik/Documents/projects/cameleer/pom.xml` -Expected: Compilation failure (`getCircuitBreakerState` doesn't exist) - -- [ ] **Step 3: Add CB fields to ProcessorExecution** - -```java -private String circuitBreakerState; -private Boolean fallbackTriggered; - -public String getCircuitBreakerState() { return circuitBreakerState; } -public void setCircuitBreakerState(String circuitBreakerState) { this.circuitBreakerState = circuitBreakerState; } -public Boolean getFallbackTriggered() { return fallbackTriggered; } -public void setFallbackTriggered(Boolean fallbackTriggered) { this.fallbackTriggered = fallbackTriggered; } -``` - -- [ ] **Step 4: Add CB state detection in ExecutionCollector** - -In `onProcessorComplete()`, after endpoint URI capture (line 495) and before tap evaluation (line 498), add: - -```java -// Detect circuit breaker state from Camel exchange properties -if ("circuitBreaker".equals(processorExec.getProcessorType())) { - Boolean shortCircuited = exchange.getProperty( - "CamelCircuitBreakerResponseShortCircuited", Boolean.class); - Boolean fromFallback = exchange.getProperty( - "CamelCircuitBreakerResponseFromFallback", Boolean.class); - Boolean successful = exchange.getProperty( - "CamelCircuitBreakerResponseSuccessfulExecution", Boolean.class); - - if (Boolean.TRUE.equals(shortCircuited)) { - processorExec.setCircuitBreakerState("OPEN"); - } else if (Boolean.TRUE.equals(successful)) { - processorExec.setCircuitBreakerState("CLOSED"); - } - if (Boolean.TRUE.equals(fromFallback)) { - processorExec.setFallbackTriggered(true); - } -} -``` - -Also add the same block in `onProcessorFailed()` after `processorExec.fail(exception)` (line 571). - -- [ ] **Step 5: Run full test suite** - -Run: `mvn clean verify -f C:/Users/Hendrik/Documents/projects/cameleer/pom.xml` -Expected: All tests pass - -- [ ] **Step 6: Commit** - -```bash -git add cameleer-common/src/main/java/com/cameleer/common/model/ProcessorExecution.java \ - cameleer-agent/src/main/java/com/cameleer/agent/collector/ExecutionCollector.java \ - cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionCollectorTest.java -git commit -m "feat: detect circuit breaker state and fallback at execution time" -``` - ---- - -## Task 4: Agent Events + Exporter Interface - -**Files:** -- Create: `cameleer-common/src/main/java/com/cameleer/common/model/AgentEvent.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/export/Exporter.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/export/HttpExporter.java` -- Modify: `cameleer-agent/src/test/java/com/cameleer/agent/export/HttpExporterTest.java` - -- [ ] **Step 1: Create AgentEvent model** - -```java -package com.cameleer.common.model; - -import com.fasterxml.jackson.annotation.JsonInclude; -import java.time.Instant; -import java.util.Map; - -@JsonInclude(JsonInclude.Include.NON_NULL) -public class AgentEvent { - private String eventType; - private Instant timestamp; - private Map details; - - public AgentEvent() {} - - public AgentEvent(String eventType, Map details) { - this.eventType = eventType; - this.timestamp = Instant.now(); - this.details = details; - } - - public String getEventType() { return eventType; } - public void setEventType(String eventType) { this.eventType = eventType; } - public Instant getTimestamp() { return timestamp; } - public void setTimestamp(Instant timestamp) { this.timestamp = timestamp; } - public Map getDetails() { return details; } - public void setDetails(Map details) { this.details = details; } -} -``` - -- [ ] **Step 2: Add `exportEvent()` to Exporter interface** - -```java -default void exportEvent(AgentEvent event) {} -``` - -Add `import com.cameleer.common.model.AgentEvent;` - -- [ ] **Step 3: Implement event export in HttpExporter** - -Add event queue and flush method following the existing execution/metrics pattern: - -```java -private final ConcurrentLinkedQueue eventQueue = new ConcurrentLinkedQueue<>(); -``` - -Add `exportEvent()` override (same queue/size pattern as `exportExecution()`). - -Add `flushEvents()` method (same batch/POST pattern as `flushExecutions()`, endpoint: `/api/v1/data/events`). - -Call `flushEvents()` from `flush()` after `flushMetrics()`. - -- [ ] **Step 4: Write HttpExporter event test** - -Add test in `HttpExporterTest` that queues an event, triggers flush, verifies `serverConnection.sendData("/api/v1/data/events", ...)` is called. - -- [ ] **Step 5: Run tests** - -Run: `mvn clean verify -f C:/Users/Hendrik/Documents/projects/cameleer/pom.xml` -Expected: All tests pass - -- [ ] **Step 6: Commit** - -```bash -git add cameleer-common/src/main/java/com/cameleer/common/model/AgentEvent.java \ - cameleer-agent/src/main/java/com/cameleer/agent/export/Exporter.java \ - cameleer-agent/src/main/java/com/cameleer/agent/export/HttpExporter.java \ - cameleer-agent/src/test/java/com/cameleer/agent/export/HttpExporterTest.java -git commit -m "feat: add AgentEvent model and event export to HttpExporter" -``` - ---- - -## Task 5: Route Lifecycle Events in EventNotifier - -**Files:** -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/notifier/CameleerEventNotifier.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/command/DefaultCommandHandler.java` - -- [ ] **Step 1: Send route lifecycle events from CameleerEventNotifier** - -In the `notify()` method, update the route event handlers to also export AgentEvents: - -```java -else if (event instanceof CamelEvent.RouteStartedEvent routeStarted) { - String routeId = routeStarted.getRoute().getRouteId(); - LOG.info("Cameleer: Route started: {}", routeId); - sendEvent("ROUTE_STARTED", Map.of("routeId", routeId)); -} -``` - -Same pattern for `RouteStoppedEvent`, `RouteAddedEvent`, `RouteRemovedEvent`. - -Add helper: -```java -private void sendEvent(String eventType, Map details) { - Exporter exp = this.exporter; - if (exp != null) { - exp.exportEvent(new AgentEvent(eventType, details)); - } -} -``` - -- [ ] **Step 2: Send CONFIG_APPLIED event from DefaultCommandHandler** - -After the config-update summary is built (line 150), before returning: - -```java -if (!changes.isEmpty()) { - Exporter exp = executionCollector.getExporter(); - if (exp != null) { - exp.exportEvent(new AgentEvent("CONFIG_APPLIED", Map.of( - "configVersion", String.valueOf(appConfig.getVersion()), - "changeCount", String.valueOf(changes.size()), - "summary", summary))); - } -} -``` - -Note: `ExecutionCollector` needs a `getExporter()` getter (it already has `setExporter()`). - -- [ ] **Step 3: Run tests** - -Run: `mvn clean verify -f C:/Users/Hendrik/Documents/projects/cameleer/pom.xml` -Expected: All tests pass - -- [ ] **Step 4: Commit** - -```bash -git add cameleer-agent/src/main/java/com/cameleer/agent/notifier/CameleerEventNotifier.java \ - cameleer-agent/src/main/java/com/cameleer/agent/command/DefaultCommandHandler.java \ - cameleer-agent/src/main/java/com/cameleer/agent/collector/ExecutionCollector.java -git commit -m "feat: emit agent events for route lifecycle and config changes" -``` - ---- - -## Task 6: Startup Report - -**Files:** -- Create: `cameleer-agent/src/main/java/com/cameleer/agent/health/StartupReport.java` -- Create: `cameleer-agent/src/test/java/com/cameleer/agent/health/StartupReportTest.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/instrumentation/CameleerHookInstaller.java` - -- [ ] **Step 1: Write StartupReport tests** - -Test cases: -1. All checks pass → overall status UP, log contains `[PASS]` for each -2. No routes → status DEGRADED, routes check is WARN -3. Server connection failed → status DEGRADED, server check is WARN -4. `generateReport()` returns a list of `HealthCheck` records - -```java -record HealthCheck(String name, Status status, String detail) { - enum Status { PASS, WARN, FAIL } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Expected: Compilation failure - -- [ ] **Step 3: Implement StartupReport** - -`StartupReport` takes a `StartupContext` record (agentId, engineLevel, routeCount, diagramCount, serverConnected, logForwardingFramework, metricsAvailable, prometheusUrl, tapCount, configVersion, exportType) and produces: -- `List getChecks()` — evaluates all checks -- `String getOverallStatus()` — UP/DEGRADED/DOWN -- `void log()` — formatted log output -- `AgentEvent toEvent()` — AGENT_STARTED event with details - -- [ ] **Step 4: Wire into CameleerHookInstaller.postInstall()** - -At the end of `postInstall()`, after the final log line (line 150): - -```java -StartupReport report = new StartupReport(new StartupReport.StartupContext(...)); -report.log(); -if (sharedExporter != null) { - sharedExporter.exportEvent(report.toEvent()); -} -``` - -- [ ] **Step 5: Run tests** - -Run: `mvn clean verify -f C:/Users/Hendrik/Documents/projects/cameleer/pom.xml` -Expected: All tests pass - -- [ ] **Step 6: Commit** - -```bash -git add cameleer-agent/src/main/java/com/cameleer/agent/health/StartupReport.java \ - cameleer-agent/src/test/java/com/cameleer/agent/health/StartupReportTest.java \ - cameleer-agent/src/main/java/com/cameleer/agent/instrumentation/CameleerHookInstaller.java -git commit -m "feat: add structured startup report with health checks" -``` - ---- - -## Task 7: Health Endpoint - -**Files:** -- Create: `cameleer-agent/src/main/java/com/cameleer/agent/health/HealthEndpoint.java` -- Create: `cameleer-agent/src/test/java/com/cameleer/agent/health/HealthEndpointTest.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/metrics/PrometheusEndpoint.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/instrumentation/CameleerHookInstaller.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/CameleerAgentConfig.java` - -- [ ] **Step 1: Add health config to CameleerAgentConfig** - -```java -private boolean healthEnabled; -private int healthPort; -private String healthPath; -``` - -In `reload()`: -```java -this.healthEnabled = getBoolProp("cameleer.health.enabled", true); -this.healthPort = getIntProp("cameleer.health.port", 9464); -this.healthPath = getStringProp("cameleer.health.path", "/cameleer/health"); -``` - -Add getters. - -- [ ] **Step 2: Expose HttpServer from PrometheusEndpoint** - -Add getter to `PrometheusEndpoint.java`: -```java -public HttpServer getServer() { return server; } -public int getPort() { return port; } -``` - -- [ ] **Step 3: Write HealthEndpoint tests** - -Test that the health endpoint returns correct JSON: -- Status `UP` when all checks pass -- Status `DEGRADED` when a check is WARN -- Response includes `agentId`, `uptime`, `checks` map - -- [ ] **Step 4: Implement HealthEndpoint** - -```java -public class HealthEndpoint { - // Takes a Supplier so checks are re-evaluated per request - // If PrometheusEndpoint provides an HttpServer, add context to it - // Otherwise create own HttpServer on healthPort - // JSON response built with CameleerJson.mapper() -} -``` - -- [ ] **Step 5: Wire into CameleerHookInstaller** - -After Prometheus setup and startup report, if health is enabled: -```java -if (config.isHealthEnabled()) { - HttpServer existingServer = prometheusEndpoint != null ? prometheusEndpoint.getServer() : null; - healthEndpoint = new HealthEndpoint(config, existingServer, startupContextSupplier); - healthEndpoint.start(); -} -``` - -- [ ] **Step 6: Run tests** - -Run: `mvn clean verify -f C:/Users/Hendrik/Documents/projects/cameleer/pom.xml` -Expected: All tests pass - -- [ ] **Step 7: Commit** - -```bash -git add cameleer-agent/src/main/java/com/cameleer/agent/health/HealthEndpoint.java \ - cameleer-agent/src/test/java/com/cameleer/agent/health/HealthEndpointTest.java \ - cameleer-agent/src/main/java/com/cameleer/agent/metrics/PrometheusEndpoint.java \ - cameleer-agent/src/main/java/com/cameleer/agent/instrumentation/CameleerHookInstaller.java \ - cameleer-agent/src/main/java/com/cameleer/agent/CameleerAgentConfig.java -git commit -m "feat: add HTTP health endpoint with reusable check framework" -``` - ---- - -## Task 8: OTel — Model + Config + Correlate Mode - -**Files:** -- Modify: `cameleer-common/src/main/java/com/cameleer/common/model/RouteExecution.java` -- Create: `cameleer-agent/src/main/java/com/cameleer/agent/otel/OtelBridge.java` -- Create: `cameleer-agent/src/test/java/com/cameleer/agent/otel/OtelBridgeTest.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/CameleerAgentConfig.java` - -- [ ] **Step 1: Add traceId/spanId to RouteExecution** - -```java -private String traceId; -private String spanId; -``` - -Add getters/setters. - -- [ ] **Step 2: Add OTel config to CameleerAgentConfig** - -```java -private String otelMode; // OFF, AUTO, CREATE, CORRELATE -private String otelEndpoint; -private String otelServiceName; -``` - -In `reload()`: -```java -this.otelMode = getStringProp("cameleer.otel.mode", "OFF").toUpperCase(); -this.otelEndpoint = getStringProp("cameleer.otel.endpoint", "http://localhost:4318"); -this.otelServiceName = getStringProp("cameleer.otel.serviceName", this.agentApplication); -``` - -- [ ] **Step 3: Write OtelBridge CORRELATE mode tests** - -```java -@Test -void correlateMode_extractsTraceparent() { - OtelBridge bridge = new OtelBridge(); - bridge.initialize(OtelBridge.Mode.CORRELATE); - - Exchange exchange = mock(Exchange.class); - Message message = mock(Message.class); - when(exchange.getMessage()).thenReturn(message); - when(message.getHeader("traceparent", String.class)) - .thenReturn("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"); - - RouteExecution routeExec = new RouteExecution("test", "ex-1"); - bridge.onExchangeCreated(exchange, routeExec); - - assertEquals("0af7651916cd43dd8448eb211c80319c", routeExec.getTraceId()); - assertEquals("b7ad6b7169203331", routeExec.getSpanId()); -} - -@Test -void correlateMode_noTraceparent_fieldsNull() { - OtelBridge bridge = new OtelBridge(); - bridge.initialize(OtelBridge.Mode.CORRELATE); - - Exchange exchange = mock(Exchange.class); - Message message = mock(Message.class); - when(exchange.getMessage()).thenReturn(message); - when(message.getHeader("traceparent", String.class)).thenReturn(null); - - RouteExecution routeExec = new RouteExecution("test", "ex-1"); - bridge.onExchangeCreated(exchange, routeExec); - - assertNull(routeExec.getTraceId()); - assertNull(routeExec.getSpanId()); -} - -@Test -void offMode_noInteraction() { - OtelBridge bridge = new OtelBridge(); - bridge.initialize(OtelBridge.Mode.OFF); - - RouteExecution routeExec = new RouteExecution("test", "ex-1"); - bridge.onExchangeCreated(mock(Exchange.class), routeExec); - - assertNull(routeExec.getTraceId()); -} -``` - -- [ ] **Step 4: Implement OtelBridge (CORRELATE + OFF modes only)** - -```java -package com.cameleer.agent.otel; - -public class OtelBridge { - public enum Mode { OFF, AUTO, CREATE, CORRELATE } - - private Mode activeMode = Mode.OFF; - - public void initialize(Mode mode) { this.activeMode = mode; } - - public void initialize(CameleerAgentConfig config, ClassLoader appClassLoader) { - Mode requested = Mode.valueOf(config.getOtelMode()); - if (requested == Mode.AUTO) { - activeMode = resolveAutoMode(appClassLoader); - } else { - activeMode = requested; - } - if (activeMode == Mode.CREATE) { - initializeOtelSdk(config); - } - } - - public void onExchangeCreated(Exchange exchange, RouteExecution routeExec) { - switch (activeMode) { - case CORRELATE -> extractTraceparent(exchange, routeExec); - case CREATE -> createRootSpan(exchange, routeExec); - default -> {} // OFF: no-op - } - } - - // Other lifecycle methods: onProcessorStart, onProcessorComplete, etc. - // CREATE mode delegates to shaded OTel SDK (Task 9) - // CORRELATE mode: only onExchangeCreated does anything - - private void extractTraceparent(Exchange exchange, RouteExecution routeExec) { - String traceparent = exchange.getMessage().getHeader("traceparent", String.class); - if (traceparent != null) { - // Format: 00-{traceId}-{spanId}-{flags} - String[] parts = traceparent.split("-"); - if (parts.length >= 4) { - routeExec.setTraceId(parts[1]); - routeExec.setSpanId(parts[2]); - } - } - } - - private Mode resolveAutoMode(ClassLoader appClassLoader) { - try { - appClassLoader.loadClass("io.opentelemetry.api.trace.Span"); - return Mode.CORRELATE; - } catch (ClassNotFoundException e) { - return Mode.CREATE; - } - } - - // CREATE mode stubs — implemented in Task 9 - private void initializeOtelSdk(CameleerAgentConfig config) { /* Task 9 */ } - private void createRootSpan(Exchange exchange, RouteExecution routeExec) { /* Task 9 */ } - - public Mode getActiveMode() { return activeMode; } -} -``` - -- [ ] **Step 5: Wire OtelBridge into ExecutionCollector** - -Add `private volatile OtelBridge otelBridge;` field + setter. - -In `onExchangeCreated()`, after creating the RouteExecution (line 165): -```java -OtelBridge otel = this.otelBridge; -if (otel != null) { - otel.onExchangeCreated(exchange, routeExecution); -} -``` - -Same pattern in `onExchangeCompleted()`, `onExchangeFailed()`, `onProcessorComplete()`, `onProcessorFailed()`. - -- [ ] **Step 6: Initialize OtelBridge in CameleerHookInstaller** - -In `postInstall()`, after the CamelContext is stored: -```java -if (!"OFF".equals(config.getOtelMode())) { - OtelBridge otelBridge = new OtelBridge(); - otelBridge.initialize(config, camelContext.getClass().getClassLoader()); - sharedCollector.setOtelBridge(otelBridge); - LOG.info("Cameleer: OTel bridge initialized (mode={})", otelBridge.getActiveMode()); -} -``` - -- [ ] **Step 7: Run tests** - -Run: `mvn clean verify -f C:/Users/Hendrik/Documents/projects/cameleer/pom.xml` -Expected: All tests pass - -- [ ] **Step 8: Commit** - -```bash -git add cameleer-common/src/main/java/com/cameleer/common/model/RouteExecution.java \ - cameleer-agent/src/main/java/com/cameleer/agent/otel/OtelBridge.java \ - cameleer-agent/src/test/java/com/cameleer/agent/otel/OtelBridgeTest.java \ - cameleer-agent/src/main/java/com/cameleer/agent/CameleerAgentConfig.java \ - cameleer-agent/src/main/java/com/cameleer/agent/collector/ExecutionCollector.java \ - cameleer-agent/src/main/java/com/cameleer/agent/instrumentation/CameleerHookInstaller.java -git commit -m "feat: add OTel bridge with CORRELATE mode and traceparent extraction" -``` - ---- - -## Task 9: OTel — CREATE Mode (Span Export) - -**Files:** -- Modify: `cameleer-agent/pom.xml` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/otel/OtelBridge.java` -- Modify: `cameleer-agent/src/test/java/com/cameleer/agent/otel/OtelBridgeTest.java` - -- [ ] **Step 1: Add OTel SDK dependencies to agent pom.xml** - -Add in ``: -```xml - - io.opentelemetry - opentelemetry-api - ${otel.version} - - - io.opentelemetry - opentelemetry-sdk - ${otel.version} - - - io.opentelemetry - opentelemetry-exporter-otlp - ${otel.version} - -``` - -Add shade relocation in the shade plugin ``: -```xml - - io.opentelemetry - com.cameleer.shaded.otel - -``` - -Note: Check the latest stable OTel SDK version (1.x) and pin it. Also need to relocate transitive dependencies (`io.grpc`, `com.google.protobuf`, etc.) or use the `opentelemetry-exporter-otlp-http` variant which avoids gRPC. The HTTP/protobuf exporter is lighter. - -- [ ] **Step 2: Implement CREATE mode in OtelBridge** - -```java -private void initializeOtelSdk(CameleerAgentConfig config) { - // Use shaded OTel SDK classes - OtlpHttpSpanExporter exporter = OtlpHttpSpanExporter.builder() - .setEndpoint(config.getOtelEndpoint()) - .build(); - SdkTracerProvider tracerProvider = SdkTracerProvider.builder() - .addSpanProcessor(BatchSpanProcessor.builder(exporter).build()) - .setResource(Resource.getDefault().merge( - Resource.create(Attributes.of( - ResourceAttributes.SERVICE_NAME, config.getOtelServiceName())))) - .build(); - OpenTelemetry otel = OpenTelemetrySdk.builder() - .setTracerProvider(tracerProvider) - .build(); - this.tracer = otel.getTracer("cameleer-agent"); - this.tracerProvider = tracerProvider; -} -``` - -Implement span lifecycle methods: -- `createRootSpan()`: start span, store in `ThreadLocal` or exchange property -- `onProcessorStart()`: create child span -- `onProcessorComplete/Failed()`: end child span -- `onExchangeCompleted/Failed()`: end root span -- Store spans in a `ConcurrentHashMap` keyed by exchangeId (root) and exchangeId+processorId (child) - -- [ ] **Step 3: Write CREATE mode test** - -Use OTel's `InMemorySpanExporter` for testing: -```java -@Test -void createMode_producesSpans() { - InMemorySpanExporter spanExporter = InMemorySpanExporter.create(); - // Initialize bridge with test exporter - // Simulate exchange lifecycle - // Verify spans exported with correct attributes -} -``` - -- [ ] **Step 4: Add shutdown cleanup** - -In `OtelBridge.close()`: flush and shut down the tracer provider. -Wire into `CameleerEventNotifier.gracefulShutdown()` or `CameleerHookInstaller` shutdown hooks. - -- [ ] **Step 5: Run tests** - -Run: `mvn clean verify -f C:/Users/Hendrik/Documents/projects/cameleer/pom.xml` -Expected: All tests pass. Verify shaded JAR doesn't have unshaded OTel classes. - -- [ ] **Step 6: Commit** - -```bash -git add cameleer-agent/pom.xml \ - cameleer-agent/src/main/java/com/cameleer/agent/otel/OtelBridge.java \ - cameleer-agent/src/test/java/com/cameleer/agent/otel/OtelBridgeTest.java -git commit -m "feat: add OTel CREATE mode with OTLP span export" -``` - ---- - -## Task 10: Update PROTOCOL.md and Documentation - -**Files:** -- Modify: `cameleer-common/PROTOCOL.md` - -- [ ] **Step 1: Update PROTOCOL.md** - -Add sections/fields: -- Error classification fields in execution JSON schema -- Sampling config in ApplicationConfig schema -- Circuit breaker fields in ProcessorExecution schema -- AgentEvent model and `/api/v1/data/events` endpoint -- Health endpoint description -- OTel traceId/spanId fields -- `cameleer.otel.mode` config property -- `cameleer.sampling.rate` config property -- `cameleer.health.*` config properties - -- [ ] **Step 2: Run verify to ensure nothing is broken** - -Run: `mvn clean verify -f C:/Users/Hendrik/Documents/projects/cameleer/pom.xml` -Expected: All tests pass - -- [ ] **Step 3: Commit** - -```bash -git add cameleer-common/PROTOCOL.md -git commit -m "docs: update PROTOCOL.md with new feature pack fields and endpoints" -``` - ---- - -## Verification Checklist - -After all tasks are complete: - -- [ ] `mvn clean verify` — all tests pass -- [ ] Error classification: check exported JSON has `errorType`, `errorCategory`, `rootCauseType`, `rootCauseMessage` on failures -- [ ] Sampling: set `cameleer.sampling.rate=0.0` and verify no executions recorded -- [ ] CB detection: verify `circuitBreakerState` field appears on CB processors (if sample app has a circuit breaker route) -- [ ] Startup report: check agent log output for structured report -- [ ] Health endpoint: `curl http://localhost:9464/cameleer/health` returns JSON -- [ ] Agent events: check server receives AGENT_STARTED event on startup -- [ ] OTel CORRELATE: add `traceparent` header to test exchange, verify `traceId`/`spanId` in execution JSON diff --git a/docs/superpowers/plans/2026-03-30-execution-tracking-integration-tests.md b/docs/superpowers/plans/2026-03-30-execution-tracking-integration-tests.md deleted file mode 100644 index 7c5c919..0000000 --- a/docs/superpowers/plans/2026-03-30-execution-tracking-integration-tests.md +++ /dev/null @@ -1,909 +0,0 @@ -# Execution Tracking Integration Tests — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build integration test infrastructure that runs real Camel routes through the full agent pipeline and asserts on the captured execution tree, preventing the class of regression where mocked exchange properties don't match real Camel behavior. - -**Architecture:** Three test layers — (1) property snapshot tests documenting what Camel 4.10 actually sets on sub-exchanges, (2) execution tree integration tests verifying the full agent pipeline produces correct processor trees, (3) lightweight execution assertions in existing sample app tests. All use a shared `TestExporter` that captures `RouteExecution` objects in memory. - -**Tech Stack:** JUnit 5, Apache Camel 4.10 (DefaultCamelContext), camel-resilience4j (CB tests), existing cameleer-agent + cameleer-common modules. - ---- - -### Task 1: TestExporter - -**Files:** -- Create: `cameleer-agent/src/test/java/com/cameleer/agent/test/TestExporter.java` - -- [ ] **Step 1: Create TestExporter class** - -```java -package com.cameleer.agent.test; - -import com.cameleer.agent.export.Exporter; -import com.cameleer.common.model.RouteExecution; - -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; - -/** - * In-memory exporter for integration tests. Captures RouteExecution objects - * and provides blocking await for async route completion. - */ -public class TestExporter implements Exporter { - - private final CopyOnWriteArrayList executions = new CopyOnWriteArrayList<>(); - private volatile CountDownLatch latch = new CountDownLatch(1); - - @Override - public void exportExecution(RouteExecution routeExecution) { - executions.add(routeExecution); - latch.countDown(); - } - - /** Returns all captured executions. */ - public List getExecutions() { - return executions; - } - - /** Blocks until at least one execution is captured or timeout expires. Returns true if received. */ - public boolean awaitExecution(long timeout, TimeUnit unit) throws InterruptedException { - return latch.await(timeout, unit); - } - - /** Resets state for the next test. Call in @BeforeEach. */ - public void reset() { - executions.clear(); - latch = new CountDownLatch(1); - } - - /** Resets with expected execution count. Use when a route produces multiple executions. */ - public void reset(int expectedCount) { - executions.clear(); - latch = new CountDownLatch(expectedCount); - } -} -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `mvn compile -pl cameleer-agent -q` -Expected: no errors - -- [ ] **Step 3: Commit** - -```bash -git add cameleer-agent/src/test/java/com/cameleer/agent/test/TestExporter.java -git commit -m "test: add TestExporter for integration test infrastructure" -``` - ---- - -### Task 2: Property Snapshot Tests - -**Files:** -- Create: `cameleer-agent/src/test/java/com/cameleer/agent/collector/CamelExchangePropertySnapshotTest.java` - -- [ ] **Step 1: Create the test class with split sub-exchange property test** - -This test starts a real CamelContext, runs a split route, captures the actual exchange properties Camel 4.10 sets on sub-exchanges, and asserts them as a canonical reference. - -```java -package com.cameleer.agent.collector; - -import org.apache.camel.CamelContext; -import org.apache.camel.Exchange; -import org.apache.camel.ProducerTemplate; -import org.apache.camel.builder.RouteBuilder; -import org.apache.camel.impl.DefaultCamelContext; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Canonical reference for exchange properties Camel 4.10 sets on sub-exchanges. - * These are version-upgrade canaries — if Camel changes property behavior, these fail. - *

- * NOT testing our agent code. Testing Camel's actual behavior so we don't make - * wrong assumptions about exchange properties (as happened with CamelParentExchangeId). - */ -class CamelExchangePropertySnapshotTest { - - private CamelContext camelContext; - private ProducerTemplate producer; - - @BeforeEach - void setUp() throws Exception { - camelContext = new DefaultCamelContext(); - } - - @AfterEach - void tearDown() throws Exception { - if (camelContext != null) { - camelContext.stop(); - } - } - - @Test - void splitSubExchange_properties() throws Exception { - List> capturedProperties = new CopyOnWriteArrayList<>(); - - camelContext.addRoutes(new RouteBuilder() { - @Override - public void configure() { - from("direct:test-split") - .routeId("test-split") - .split(body()) - .process(exchange -> { - // Capture ALL properties on the split sub-exchange - Map snapshot = new ConcurrentHashMap<>(exchange.getProperties()); - capturedProperties.add(snapshot); - }) - .end(); - } - }); - camelContext.start(); - producer = camelContext.createProducerTemplate(); - - producer.sendBody("direct:test-split", List.of("item1", "item2", "item3")); - - assertEquals(3, capturedProperties.size(), "Should have 3 sub-exchange property snapshots"); - - Map firstSub = capturedProperties.get(0); - - // SPLIT_INDEX is set by Camel on split sub-exchanges - assertNotNull(firstSub.get(Exchange.SPLIT_INDEX), "SPLIT_INDEX must be set"); - assertEquals(0, firstSub.get(Exchange.SPLIT_INDEX)); - - // SPLIT_SIZE is set on the last element (or all elements depending on Camel version) - // Don't assert it's always present — just document behavior - - // CORRELATION_ID links back to parent exchange - assertNotNull(firstSub.get(Exchange.CORRELATION_ID), - "CORRELATION_ID must be set on split sub-exchanges"); - - // CamelParentExchangeId — document whether Camel 4.10 sets it - // This was the wrong assumption that broke commit b691ad0 - Object parentExchangeId = firstSub.get("CamelParentExchangeId"); - // If this assertion fails after a Camel upgrade, update SubExchangeTracker accordingly - if (parentExchangeId != null) { - fail("CamelParentExchangeId IS set on split sub-exchanges in this Camel version. " + - "Update SubExchangeTracker.onSplitStart() — can use CamelParentExchangeId directly."); - } - } - - @Test - void multicastSubExchange_properties() throws Exception { - List> capturedProperties = new CopyOnWriteArrayList<>(); - - camelContext.addRoutes(new RouteBuilder() { - @Override - public void configure() { - from("direct:test-multicast") - .routeId("test-multicast") - .multicast() - .to("direct:mc-branch-a", "direct:mc-branch-b") - .end(); - - from("direct:mc-branch-a").routeId("mc-a") - .process(exchange -> capturedProperties.add(new ConcurrentHashMap<>(exchange.getProperties()))); - from("direct:mc-branch-b").routeId("mc-b") - .process(exchange -> capturedProperties.add(new ConcurrentHashMap<>(exchange.getProperties()))); - } - }); - camelContext.start(); - producer = camelContext.createProducerTemplate(); - - producer.sendBody("direct:test-multicast", "test-body"); - - assertEquals(2, capturedProperties.size(), "Should have 2 multicast sub-exchange snapshots"); - - // CamelMulticastIndex should be set - boolean hasMulticastIndex = capturedProperties.stream() - .anyMatch(props -> props.containsKey("CamelMulticastIndex")); - assertTrue(hasMulticastIndex, "CamelMulticastIndex must be set on multicast sub-exchanges"); - } - - @Test - void loopIteration_properties() throws Exception { - List> capturedProperties = new CopyOnWriteArrayList<>(); - List exchangeIds = new CopyOnWriteArrayList<>(); - - camelContext.addRoutes(new RouteBuilder() { - @Override - public void configure() { - from("direct:test-loop") - .routeId("test-loop") - .loop(3) - .process(exchange -> { - capturedProperties.add(new ConcurrentHashMap<>(exchange.getProperties())); - exchangeIds.add(exchange.getExchangeId()); - }) - .end(); - } - }); - camelContext.start(); - producer = camelContext.createProducerTemplate(); - - producer.sendBody("direct:test-loop", "test-body"); - - assertEquals(3, capturedProperties.size(), "Should have 3 loop iterations"); - - // Loop reuses the SAME exchange — verify exchange IDs are identical - assertEquals(1, exchangeIds.stream().distinct().count(), - "Loop must reuse the same exchange (not create sub-exchanges)"); - - // CamelLoopIndex should be present - assertNotNull(capturedProperties.get(0).get("CamelLoopIndex"), - "CamelLoopIndex must be set during loop iterations"); - } - - @Test - void circuitBreakerInternal_properties() throws Exception { - List> mainFlowProperties = new CopyOnWriteArrayList<>(); - List mainFlowExchangeIds = new CopyOnWriteArrayList<>(); - - camelContext.addRoutes(new RouteBuilder() { - @Override - public void configure() { - from("direct:test-cb") - .routeId("test-cb") - .circuitBreaker() - .process(exchange -> { - mainFlowProperties.add(new ConcurrentHashMap<>(exchange.getProperties())); - mainFlowExchangeIds.add(exchange.getExchangeId()); - }) - .onFallback() - .log("fallback") - .end(); - } - }); - camelContext.start(); - producer = camelContext.createProducerTemplate(); - - String parentExchangeId = producer.send("direct:test-cb", - exchange -> exchange.getMessage().setBody("test")).getExchangeId(); - - assertFalse(mainFlowProperties.isEmpty(), "CB main flow should execute"); - - // Document whether CB creates a separate exchange or reuses the parent - String cbExchangeId = mainFlowExchangeIds.get(0); - boolean cbCreatesNewExchange = !cbExchangeId.equals(parentExchangeId); - // This is informational — the test documents the behavior for reference - System.out.println("CB creates new exchange: " + cbCreatesNewExchange); - System.out.println("CB exchange properties: " + mainFlowProperties.get(0).keySet()); - } - - @Test - void splitWithCircuitBreaker_cbInheritsProperties() throws Exception { - List> splitSubProperties = new CopyOnWriteArrayList<>(); - List> cbInternalProperties = new CopyOnWriteArrayList<>(); - List splitSubExchangeIds = new CopyOnWriteArrayList<>(); - List cbExchangeIds = new CopyOnWriteArrayList<>(); - - camelContext.addRoutes(new RouteBuilder() { - @Override - public void configure() { - from("direct:test-split-cb") - .routeId("test-split-cb") - .split(body()) - .process(exchange -> { - splitSubProperties.add(new ConcurrentHashMap<>(exchange.getProperties())); - splitSubExchangeIds.add(exchange.getExchangeId()); - }) - .circuitBreaker() - .process(exchange -> { - cbInternalProperties.add(new ConcurrentHashMap<>(exchange.getProperties())); - cbExchangeIds.add(exchange.getExchangeId()); - }) - .onFallback() - .log("fallback") - .end() - .end(); - } - }); - camelContext.start(); - producer = camelContext.createProducerTemplate(); - - producer.sendBody("direct:test-split-cb", List.of("item1")); - - assertFalse(splitSubProperties.isEmpty(), "Split sub-exchange should execute"); - assertFalse(cbInternalProperties.isEmpty(), "CB internal exchange should execute"); - - // Key question: does CB inside split use a DIFFERENT exchange? - boolean cbUsesDifferentExchange = !splitSubExchangeIds.get(0).equals(cbExchangeIds.get(0)); - System.out.println("CB inside split uses different exchange: " + cbUsesDifferentExchange); - - // If CB uses the same exchange as the split sub-exchange, there's no phantom wrapper risk - // If CB uses a different exchange, document whether it inherits SPLIT_INDEX - if (cbUsesDifferentExchange) { - boolean cbHasSplitIndex = cbInternalProperties.get(0).containsKey(Exchange.SPLIT_INDEX); - System.out.println("CB internal exchange inherits SPLIT_INDEX: " + cbHasSplitIndex); - // This is the scenario that caused phantom wrappers - } - } -} -``` - -- [ ] **Step 2: Run tests to verify they pass and document Camel 4.10 behavior** - -Run: `mvn test -pl cameleer-agent -Dtest="CamelExchangePropertySnapshotTest" -am` -Expected: All tests PASS. Console output documents actual Camel behavior. - -- [ ] **Step 3: Review output and update assertions if needed** - -Read the console output. If CamelParentExchangeId IS set (unexpected), update the assertion and note the discovery. The point is to document reality, not enforce assumptions. - -- [ ] **Step 4: Commit** - -```bash -git add cameleer-agent/src/test/java/com/cameleer/agent/collector/CamelExchangePropertySnapshotTest.java -git commit -m "test: add Camel 4.10 exchange property snapshot tests" -``` - ---- - -### Task 3: Execution Tree Integration Tests — Base Setup - -**Files:** -- Create: `cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionTreeIntegrationTest.java` - -- [ ] **Step 1: Create the test class with base setup and the first test (split with aggregation)** - -```java -package com.cameleer.agent.collector; - -import com.cameleer.agent.CameleerAgentConfig; -import com.cameleer.agent.notifier.CameleerEventNotifier; -import com.cameleer.agent.notifier.CameleerInterceptStrategy; -import com.cameleer.agent.test.TestExporter; -import com.cameleer.common.model.ProcessorExecution; -import com.cameleer.common.model.RouteExecution; -import org.apache.camel.AggregationStrategy; -import org.apache.camel.CamelContext; -import org.apache.camel.Exchange; -import org.apache.camel.ProducerTemplate; -import org.apache.camel.builder.RouteBuilder; -import org.apache.camel.impl.DefaultCamelContext; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.concurrent.TimeUnit; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Integration tests that run real Camel routes through the full agent pipeline: - * InterceptStrategy + EventNotifier + ExecutionCollector + TestExporter. - * Asserts on the captured RouteExecution processor tree. - *

- * These tests would have caught both recent breakages: - * - CamelParentExchangeId assumption (b691ad0) - * - Phantom CB iteration wrappers - */ -class ExecutionTreeIntegrationTest { - - private CamelContext camelContext; - private ProducerTemplate producer; - private TestExporter testExporter; - private ExecutionCollector collector; - - @BeforeEach - void setUp() { - // Use system properties for config (CameleerAgentConfig is a singleton) - System.setProperty("cameleer.enabled", "true"); - System.setProperty("cameleer.engine.level", "REGULAR"); - System.setProperty("cameleer.metrics.enabled", "false"); - System.setProperty("cameleer.diagram.enabled", "false"); - System.setProperty("cameleer.taps.enabled", "false"); - - CameleerAgentConfig config = CameleerAgentConfig.getInstance(); - config.reload(); - - testExporter = new TestExporter(); - collector = new ExecutionCollector(config, testExporter); - - camelContext = new DefaultCamelContext(); - - // Phase 1: register InterceptStrategy BEFORE routes start - CameleerInterceptStrategy interceptStrategy = new CameleerInterceptStrategy(collector, config); - camelContext.getCamelContextExtension().addInterceptStrategy(interceptStrategy); - } - - @AfterEach - void tearDown() throws Exception { - if (camelContext != null) { - camelContext.stop(); - } - collector.shutdown(); - testExporter.reset(); - } - - /** Starts routes and registers EventNotifier (must be called after addRoutes). */ - private void startContext() throws Exception { - camelContext.start(); - - // Phase 2: register EventNotifier AFTER routes start - CameleerEventNotifier notifier = new CameleerEventNotifier(collector); - camelContext.getManagementStrategy().addEventNotifier(notifier); - notifier.start(); - - producer = camelContext.createProducerTemplate(); - } - - // --- Helper to find processor by type in a flat or nested tree --- - - private ProcessorExecution findProcessor(List processors, String processorType) { - for (ProcessorExecution p : processors) { - if (processorType.equals(p.getProcessorType())) return p; - ProcessorExecution found = findProcessor(p.getChildren(), processorType); - if (found != null) return found; - } - return null; - } - - private long countProcessorsByType(List processors, String processorType) { - long count = 0; - for (ProcessorExecution p : processors) { - if (processorType.equals(p.getProcessorType())) count++; - count += countProcessorsByType(p.getChildren(), processorType); - } - return count; - } - - // === Split Tests === - - @Test - void splitWithAggregation_correctTree() throws Exception { - camelContext.addRoutes(new RouteBuilder() { - @Override - public void configure() { - from("direct:split-agg") - .routeId("split-agg") - .split(body(), new ConcatAggregation()) - .log("item: ${body}") - .end() - .log("after split: ${body}"); - } - }); - startContext(); - - producer.sendBody("direct:split-agg", List.of("a", "b", "c")); - assertTrue(testExporter.awaitExecution(5, TimeUnit.SECONDS)); - - RouteExecution exec = testExporter.getExecutions().get(0); - List root = exec.getProcessors(); - - // Find the split processor at root level - ProcessorExecution splitProc = root.stream() - .filter(p -> "split".equals(p.getProcessorType())) - .findFirst().orElse(null); - assertNotNull(splitProc, "Split processor must be at root level"); - - // Split must have 3 iteration wrappers - long iterCount = splitProc.getChildren().stream() - .filter(c -> "splitIteration".equals(c.getProcessorType())) - .count(); - assertEquals(3, iterCount, "Split must have 3 iteration wrappers"); - - // Post-split log must be a sibling of split (at root level), not a child - boolean postSplitLogAtRoot = root.stream() - .anyMatch(p -> "log".equals(p.getProcessorType()) && root.indexOf(p) > root.indexOf(splitProc)); - assertTrue(postSplitLogAtRoot, "Post-split log must be at root level after split"); - } - - /** Simple aggregation that concatenates bodies. */ - private static class ConcatAggregation implements AggregationStrategy { - @Override - public Exchange aggregate(Exchange oldExchange, Exchange newExchange) { - if (oldExchange == null) return newExchange; - String old = oldExchange.getMessage().getBody(String.class); - String add = newExchange.getMessage().getBody(String.class); - oldExchange.getMessage().setBody(old + "," + add); - return oldExchange; - } - } -} -``` - -- [ ] **Step 2: Run to verify the first test passes** - -Run: `mvn test -pl cameleer-agent -Dtest="ExecutionTreeIntegrationTest#splitWithAggregation_correctTree" -am` -Expected: PASS - -- [ ] **Step 3: Commit** - -```bash -git add cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionTreeIntegrationTest.java -git commit -m "test: add execution tree integration test infrastructure with split+aggregation test" -``` - ---- - -### Task 4: Execution Tree Integration Tests — Circuit Breaker Inside Split - -**Files:** -- Modify: `cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionTreeIntegrationTest.java` - -- [ ] **Step 1: Add the CB-inside-split test** - -Add this test method to `ExecutionTreeIntegrationTest`: - -```java - @Test - void splitWithCircuitBreaker_cbProcessorsNested() throws Exception { - camelContext.addRoutes(new RouteBuilder() { - @Override - public void configure() { - from("direct:split-cb") - .routeId("split-cb") - .split(body()) - .circuitBreaker() - .log("inside CB: ${body}") - .onFallback() - .log("fallback") - .end() - .end(); - } - }); - startContext(); - - producer.sendBody("direct:split-cb", List.of("a", "b")); - assertTrue(testExporter.awaitExecution(5, TimeUnit.SECONDS)); - - RouteExecution exec = testExporter.getExecutions().get(0); - ProcessorExecution splitProc = findProcessor(exec.getProcessors(), "split"); - assertNotNull(splitProc); - - // Must have exactly 2 iteration wrappers (not 4 — no phantom CB wrappers) - long iterCount = splitProc.getChildren().stream() - .filter(c -> "splitIteration".equals(c.getProcessorType())) - .count(); - assertEquals(2, iterCount, - "Split must have exactly 2 iteration wrappers — CB internal exchanges must NOT create phantom wrappers"); - - // CB must be inside the iteration wrapper, not at root - for (ProcessorExecution iter : splitProc.getChildren()) { - if (!"splitIteration".equals(iter.getProcessorType())) continue; - ProcessorExecution cb = findProcessor(iter.getChildren(), "circuitBreaker"); - assertNotNull(cb, "CircuitBreaker must be a child inside the iteration wrapper"); - } - } -``` - -- [ ] **Step 2: Run to verify the test passes** - -Run: `mvn test -pl cameleer-agent -Dtest="ExecutionTreeIntegrationTest#splitWithCircuitBreaker_cbProcessorsNested" -am` -Expected: PASS - -- [ ] **Step 3: Commit** - -```bash -git add cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionTreeIntegrationTest.java -git commit -m "test: add split+circuitBreaker integration test — prevents phantom wrapper regression" -``` - ---- - -### Task 5: Execution Tree Integration Tests — Filter and Idempotent Gate State - -**Files:** -- Modify: `cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionTreeIntegrationTest.java` - -- [ ] **Step 1: Add filter rejection and idempotent duplicate tests** - -```java - @Test - void splitWithFilter_rejectedItemHasEmptyChildren() throws Exception { - camelContext.addRoutes(new RouteBuilder() { - @Override - public void configure() { - from("direct:split-filter") - .routeId("split-filter") - .split(body()) - .filter(simple("${body} != 'reject-me'")) - .log("passed: ${body}") - .end() - .end(); - } - }); - startContext(); - - producer.sendBody("direct:split-filter", List.of("keep", "reject-me", "also-keep")); - assertTrue(testExporter.awaitExecution(5, TimeUnit.SECONDS)); - - RouteExecution exec = testExporter.getExecutions().get(0); - ProcessorExecution splitProc = findProcessor(exec.getProcessors(), "split"); - assertNotNull(splitProc); - - assertEquals(3, splitProc.getChildren().stream() - .filter(c -> "splitIteration".equals(c.getProcessorType())).count()); - - // Find the filter in the rejected iteration (index 1) - ProcessorExecution rejectedIter = splitProc.getChildren().stream() - .filter(c -> "splitIteration".equals(c.getProcessorType()) && Integer.valueOf(1).equals(c.getSplitIndex())) - .findFirst().orElse(null); - assertNotNull(rejectedIter); - - ProcessorExecution rejectedFilter = findProcessor(rejectedIter.getChildren(), "filter"); - assertNotNull(rejectedFilter, "Filter must exist in rejected iteration"); - assertTrue(rejectedFilter.getChildren().isEmpty(), - "Rejected filter must have empty children (downstream processors skipped)"); - } - - @Test - void splitWithIdempotent_duplicateDetected() throws Exception { - camelContext.addRoutes(new RouteBuilder() { - @Override - public void configure() { - from("direct:split-idem") - .routeId("split-idem") - .split(body()) - .idempotentConsumer(simple("${body}"), - org.apache.camel.support.processor.idempotent.MemoryIdempotentRepository - .memoryIdempotentRepository(200)) - .log("unique: ${body}") - .end() - .end(); - } - }); - startContext(); - - // "dup" appears twice — second occurrence should be detected as duplicate - producer.sendBody("direct:split-idem", List.of("a", "dup", "b", "dup")); - assertTrue(testExporter.awaitExecution(5, TimeUnit.SECONDS)); - - RouteExecution exec = testExporter.getExecutions().get(0); - ProcessorExecution splitProc = findProcessor(exec.getProcessors(), "split"); - assertNotNull(splitProc); - - assertEquals(4, splitProc.getChildren().stream() - .filter(c -> "splitIteration".equals(c.getProcessorType())).count()); - - // Count how many idempotentConsumer processors have children (the log) - // The first "dup" should pass (has children), the second should be rejected (empty children) - long idempotentWithChildren = 0; - long idempotentEmpty = 0; - for (ProcessorExecution iter : splitProc.getChildren()) { - ProcessorExecution idem = findProcessor(iter.getChildren(), "idempotentConsumer"); - if (idem != null) { - if (idem.getChildren().isEmpty()) { - idempotentEmpty++; - } else { - idempotentWithChildren++; - } - } - } - assertEquals(3, idempotentWithChildren, "3 unique items should pass through idempotent consumer"); - assertEquals(1, idempotentEmpty, "1 duplicate should be rejected (empty children)"); - } -``` - -- [ ] **Step 2: Run tests** - -Run: `mvn test -pl cameleer-agent -Dtest="ExecutionTreeIntegrationTest#splitWithFilter_rejectedItemHasEmptyChildren+splitWithIdempotent_duplicateDetected" -am` -Expected: PASS - -- [ ] **Step 3: Commit** - -```bash -git add cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionTreeIntegrationTest.java -git commit -m "test: add filter rejection and idempotent duplicate integration tests" -``` - ---- - -### Task 6: Execution Tree Integration Tests — Multicast, RecipientList, Loop - -**Files:** -- Modify: `cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionTreeIntegrationTest.java` - -- [ ] **Step 1: Add multicast, recipientList, and loop tests** - -```java - @Test - void multicastExecution_branchWrappersCreated() throws Exception { - camelContext.addRoutes(new RouteBuilder() { - @Override - public void configure() { - from("direct:multicast-test") - .routeId("multicast-test") - .multicast() - .to("direct:mc-a", "direct:mc-b") - .end(); - - from("direct:mc-a").routeId("mc-a").log("branch A"); - from("direct:mc-b").routeId("mc-b").log("branch B"); - } - }); - startContext(); - - producer.sendBody("direct:multicast-test", "test"); - assertTrue(testExporter.awaitExecution(5, TimeUnit.SECONDS)); - - RouteExecution exec = testExporter.getExecutions().get(0); - ProcessorExecution mcProc = findProcessor(exec.getProcessors(), "multicast"); - assertNotNull(mcProc, "Multicast processor must be in execution tree"); - - long branchCount = mcProc.getChildren().stream() - .filter(c -> "multicastBranch".equals(c.getProcessorType())) - .count(); - assertEquals(2, branchCount, "Multicast must have 2 branch wrappers"); - } - - @Test - void recipientListExecution_resolvedUris() throws Exception { - camelContext.addRoutes(new RouteBuilder() { - @Override - public void configure() { - from("direct:recipient-test") - .routeId("recipient-test") - .setHeader("targets", constant("direct:rl-a,direct:rl-b")) - .recipientList(header("targets")).delimiter(",") - .end(); - - from("direct:rl-a").routeId("rl-a").log("recipient A"); - from("direct:rl-b").routeId("rl-b").log("recipient B"); - } - }); - startContext(); - - producer.sendBody("direct:recipient-test", "test"); - assertTrue(testExporter.awaitExecution(5, TimeUnit.SECONDS)); - - RouteExecution exec = testExporter.getExecutions().get(0); - ProcessorExecution rlProc = findProcessor(exec.getProcessors(), "recipientList"); - assertNotNull(rlProc, "RecipientList processor must be in execution tree"); - - long branchCount = rlProc.getChildren().stream() - .filter(c -> "recipientListBranch".equals(c.getProcessorType())) - .count(); - assertEquals(2, branchCount, "RecipientList must have 2 branch wrappers"); - - // Verify resolved URIs are captured on branches - boolean hasResolvedUri = rlProc.getChildren().stream() - .filter(c -> "recipientListBranch".equals(c.getProcessorType())) - .allMatch(c -> c.getResolvedEndpointUri() != null && !c.getResolvedEndpointUri().isEmpty()); - assertTrue(hasResolvedUri, "RecipientList branches must have resolvedEndpointUri set"); - } - - @Test - void loopExecution_iterationWrappers() throws Exception { - camelContext.addRoutes(new RouteBuilder() { - @Override - public void configure() { - from("direct:loop-test") - .routeId("loop-test") - .loop(3) - .log("iteration ${header.CamelLoopIndex}") - .end(); - } - }); - startContext(); - - producer.sendBody("direct:loop-test", "test"); - assertTrue(testExporter.awaitExecution(5, TimeUnit.SECONDS)); - - RouteExecution exec = testExporter.getExecutions().get(0); - ProcessorExecution loopProc = findProcessor(exec.getProcessors(), "loop"); - assertNotNull(loopProc, "Loop processor must be in execution tree"); - - long iterCount = loopProc.getChildren().stream() - .filter(c -> "loopIteration".equals(c.getProcessorType())) - .count(); - assertEquals(3, iterCount, "Loop must have 3 iteration wrappers"); - } - - @Test - void nestedSplitLoop_correctNesting() throws Exception { - camelContext.addRoutes(new RouteBuilder() { - @Override - public void configure() { - from("direct:split-loop") - .routeId("split-loop") - .split(body()) - .loop(2) - .log("item ${body} iter ${header.CamelLoopIndex}") - .end() - .end(); - } - }); - startContext(); - - producer.sendBody("direct:split-loop", List.of("a", "b")); - assertTrue(testExporter.awaitExecution(5, TimeUnit.SECONDS)); - - RouteExecution exec = testExporter.getExecutions().get(0); - ProcessorExecution splitProc = findProcessor(exec.getProcessors(), "split"); - assertNotNull(splitProc); - - // 2 split iterations, each containing a loop with 2 loop iterations - for (ProcessorExecution iter : splitProc.getChildren()) { - if (!"splitIteration".equals(iter.getProcessorType())) continue; - ProcessorExecution loopProc = findProcessor(iter.getChildren(), "loop"); - assertNotNull(loopProc, "Each split iteration must contain a loop"); - long loopIters = loopProc.getChildren().stream() - .filter(c -> "loopIteration".equals(c.getProcessorType())) - .count(); - assertEquals(2, loopIters, "Each loop must have 2 iteration wrappers"); - } - } -``` - -- [ ] **Step 2: Run all new tests** - -Run: `mvn test -pl cameleer-agent -Dtest="ExecutionTreeIntegrationTest" -am` -Expected: All PASS - -- [ ] **Step 3: Commit** - -```bash -git add cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionTreeIntegrationTest.java -git commit -m "test: add multicast, recipientList, loop, and nested split-loop integration tests" -``` - ---- - -### Task 7: Sample App Test Enhancements - -**Files:** -- Create: `cameleer-sample-app/src/test/java/com/cameleer/sample/test/TestExporterConfig.java` -- Modify: `cameleer-sample-app/src/test/java/com/cameleer/sample/routes/NestedSplitRouteTest.java` - -- [ ] **Step 1: Create TestExporter config for sample app** - -The sample app loads the agent via the shaded JAR. To access the ExecutionCollector's exporter from tests, we need a different approach. Since the agent is loaded as a Java agent (not via Spring), the simplest approach is to create a test utility that accesses the agent's static state. - -However, the agent is shaded and loaded on the system classloader — accessing its internals from Spring Boot test code is non-trivial (classloader boundary). - -**Pragmatic alternative:** Instead of wiring a TestExporter into the sample app, add assertions that verify execution data indirectly — check that the agent's `LogExporter` produced output by capturing stdout, or add a system property that points to a test exporter class. - -Given the classloader complexity, the **recommended approach for now** is to focus the execution tree assertions in the agent module's integration tests (Tasks 3-6) where we have full access. The sample app tests keep their existing route-correctness assertions. - -Skip this task if the classloader boundary makes it impractical. The agent module integration tests provide the primary safety net. - -- [ ] **Step 2: If skipped, document why** - -Add a comment to the plan noting that sample app test enhancements are deferred due to the Java agent classloader boundary. The agent module integration tests in Task 3-6 cover the critical execution tree verification. - -- [ ] **Step 3: Commit (if any changes)** - ---- - -### Task 8: Run Full Test Suite and Verify - -**Files:** None (verification only) - -- [ ] **Step 1: Run all agent tests** - -Run: `mvn test -pl cameleer-agent -am` -Expected: All tests pass (existing unit tests + new integration tests) - -- [ ] **Step 2: Run all sample app tests** - -Run: `mvn test -pl cameleer-sample-app` -Expected: All 20 tests pass - -- [ ] **Step 3: Run full build** - -Run: `mvn clean verify` -Expected: BUILD SUCCESS with all modules green - -- [ ] **Step 4: Final commit with spec doc** - -```bash -git add docs/superpowers/specs/2026-03-30-execution-tracking-integration-tests-design.md -git add docs/superpowers/plans/2026-03-30-execution-tracking-integration-tests.md -git commit -m "docs: add execution tracking integration test spec and plan" -``` diff --git a/docs/superpowers/plans/2026-03-31-chunked-transport-clickhouse.md b/docs/superpowers/plans/2026-03-31-chunked-transport-clickhouse.md deleted file mode 100644 index c01e440..0000000 --- a/docs/superpowers/plans/2026-03-31-chunked-transport-clickhouse.md +++ /dev/null @@ -1,1784 +0,0 @@ -# Chunked Transport with ClickHouse-Native Storage Implementation Plan - -> **Status: COMPLETED** — Verified 2026-04-09. All components implemented and tested. - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. - -**Goal:** Replace the atomic RouteExecution tree transport with chunked flat processor records using seq/parentSeq, enabling incremental flushing and ClickHouse-optimized append-only storage. - -**Architecture:** Agent emits flat `FlatProcessorRecord`s with `seq`/`parentSeq`/`iteration` fields into a bounded buffer. A `ChunkedExporter` wraps buffered records in an exchange envelope (`ExecutionChunk`) and flushes via HTTP when count, size, or time thresholds are reached. Server decomposes chunks into exchange upserts + processor row inserts. Tree reconstruction is server-side, on-demand, O(n). - -**Tech Stack:** Java 17, Jackson (via `CameleerJson`), Apache Camel 4.10, JUnit 5 + Mockito, existing `ServerConnection` HTTP client. - -**Design Spec:** `docs/superpowers/specs/2026-03-31-chunked-transport-clickhouse-design.md` - ---- - -## File Structure - -### New Files (cameleer-common) - -| File | Responsibility | -|---|---| -| `cameleer-common/src/main/java/com/cameleer/common/model/FlatProcessorRecord.java` | Flat processor execution record with seq/parentSeq/iteration | -| `cameleer-common/src/main/java/com/cameleer/common/model/ExecutionChunk.java` | Chunk document: exchange envelope + list of FlatProcessorRecords | -| `cameleer-common/src/test/java/com/cameleer/common/model/FlatProcessorRecordSerializationTest.java` | JSON round-trip tests for FlatProcessorRecord | -| `cameleer-common/src/test/java/com/cameleer/common/model/ExecutionChunkSerializationTest.java` | JSON round-trip tests for ExecutionChunk | - -### New Files (cameleer-agent) - -| File | Responsibility | -|---|---| -| `cameleer-agent/src/main/java/com/cameleer/agent/collector/ExchangeState.java` | Per-exchange tracking state: seqCounter, processorSeqStack, loopStates, buffer, envelope | -| `cameleer-agent/src/main/java/com/cameleer/agent/collector/LoopTrackingState.java` | Lightweight loop state: loopSeq, stackDepthAtEntry, currentIndex, iterationCount | -| `cameleer-agent/src/main/java/com/cameleer/agent/collector/SubExchangeContext.java` | Sub-exchange parent tracking: parentContainerSeq, parentContainerProcessorId, iteration | -| `cameleer-agent/src/main/java/com/cameleer/agent/collector/FlatExecutionCollector.java` | New collector producing flat records — replaces tree-building ExecutionCollector | -| `cameleer-agent/src/main/java/com/cameleer/agent/export/ChunkedExporter.java` | Buffers FlatProcessorRecords, flushes ExecutionChunks via ServerConnection | -| `cameleer-agent/src/test/java/com/cameleer/agent/collector/FlatExecutionCollectorTest.java` | Unit tests for flat collector with mocked exchanges | -| `cameleer-agent/src/test/java/com/cameleer/agent/collector/FlatExecutionTreeIntegrationTest.java` | Integration tests with real Camel context verifying flat record correctness | -| `cameleer-agent/src/test/java/com/cameleer/agent/export/ChunkedExporterTest.java` | Unit tests for chunked exporter buffering, flushing, back-pressure | -| `cameleer-agent/src/test/java/com/cameleer/agent/test/TestChunkedExporter.java` | In-memory ChunkedExporter for integration tests (analogous to TestExporter) | - -### Modified Files - -| File | Change | -|---|---| -| `cameleer-agent/.../export/Exporter.java` | Add `exportChunk(ExecutionChunk)` default method, deprecate `exportExecution(RouteExecution)` | -| `cameleer-agent/.../instrumentation/CameleerHookInstaller.java` | Wire FlatExecutionCollector + ChunkedExporter when transport v2 enabled | -| `cameleer-common/.../model/ProcessorExecution.java` | Deprecate `children`, `addChild()`, `splitIndex`, `splitSize`, `loopIndex`, `loopSize`, `multicastIndex` | -| `cameleer-agent/.../export/HttpExporter.java` | Deprecate class | -| `cameleer-agent/.../collector/ExecutionCollector.java` | Deprecate class | -| `cameleer-agent/.../collector/SubExchangeTracker.java` | Deprecate class | - ---- - -## Task 1: FlatProcessorRecord Model - -**Files:** -- Create: `cameleer-common/src/main/java/com/cameleer/common/model/FlatProcessorRecord.java` -- Test: `cameleer-common/src/test/java/com/cameleer/common/model/FlatProcessorRecordSerializationTest.java` - -- [x] **Step 1: Write serialization test** - -```java -package com.cameleer.common.model; - -import com.cameleer.common.json.CameleerJson; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Test; - -import java.time.Instant; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; - -class FlatProcessorRecordSerializationTest { - - private static final ObjectMapper MAPPER = CameleerJson.mapper(); - - @Test - void roundTrip_allFieldsPopulated() throws Exception { - FlatProcessorRecord record = new FlatProcessorRecord(); - record.setSeq(3); - record.setParentSeq(2); - record.setParentProcessorId("split1"); - record.setProcessorId("log2"); - record.setProcessorType("log"); - record.setIteration(0); - record.setIterationSize(100); - record.setStatus(ExecutionStatus.COMPLETED); - record.setStartTime(Instant.parse("2026-03-28T14:30:00.124Z")); - record.setDurationMs(1); - record.setResolvedEndpointUri("direct://target"); - record.setInputBody("{\"order\":1}"); - record.setOutputBody("{\"result\":\"ok\"}"); - record.setInputHeaders(Map.of("Content-Type", "application/json")); - record.setOutputHeaders(Map.of("Content-Type", "application/json")); - record.setErrorMessage("fail"); - record.setErrorStackTrace("java.lang.RuntimeException: fail\n\tat ..."); - record.setErrorType("java.lang.RuntimeException"); - record.setErrorCategory("UNKNOWN"); - record.setRootCauseType("java.lang.RuntimeException"); - record.setRootCauseMessage("fail"); - record.setAttributes(Map.of("orderId", "ORD-001")); - record.setCircuitBreakerState("CLOSED"); - record.setFallbackTriggered(false); - record.setFilterMatched(true); - record.setDuplicateMessage(false); - - String json = MAPPER.writeValueAsString(record); - FlatProcessorRecord deserialized = MAPPER.readValue(json, FlatProcessorRecord.class); - - assertEquals(3, deserialized.getSeq()); - assertEquals(2, deserialized.getParentSeq()); - assertEquals("split1", deserialized.getParentProcessorId()); - assertEquals("log2", deserialized.getProcessorId()); - assertEquals("log", deserialized.getProcessorType()); - assertEquals(0, deserialized.getIteration()); - assertEquals(100, deserialized.getIterationSize()); - assertEquals(ExecutionStatus.COMPLETED, deserialized.getStatus()); - assertEquals(1, deserialized.getDurationMs()); - assertEquals("direct://target", deserialized.getResolvedEndpointUri()); - assertEquals("{\"order\":1}", deserialized.getInputBody()); - assertEquals("{\"result\":\"ok\"}", deserialized.getOutputBody()); - assertEquals(Map.of("Content-Type", "application/json"), deserialized.getInputHeaders()); - assertEquals(Map.of("Content-Type", "application/json"), deserialized.getOutputHeaders()); - assertEquals("fail", deserialized.getErrorMessage()); - assertEquals("java.lang.RuntimeException", deserialized.getErrorType()); - assertEquals("UNKNOWN", deserialized.getErrorCategory()); - assertEquals(Map.of("orderId", "ORD-001"), deserialized.getAttributes()); - assertEquals("CLOSED", deserialized.getCircuitBreakerState()); - assertFalse(deserialized.getFallbackTriggered()); - assertTrue(deserialized.getFilterMatched()); - assertFalse(deserialized.getDuplicateMessage()); - } - - @Test - void nullFieldsOmitted_nonNullInclusion() throws Exception { - FlatProcessorRecord record = new FlatProcessorRecord(); - record.setSeq(1); - record.setProcessorId("log1"); - record.setProcessorType("log"); - record.setStatus(ExecutionStatus.COMPLETED); - record.setStartTime(Instant.parse("2026-03-28T14:30:00.124Z")); - record.setDurationMs(1); - - String json = MAPPER.writeValueAsString(record); - - assertFalse(json.contains("parentSeq"), "null parentSeq should be omitted"); - assertFalse(json.contains("parentProcessorId"), "null parentProcessorId should be omitted"); - assertFalse(json.contains("iteration"), "null iteration should be omitted"); - assertFalse(json.contains("inputBody"), "null inputBody should be omitted"); - assertFalse(json.contains("errorMessage"), "null errorMessage should be omitted"); - assertFalse(json.contains("attributes"), "null attributes should be omitted"); - } - - @Test - void rootProcessor_nullParentFields() throws Exception { - FlatProcessorRecord record = new FlatProcessorRecord(); - record.setSeq(1); - record.setParentSeq(null); - record.setParentProcessorId(null); - record.setProcessorId("from1"); - record.setProcessorType("from"); - record.setStatus(ExecutionStatus.COMPLETED); - record.setStartTime(Instant.now()); - record.setDurationMs(0); - - String json = MAPPER.writeValueAsString(record); - FlatProcessorRecord deserialized = MAPPER.readValue(json, FlatProcessorRecord.class); - - assertNull(deserialized.getParentSeq()); - assertNull(deserialized.getParentProcessorId()); - } -} -``` - -- [x] **Step 2: Run test to verify it fails** - -Run: `mvn test -pl cameleer-common -Dtest=FlatProcessorRecordSerializationTest -f pom.xml` -Expected: FAIL — `FlatProcessorRecord` class does not exist - -- [x] **Step 3: Write FlatProcessorRecord implementation** - -```java -package com.cameleer.common.model; - -import com.fasterxml.jackson.annotation.JsonInclude; - -import java.time.Instant; -import java.util.Map; - -@JsonInclude(JsonInclude.Include.NON_NULL) -public class FlatProcessorRecord { - - private int seq; - private Integer parentSeq; - private String parentProcessorId; - private String processorId; - private String processorType; - private Integer iteration; - private Integer iterationSize; - private ExecutionStatus status; - private Instant startTime; - private long durationMs; - private String resolvedEndpointUri; - private String inputBody; - private String outputBody; - private Map inputHeaders; - private Map outputHeaders; - private String errorMessage; - private String errorStackTrace; - private String errorType; - private String errorCategory; - private String rootCauseType; - private String rootCauseMessage; - private Map attributes; - private String circuitBreakerState; - private Boolean fallbackTriggered; - private Boolean filterMatched; - private Boolean duplicateMessage; - - public FlatProcessorRecord() {} - - public FlatProcessorRecord(int seq, String processorId, String processorType) { - this.seq = seq; - this.processorId = processorId; - this.processorType = processorType; - this.status = ExecutionStatus.RUNNING; - } - - // --- Getters and Setters --- - - public int getSeq() { return seq; } - public void setSeq(int seq) { this.seq = seq; } - - public Integer getParentSeq() { return parentSeq; } - public void setParentSeq(Integer parentSeq) { this.parentSeq = parentSeq; } - - public String getParentProcessorId() { return parentProcessorId; } - public void setParentProcessorId(String parentProcessorId) { this.parentProcessorId = parentProcessorId; } - - public String getProcessorId() { return processorId; } - public void setProcessorId(String processorId) { this.processorId = processorId; } - - public String getProcessorType() { return processorType; } - public void setProcessorType(String processorType) { this.processorType = processorType; } - - public Integer getIteration() { return iteration; } - public void setIteration(Integer iteration) { this.iteration = iteration; } - - public Integer getIterationSize() { return iterationSize; } - public void setIterationSize(Integer iterationSize) { this.iterationSize = iterationSize; } - - public ExecutionStatus getStatus() { return status; } - public void setStatus(ExecutionStatus status) { this.status = status; } - - public Instant getStartTime() { return startTime; } - public void setStartTime(Instant startTime) { this.startTime = startTime; } - - public long getDurationMs() { return durationMs; } - public void setDurationMs(long durationMs) { this.durationMs = durationMs; } - - public String getResolvedEndpointUri() { return resolvedEndpointUri; } - public void setResolvedEndpointUri(String resolvedEndpointUri) { this.resolvedEndpointUri = resolvedEndpointUri; } - - public String getInputBody() { return inputBody; } - public void setInputBody(String inputBody) { this.inputBody = inputBody; } - - public String getOutputBody() { return outputBody; } - public void setOutputBody(String outputBody) { this.outputBody = outputBody; } - - public Map getInputHeaders() { return inputHeaders; } - public void setInputHeaders(Map inputHeaders) { this.inputHeaders = inputHeaders; } - - public Map getOutputHeaders() { return outputHeaders; } - public void setOutputHeaders(Map outputHeaders) { this.outputHeaders = outputHeaders; } - - public String getErrorMessage() { return errorMessage; } - public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } - - public String getErrorStackTrace() { return errorStackTrace; } - public void setErrorStackTrace(String errorStackTrace) { this.errorStackTrace = errorStackTrace; } - - public String getErrorType() { return errorType; } - public void setErrorType(String errorType) { this.errorType = errorType; } - - public String getErrorCategory() { return errorCategory; } - public void setErrorCategory(String errorCategory) { this.errorCategory = errorCategory; } - - public String getRootCauseType() { return rootCauseType; } - public void setRootCauseType(String rootCauseType) { this.rootCauseType = rootCauseType; } - - public String getRootCauseMessage() { return rootCauseMessage; } - public void setRootCauseMessage(String rootCauseMessage) { this.rootCauseMessage = rootCauseMessage; } - - public Map getAttributes() { return attributes; } - public void setAttributes(Map attributes) { this.attributes = attributes; } - - public String getCircuitBreakerState() { return circuitBreakerState; } - public void setCircuitBreakerState(String circuitBreakerState) { this.circuitBreakerState = circuitBreakerState; } - - public Boolean getFallbackTriggered() { return fallbackTriggered; } - public void setFallbackTriggered(Boolean fallbackTriggered) { this.fallbackTriggered = fallbackTriggered; } - - public Boolean getFilterMatched() { return filterMatched; } - public void setFilterMatched(Boolean filterMatched) { this.filterMatched = filterMatched; } - - public Boolean getDuplicateMessage() { return duplicateMessage; } - public void setDuplicateMessage(Boolean duplicateMessage) { this.duplicateMessage = duplicateMessage; } -} -``` - -- [x] **Step 4: Run test to verify it passes** - -Run: `mvn test -pl cameleer-common -Dtest=FlatProcessorRecordSerializationTest -f pom.xml` -Expected: PASS (3 tests) - -- [x] **Step 5: Commit** - -```bash -git add cameleer-common/src/main/java/com/cameleer/common/model/FlatProcessorRecord.java cameleer-common/src/test/java/com/cameleer/common/model/FlatProcessorRecordSerializationTest.java -git commit -m "feat: add FlatProcessorRecord model for chunked transport" -``` - ---- - -## Task 2: ExecutionChunk Model - -**Files:** -- Create: `cameleer-common/src/main/java/com/cameleer/common/model/ExecutionChunk.java` -- Test: `cameleer-common/src/test/java/com/cameleer/common/model/ExecutionChunkSerializationTest.java` - -- [x] **Step 1: Write serialization test** - -```java -package com.cameleer.common.model; - -import com.cameleer.common.json.CameleerJson; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Test; - -import java.time.Instant; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; - -class ExecutionChunkSerializationTest { - - private static final ObjectMapper MAPPER = CameleerJson.mapper(); - - @Test - void roundTrip_runningChunkWithProcessors() throws Exception { - FlatProcessorRecord proc1 = new FlatProcessorRecord(1, "log1", "log"); - proc1.setStatus(ExecutionStatus.COMPLETED); - proc1.setStartTime(Instant.parse("2026-03-28T14:30:00.124Z")); - proc1.setDurationMs(1); - - FlatProcessorRecord proc2 = new FlatProcessorRecord(2, "split1", "split"); - proc2.setStatus(ExecutionStatus.COMPLETED); - proc2.setStartTime(Instant.parse("2026-03-28T14:30:00.125Z")); - proc2.setDurationMs(500); - proc2.setIterationSize(100); - - ExecutionChunk chunk = new ExecutionChunk(); - chunk.setExchangeId("A2A3C980AC95F62-0000000000001234"); - chunk.setApplicationName("order-service"); - chunk.setAgentId("order-service-pod-7f8d9"); - chunk.setRouteId("order-processing"); - chunk.setCorrelationId("A2A3C980AC95F62-0000000000001234"); - chunk.setStatus(ExecutionStatus.RUNNING); - chunk.setStartTime(Instant.parse("2026-03-28T14:30:00.123Z")); - chunk.setEngineLevel("REGULAR"); - chunk.setChunkSeq(0); - chunk.setFinal(false); - chunk.setProcessors(List.of(proc1, proc2)); - - String json = MAPPER.writeValueAsString(chunk); - ExecutionChunk deserialized = MAPPER.readValue(json, ExecutionChunk.class); - - assertEquals("A2A3C980AC95F62-0000000000001234", deserialized.getExchangeId()); - assertEquals("order-service", deserialized.getApplicationName()); - assertEquals("order-service-pod-7f8d9", deserialized.getAgentId()); - assertEquals("order-processing", deserialized.getRouteId()); - assertEquals(ExecutionStatus.RUNNING, deserialized.getStatus()); - assertEquals(0, deserialized.getChunkSeq()); - assertFalse(deserialized.isFinal()); - assertNull(deserialized.getEndTime()); - assertNull(deserialized.getDurationMs()); - assertEquals(2, deserialized.getProcessors().size()); - assertEquals("log1", deserialized.getProcessors().get(0).getProcessorId()); - assertEquals(100, deserialized.getProcessors().get(1).getIterationSize()); - } - - @Test - void roundTrip_finalChunkWithErrors() throws Exception { - ExecutionChunk chunk = new ExecutionChunk(); - chunk.setExchangeId("ex-fail-1"); - chunk.setApplicationName("order-service"); - chunk.setAgentId("pod-1"); - chunk.setRouteId("error-route"); - chunk.setCorrelationId("ex-fail-1"); - chunk.setStatus(ExecutionStatus.FAILED); - chunk.setStartTime(Instant.parse("2026-03-28T14:30:00.123Z")); - chunk.setEndTime(Instant.parse("2026-03-28T14:30:00.200Z")); - chunk.setDurationMs(77L); - chunk.setEngineLevel("REGULAR"); - chunk.setErrorMessage("Connection refused"); - chunk.setErrorType("java.net.ConnectException"); - chunk.setErrorCategory("CONNECTION"); - chunk.setChunkSeq(2); - chunk.setFinal(true); - chunk.setAttributes(Map.of("orderId", "ORD-FAIL")); - chunk.setProcessors(List.of()); - - String json = MAPPER.writeValueAsString(chunk); - ExecutionChunk deserialized = MAPPER.readValue(json, ExecutionChunk.class); - - assertEquals(ExecutionStatus.FAILED, deserialized.getStatus()); - assertTrue(deserialized.isFinal()); - assertEquals(77L, deserialized.getDurationMs()); - assertEquals("CONNECTION", deserialized.getErrorCategory()); - assertEquals(Map.of("orderId", "ORD-FAIL"), deserialized.getAttributes()); - } - - @Test - void emptyProcessors_serializedAsEmptyArray() throws Exception { - ExecutionChunk chunk = new ExecutionChunk(); - chunk.setExchangeId("ex-1"); - chunk.setApplicationName("app"); - chunk.setAgentId("agent-1"); - chunk.setRouteId("route-1"); - chunk.setCorrelationId("ex-1"); - chunk.setStatus(ExecutionStatus.COMPLETED); - chunk.setStartTime(Instant.now()); - chunk.setEndTime(Instant.now()); - chunk.setDurationMs(0L); - chunk.setEngineLevel("REGULAR"); - chunk.setChunkSeq(0); - chunk.setFinal(true); - chunk.setProcessors(List.of()); - - String json = MAPPER.writeValueAsString(chunk); - assertTrue(json.contains("\"processors\":[]")); - } -} -``` - -- [x] **Step 2: Run test to verify it fails** - -Run: `mvn test -pl cameleer-common -Dtest=ExecutionChunkSerializationTest -f pom.xml` -Expected: FAIL — `ExecutionChunk` class does not exist - -- [x] **Step 3: Write ExecutionChunk implementation** - -```java -package com.cameleer.common.model; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -@JsonInclude(JsonInclude.Include.NON_NULL) -public class ExecutionChunk { - - private String exchangeId; - private String applicationName; - private String agentId; - private String routeId; - private String correlationId; - private ExecutionStatus status; - private Instant startTime; - private Instant endTime; - private Long durationMs; - private String engineLevel; - private String errorMessage; - private String errorStackTrace; - private String errorType; - private String errorCategory; - private String rootCauseType; - private String rootCauseMessage; - private Map attributes; - private String traceId; - private String spanId; - private String originalExchangeId; - private String replayExchangeId; - - private int chunkSeq; - @JsonProperty("final") - private boolean isFinal; - - private List processors = new ArrayList<>(); - - public ExecutionChunk() {} - - // --- Getters and Setters --- - - public String getExchangeId() { return exchangeId; } - public void setExchangeId(String exchangeId) { this.exchangeId = exchangeId; } - - public String getApplicationName() { return applicationName; } - public void setApplicationName(String applicationName) { this.applicationName = applicationName; } - - public String getAgentId() { return agentId; } - public void setAgentId(String agentId) { this.agentId = agentId; } - - public String getRouteId() { return routeId; } - public void setRouteId(String routeId) { this.routeId = routeId; } - - public String getCorrelationId() { return correlationId; } - public void setCorrelationId(String correlationId) { this.correlationId = correlationId; } - - public ExecutionStatus getStatus() { return status; } - public void setStatus(ExecutionStatus status) { this.status = status; } - - public Instant getStartTime() { return startTime; } - public void setStartTime(Instant startTime) { this.startTime = startTime; } - - public Instant getEndTime() { return endTime; } - public void setEndTime(Instant endTime) { this.endTime = endTime; } - - public Long getDurationMs() { return durationMs; } - public void setDurationMs(Long durationMs) { this.durationMs = durationMs; } - - public String getEngineLevel() { return engineLevel; } - public void setEngineLevel(String engineLevel) { this.engineLevel = engineLevel; } - - public String getErrorMessage() { return errorMessage; } - public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } - - public String getErrorStackTrace() { return errorStackTrace; } - public void setErrorStackTrace(String errorStackTrace) { this.errorStackTrace = errorStackTrace; } - - public String getErrorType() { return errorType; } - public void setErrorType(String errorType) { this.errorType = errorType; } - - public String getErrorCategory() { return errorCategory; } - public void setErrorCategory(String errorCategory) { this.errorCategory = errorCategory; } - - public String getRootCauseType() { return rootCauseType; } - public void setRootCauseType(String rootCauseType) { this.rootCauseType = rootCauseType; } - - public String getRootCauseMessage() { return rootCauseMessage; } - public void setRootCauseMessage(String rootCauseMessage) { this.rootCauseMessage = rootCauseMessage; } - - public Map getAttributes() { return attributes; } - public void setAttributes(Map attributes) { this.attributes = attributes; } - - public String getTraceId() { return traceId; } - public void setTraceId(String traceId) { this.traceId = traceId; } - - public String getSpanId() { return spanId; } - public void setSpanId(String spanId) { this.spanId = spanId; } - - public String getOriginalExchangeId() { return originalExchangeId; } - public void setOriginalExchangeId(String originalExchangeId) { this.originalExchangeId = originalExchangeId; } - - public String getReplayExchangeId() { return replayExchangeId; } - public void setReplayExchangeId(String replayExchangeId) { this.replayExchangeId = replayExchangeId; } - - public int getChunkSeq() { return chunkSeq; } - public void setChunkSeq(int chunkSeq) { this.chunkSeq = chunkSeq; } - - public boolean isFinal() { return isFinal; } - public void setFinal(boolean isFinal) { this.isFinal = isFinal; } - - public List getProcessors() { return processors; } - public void setProcessors(List processors) { this.processors = processors; } -} -``` - -- [x] **Step 4: Run test to verify it passes** - -Run: `mvn test -pl cameleer-common -Dtest=ExecutionChunkSerializationTest -f pom.xml` -Expected: PASS (3 tests) - -- [x] **Step 5: Commit** - -```bash -git add cameleer-common/src/main/java/com/cameleer/common/model/ExecutionChunk.java cameleer-common/src/test/java/com/cameleer/common/model/ExecutionChunkSerializationTest.java -git commit -m "feat: add ExecutionChunk model for chunked transport envelope" -``` - ---- - -## Task 3: Agent State Classes (ExchangeState, LoopTrackingState, SubExchangeContext) - -**Files:** -- Create: `cameleer-agent/src/main/java/com/cameleer/agent/collector/ExchangeState.java` -- Create: `cameleer-agent/src/main/java/com/cameleer/agent/collector/LoopTrackingState.java` -- Create: `cameleer-agent/src/main/java/com/cameleer/agent/collector/SubExchangeContext.java` - -These are simple data holders with no complex logic — tested transitively via FlatExecutionCollector tests. - -- [x] **Step 1: Write ExchangeState** - -```java -package com.cameleer.agent.collector; - -import com.cameleer.common.model.ExecutionStatus; -import com.cameleer.common.model.FlatProcessorRecord; - -import java.time.Instant; -import java.util.ArrayDeque; -import java.util.Deque; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * Per-exchange tracking state for flat record emission. - * Replaces the tree-based tracking in ExecutionCollector. - */ -class ExchangeState { - - final String exchangeId; - final String routeId; - final String correlationId; - final Instant startTime; - final long startNanos; - final String engineLevel; - final AtomicInteger seqCounter = new AtomicInteger(0); - final ConcurrentLinkedQueue buffer = new ConcurrentLinkedQueue<>(); - final AtomicInteger bufferCount = new AtomicInteger(0); - final AtomicInteger estimatedBufferSize = new AtomicInteger(0); - final AtomicInteger chunkSeq = new AtomicInteger(0); - final Map seqToProcessorId = new ConcurrentHashMap<>(); - final Map attributes = new ConcurrentHashMap<>(); - - // Mutable envelope fields updated during exchange lifecycle - volatile ExecutionStatus status = ExecutionStatus.RUNNING; - volatile String traceId; - volatile String spanId; - volatile String originalExchangeId; - volatile String replayExchangeId; - - ExchangeState(String exchangeId, String routeId, String correlationId, - String engineLevel, long startNanos) { - this.exchangeId = exchangeId; - this.routeId = routeId; - this.correlationId = correlationId; - this.engineLevel = engineLevel; - this.startNanos = startNanos; - this.startTime = Instant.now(); - } -} -``` - -- [x] **Step 2: Write LoopTrackingState** - -```java -package com.cameleer.agent.collector; - -/** - * Lightweight loop state tracking — replaces SubExchangeTracker.LoopState. - * One entry per active loop nesting level in the Deque. - */ -class LoopTrackingState { - - final int loopSeq; - final String loopProcessorId; - final int stackDepthAtEntry; - int currentIndex = -1; - int iterationCount = 0; - - LoopTrackingState(int loopSeq, String loopProcessorId, int stackDepthAtEntry) { - this.loopSeq = loopSeq; - this.loopProcessorId = loopProcessorId; - this.stackDepthAtEntry = stackDepthAtEntry; - } -} -``` - -- [x] **Step 3: Write SubExchangeContext** - -```java -package com.cameleer.agent.collector; - -/** - * Tracks parent container info for split/multicast sub-exchanges. - * When a sub-exchange's processor stack is empty, parentContainerSeq - * is used as the parentSeq for root-level processors. - */ -class SubExchangeContext { - - final int parentContainerSeq; - final String parentContainerProcessorId; - final int iteration; - - SubExchangeContext(int parentContainerSeq, String parentContainerProcessorId, int iteration) { - this.parentContainerSeq = parentContainerSeq; - this.parentContainerProcessorId = parentContainerProcessorId; - this.iteration = iteration; - } -} -``` - -- [x] **Step 4: Compile to verify** - -Run: `mvn compile -pl cameleer-agent -f pom.xml` -Expected: BUILD SUCCESS - -- [x] **Step 5: Commit** - -```bash -git add cameleer-agent/src/main/java/com/cameleer/agent/collector/ExchangeState.java cameleer-agent/src/main/java/com/cameleer/agent/collector/LoopTrackingState.java cameleer-agent/src/main/java/com/cameleer/agent/collector/SubExchangeContext.java -git commit -m "feat: add lightweight state classes for flat execution tracking" -``` - ---- - -## Task 4: ChunkedExporter - -**Files:** -- Create: `cameleer-agent/src/main/java/com/cameleer/agent/export/ChunkedExporter.java` -- Create: `cameleer-agent/src/test/java/com/cameleer/agent/export/ChunkedExporterTest.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/export/Exporter.java` - -- [x] **Step 1: Add exportChunk to Exporter interface** - -In `cameleer-agent/src/main/java/com/cameleer/agent/export/Exporter.java`, add: - -```java -default void exportChunk(ExecutionChunk chunk) {} -``` - -Add import: `import com.cameleer.common.model.ExecutionChunk;` - -- [x] **Step 2: Write ChunkedExporter test** - -```java -package com.cameleer.agent.export; - -import com.cameleer.agent.connection.ServerConnection; -import com.cameleer.common.model.ExecutionChunk; -import com.cameleer.common.model.ExecutionStatus; -import com.cameleer.common.model.FlatProcessorRecord; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.time.Instant; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class ChunkedExporterTest { - - private ServerConnection mockConnection; - private ChunkedExporter exporter; - - @BeforeEach - void setUp() { - mockConnection = mock(ServerConnection.class); - exporter = new ChunkedExporter(mockConnection); - } - - @AfterEach - void tearDown() { - exporter.close(); - } - - @Test - void exportChunk_queuesAndFlushes() throws Exception { - CountDownLatch latch = new CountDownLatch(1); - when(mockConnection.sendData(eq("/api/v1/data/executions"), anyString())).thenAnswer(inv -> { - latch.countDown(); - return 202; - }); - - ExecutionChunk chunk = createTestChunk("ex-1", 0, false); - exporter.exportChunk(chunk); - - assertTrue(latch.await(5, TimeUnit.SECONDS), "Should flush within 5 seconds"); - verify(mockConnection).sendData(eq("/api/v1/data/executions"), anyString()); - } - - @Test - void exportChunk_batchesMultipleChunks() throws Exception { - AtomicInteger sendCount = new AtomicInteger(0); - CountDownLatch latch = new CountDownLatch(1); - when(mockConnection.sendData(eq("/api/v1/data/executions"), anyString())).thenAnswer(inv -> { - sendCount.incrementAndGet(); - latch.countDown(); - return 202; - }); - - // Queue 3 chunks — should be batched in one flush cycle - for (int i = 0; i < 3; i++) { - exporter.exportChunk(createTestChunk("ex-" + i, 0, true)); - } - - assertTrue(latch.await(5, TimeUnit.SECONDS)); - // All 3 should have been sent (possibly in 1 or more batches) - assertTrue(sendCount.get() >= 1); - } - - @Test - void exportChunk_dropsWhenQueueFull() { - // Fill queue beyond capacity - for (int i = 0; i < 1001; i++) { - exporter.exportChunk(createTestChunk("ex-" + i, 0, true)); - } - - // Should not throw — just drops silently - assertEquals(1000, exporter.getQueueSize()); - } - - @Test - void exportChunk_backpressureOn503() throws Exception { - AtomicInteger callCount = new AtomicInteger(0); - when(mockConnection.sendData(eq("/api/v1/data/executions"), anyString())).thenAnswer(inv -> { - callCount.incrementAndGet(); - return 503; - }); - - exporter.exportChunk(createTestChunk("ex-1", 0, true)); - - // Wait for first flush attempt - Thread.sleep(2000); - int firstCount = callCount.get(); - - // Add another chunk - exporter.exportChunk(createTestChunk("ex-2", 0, true)); - - // Should not immediately flush due to backoff - Thread.sleep(1000); - // May have retried at most once more - assertTrue(callCount.get() <= firstCount + 1); - } - - @Test - void exportChunk_jsonContainsExpectedFields() throws Exception { - AtomicReference capturedJson = new AtomicReference<>(); - CountDownLatch latch = new CountDownLatch(1); - when(mockConnection.sendData(eq("/api/v1/data/executions"), anyString())).thenAnswer(inv -> { - capturedJson.set(inv.getArgument(1)); - latch.countDown(); - return 202; - }); - - ExecutionChunk chunk = createTestChunk("ex-verify", 0, true); - chunk.setApplicationName("test-app"); - chunk.setAttributes(java.util.Map.of("orderId", "ORD-1")); - exporter.exportChunk(chunk); - - assertTrue(latch.await(5, TimeUnit.SECONDS)); - String json = capturedJson.get(); - assertTrue(json.contains("\"exchangeId\":\"ex-verify\"")); - assertTrue(json.contains("\"applicationName\":\"test-app\"")); - assertTrue(json.contains("\"final\":true")); - } - - private ExecutionChunk createTestChunk(String exchangeId, int chunkSeq, boolean isFinal) { - ExecutionChunk chunk = new ExecutionChunk(); - chunk.setExchangeId(exchangeId); - chunk.setApplicationName("test-app"); - chunk.setAgentId("test-agent"); - chunk.setRouteId("test-route"); - chunk.setCorrelationId(exchangeId); - chunk.setStatus(isFinal ? ExecutionStatus.COMPLETED : ExecutionStatus.RUNNING); - chunk.setStartTime(Instant.now()); - if (isFinal) { - chunk.setEndTime(Instant.now()); - chunk.setDurationMs(100L); - } - chunk.setEngineLevel("REGULAR"); - chunk.setChunkSeq(chunkSeq); - chunk.setFinal(isFinal); - chunk.setProcessors(List.of()); - return chunk; - } -} -``` - -- [x] **Step 3: Run test to verify it fails** - -Run: `mvn test -pl cameleer-agent -Dtest=ChunkedExporterTest -f pom.xml` -Expected: FAIL — `ChunkedExporter` class does not exist - -- [x] **Step 4: Write ChunkedExporter implementation** - -```java -package com.cameleer.agent.export; - -import com.cameleer.agent.connection.ServerConnection; -import com.cameleer.common.json.CameleerJson; -import com.cameleer.common.model.ExecutionChunk; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Buffers ExecutionChunks and flushes them to the server via HTTP. - * Replaces HttpExporter for the chunked transport protocol. - */ -public class ChunkedExporter implements Exporter { - - private static final Logger LOG = LoggerFactory.getLogger(ChunkedExporter.class); - private static final ObjectMapper MAPPER = CameleerJson.mapper(); - private static final int MAX_QUEUE_SIZE = 1000; - private static final int BATCH_SIZE = 50; - - private final ServerConnection serverConnection; - private final ConcurrentLinkedQueue chunkQueue = new ConcurrentLinkedQueue<>(); - private final ScheduledExecutorService scheduler; - private final AtomicLong pauseUntil = new AtomicLong(0); - private final AtomicLong droppedChunks = new AtomicLong(0); - private volatile long lastDropWarningMs = 0; - - public ChunkedExporter(ServerConnection serverConnection) { - this.serverConnection = serverConnection; - ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1); - executor.setThreadFactory(r -> { - Thread t = new Thread(r, "cameleer-chunk-exporter"); - t.setDaemon(true); - return t; - }); - this.scheduler = executor; - this.scheduler.scheduleAtFixedRate(this::flush, 1, 1, TimeUnit.SECONDS); - } - - @Override - public void exportChunk(ExecutionChunk chunk) { - if (chunkQueue.size() >= MAX_QUEUE_SIZE) { - long dropped = droppedChunks.incrementAndGet(); - long now = System.currentTimeMillis(); - if (now - lastDropWarningMs > 10_000) { - lastDropWarningMs = now; - LOG.warn("Chunk queue full ({} max), {} chunks dropped total", MAX_QUEUE_SIZE, dropped); - } - return; - } - chunkQueue.add(chunk); - } - - int getQueueSize() { - return chunkQueue.size(); - } - - private void flush() { - long now = System.currentTimeMillis(); - if (now < pauseUntil.get()) { - return; - } - flushChunks(); - } - - private void flushChunks() { - List batch = new ArrayList<>(BATCH_SIZE); - for (int i = 0; i < BATCH_SIZE; i++) { - ExecutionChunk chunk = chunkQueue.poll(); - if (chunk == null) break; - batch.add(chunk); - } - if (batch.isEmpty()) return; - - try { - String json; - if (batch.size() == 1) { - json = MAPPER.writeValueAsString(batch.get(0)); - } else { - json = MAPPER.writeValueAsString(batch); - } - int status = serverConnection.sendData("/api/v1/data/executions", json); - handleResponseStatus(status, batch.size()); - } catch (JsonProcessingException e) { - LOG.error("Failed to serialize {} chunks", batch.size(), e); - } catch (Exception e) { - LOG.warn("Failed to send {} chunks: {}", batch.size(), e.getMessage()); - } - } - - private void handleResponseStatus(int status, int count) { - if (status >= 200 && status < 300) { - LOG.debug("Exported {} chunks (HTTP {})", count, status); - } else if (status == 503) { - pauseUntil.set(System.currentTimeMillis() + 10_000); - LOG.warn("Server overloaded (503), pausing chunk export for 10 seconds"); - } else { - LOG.warn("Chunk export returned HTTP {} ({} chunks lost)", status, count); - } - } - - @Override - public void close() { - flush(); - scheduler.shutdown(); - try { - if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { - scheduler.shutdownNow(); - } - } catch (InterruptedException e) { - scheduler.shutdownNow(); - Thread.currentThread().interrupt(); - } - // Final flush of remaining items - flush(); - } -} -``` - -- [x] **Step 5: Run test to verify it passes** - -Run: `mvn test -pl cameleer-agent -Dtest=ChunkedExporterTest -f pom.xml` -Expected: PASS (5 tests) - -- [x] **Step 6: Commit** - -```bash -git add cameleer-agent/src/main/java/com/cameleer/agent/export/Exporter.java cameleer-agent/src/main/java/com/cameleer/agent/export/ChunkedExporter.java cameleer-agent/src/test/java/com/cameleer/agent/export/ChunkedExporterTest.java -git commit -m "feat: add ChunkedExporter for buffered chunk transport" -``` - ---- - -## Task 5: FlatExecutionCollector — Core Logic - -This is the largest task. The FlatExecutionCollector replaces the tree-building ExecutionCollector with flat record emission. It reuses the same Camel hooks (InterceptStrategy, EventNotifier) but produces FlatProcessorRecords instead of building a ProcessorExecution tree. - -**Files:** -- Create: `cameleer-agent/src/main/java/com/cameleer/agent/collector/FlatExecutionCollector.java` -- Create: `cameleer-agent/src/test/java/com/cameleer/agent/collector/FlatExecutionCollectorTest.java` -- Create: `cameleer-agent/src/test/java/com/cameleer/agent/test/TestChunkedExporter.java` - -This task is large and should be implemented incrementally. The collector has the same public interface as `ExecutionCollector` (same method signatures called by `CameleerInterceptStrategy` and `CameleerEventNotifier`), but internally uses `ExchangeState` instead of processor tree tracking. - -**Key differences from ExecutionCollector:** -- `processorStacks` holds `Deque` (seq numbers) not `Deque` -- No `activeSplits`/`activeMulticasts` maps with ProcessorExecution references — replaced by `SubExchangeContext` per sub-exchange -- No synthetic wrapper creation — iteration comes from exchange properties -- Loop tracking uses `LoopTrackingState` (seq-based) not `LoopState` (ProcessorExecution-based) -- `onProcessorComplete` appends a `FlatProcessorRecord` to buffer and checks flush threshold -- `onExchangeCompleted`/`onExchangeFailed` flushes final chunk with `final: true` - -Due to the size of this task, the full implementation code for `FlatExecutionCollector` should follow the same patterns as `ExecutionCollector.java` (829 lines) but adapted for flat records. The implementor should: - -1. Copy the method signatures from `ExecutionCollector` -2. Replace all tree-building logic with flat record emission -3. Replace `ProcessorExecution` stack with `Integer` seq stack -4. Replace `SubExchangeTracker` wrapper creation with `SubExchangeContext` lookup -5. Keep the loop depth guard algorithm (same as `SubExchangeTracker.onLoopStart()`) -6. Keep payload capture, tap evaluation, error classification, OTel bridge — just write results to `FlatProcessorRecord` instead of `ProcessorExecution` - -- [x] **Step 1: Write TestChunkedExporter (test utility)** - -```java -package com.cameleer.agent.test; - -import com.cameleer.common.model.ExecutionChunk; -import com.cameleer.agent.export.Exporter; - -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; - -/** - * In-memory Exporter that captures ExecutionChunks for testing. - * Analogous to TestExporter but for the chunked transport protocol. - */ -public class TestChunkedExporter implements Exporter { - - private final List chunks = new CopyOnWriteArrayList<>(); - private volatile CountDownLatch latch = new CountDownLatch(1); - - @Override - public void exportChunk(ExecutionChunk chunk) { - chunks.add(chunk); - if (chunk.isFinal()) { - latch.countDown(); - } - } - - public boolean awaitFinalChunk(long timeout, TimeUnit unit) throws InterruptedException { - return latch.await(timeout, unit); - } - - public List getChunks() { - return chunks; - } - - public List getChunksForExchange(String exchangeId) { - return chunks.stream() - .filter(c -> exchangeId.equals(c.getExchangeId())) - .toList(); - } - - public void reset() { - chunks.clear(); - latch = new CountDownLatch(1); - } - - public void reset(int expectedFinals) { - chunks.clear(); - latch = new CountDownLatch(expectedFinals); - } -} -``` - -- [x] **Step 2: Write FlatExecutionCollectorTest — basic lifecycle tests** - -Create test class with these initial tests (follow the same Mockito pattern as `ExecutionCollectorTest.java`): - -```java -package com.cameleer.agent.collector; - -import com.cameleer.agent.config.CameleerAgentConfig; -import com.cameleer.agent.test.TestChunkedExporter; -import com.cameleer.common.model.ExecutionChunk; -import com.cameleer.common.model.ExecutionStatus; -import com.cameleer.common.model.FlatProcessorRecord; -import org.apache.camel.Exchange; -import org.apache.camel.Message; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class FlatExecutionCollectorTest { - - @Mock private CameleerAgentConfig config; - @Mock private Exchange exchange; - @Mock private Message message; - - private TestChunkedExporter exporter; - private FlatExecutionCollector collector; - - @BeforeEach - void setUp() { - lenient().when(config.getEngineLevel()).thenReturn(com.cameleer.common.model.EngineLevel.COMPLETE); - lenient().when(config.isRouteRecordingEnabled(anyString())).thenReturn(true); - lenient().when(config.isCompressSuccess()).thenReturn(false); - lenient().when(config.getApplicationName()).thenReturn("test-app"); - lenient().when(config.getAgentName()).thenReturn("test-agent"); - lenient().when(exchange.getMessage()).thenReturn(message); - lenient().when(exchange.getProperty(anyString(), any(Class.class))).thenReturn(null); - lenient().when(message.getHeaders()).thenReturn(new HashMap<>()); - - exporter = new TestChunkedExporter(); - collector = new FlatExecutionCollector(config, exporter); - } - - @Test - void simpleExchange_producesSeqParentSeqRecords() { - setupExchangeProperties(exchange); - when(exchange.getExchangeId()).thenReturn("ex-1"); - when(exchange.getFromRouteId()).thenReturn("test-route"); - - collector.onExchangeCreated(exchange); - collector.onProcessorStart(exchange, "log1", "log", System.nanoTime()); - collector.onProcessorComplete(exchange, "log1", 1_000_000L); - collector.onProcessorStart(exchange, "to1", "to", System.nanoTime()); - collector.onProcessorComplete(exchange, "to1", 2_000_000L); - collector.onExchangeCompleted(exchange); - - List chunks = exporter.getChunks(); - assertFalse(chunks.isEmpty(), "Should have at least one chunk"); - - ExecutionChunk finalChunk = chunks.stream() - .filter(ExecutionChunk::isFinal) - .findFirst() - .orElseThrow(() -> new AssertionError("No final chunk")); - - assertEquals(ExecutionStatus.COMPLETED, finalChunk.getStatus()); - assertEquals("test-route", finalChunk.getRouteId()); - assertEquals("test-app", finalChunk.getApplicationName()); - - // Collect all processor records across all chunks - List allRecords = chunks.stream() - .flatMap(c -> c.getProcessors().stream()) - .toList(); - - assertEquals(2, allRecords.size()); - - FlatProcessorRecord log1 = allRecords.stream() - .filter(r -> "log1".equals(r.getProcessorId())) - .findFirst().orElseThrow(); - assertEquals(1, log1.getSeq()); - assertNull(log1.getParentSeq(), "Root-level processor should have null parentSeq"); - assertNull(log1.getParentProcessorId()); - assertNull(log1.getIteration()); - - FlatProcessorRecord to1 = allRecords.stream() - .filter(r -> "to1".equals(r.getProcessorId())) - .findFirst().orElseThrow(); - assertEquals(2, to1.getSeq()); - assertNull(to1.getParentSeq(), "Sequential sibling should have null parentSeq"); - } - - @Test - void splitExchange_producesIterationOnChildRecords() { - setupExchangeProperties(exchange); - when(exchange.getExchangeId()).thenReturn("parent-ex"); - when(exchange.getFromRouteId()).thenReturn("split-route"); - - collector.onExchangeCreated(exchange); - collector.onProcessorStart(exchange, "split1", "split", System.nanoTime()); - - // Simulate 3 split iterations - for (int i = 0; i < 3; i++) { - Exchange subExchange = mock(Exchange.class); - Message subMessage = mock(Message.class); - Map subProps = new HashMap<>(); - subProps.put(Exchange.SPLIT_INDEX, i); - subProps.put(Exchange.SPLIT_SIZE, 3); - subProps.put(Exchange.CORRELATION_ID, "parent-ex"); - - lenient().when(subExchange.getMessage()).thenReturn(subMessage); - lenient().when(subExchange.getExchangeId()).thenReturn("sub-ex-" + i); - lenient().when(subExchange.getFromRouteId()).thenReturn("split-route"); - lenient().when(subExchange.getProperty(anyString())).thenAnswer(inv -> subProps.get(inv.getArgument(0))); - lenient().when(subExchange.getProperty(anyString(), any(Class.class))).thenAnswer(inv -> { - Object val = subProps.get(inv.getArgument(0)); - return inv.getArgument(1, Class.class).isInstance(val) ? val : null; - }); - lenient().when(subMessage.getHeaders()).thenReturn(new HashMap<>()); - - collector.onExchangeCreated(subExchange); - collector.onProcessorStart(subExchange, "log-in-split", "log", System.nanoTime()); - collector.onProcessorComplete(subExchange, "log-in-split", 500_000L); - } - - collector.onProcessorComplete(exchange, "split1", 5_000_000L); - collector.onExchangeCompleted(exchange); - - List allRecords = exporter.getChunks().stream() - .flatMap(c -> c.getProcessors().stream()) - .toList(); - - // split1 + 3 log-in-split = 4 records - assertEquals(4, allRecords.size()); - - // split1 should have iterationSize = 3 - FlatProcessorRecord splitRecord = allRecords.stream() - .filter(r -> "split1".equals(r.getProcessorId())) - .findFirst().orElseThrow(); - assertEquals(3, splitRecord.getIterationSize()); - assertNull(splitRecord.getIteration(), "Container itself has no iteration"); - - // Each log-in-split should have parentSeq = split1.seq, iteration = 0/1/2 - List logRecords = allRecords.stream() - .filter(r -> "log-in-split".equals(r.getProcessorId())) - .toList(); - assertEquals(3, logRecords.size()); - - for (FlatProcessorRecord logRecord : logRecords) { - assertEquals(splitRecord.getSeq(), logRecord.getParentSeq(), - "Child should reference split processor's seq"); - assertEquals("split1", logRecord.getParentProcessorId()); - assertNotNull(logRecord.getIteration(), "Split child must have iteration"); - } - - // Verify all 3 iterations are present - List iterations = logRecords.stream() - .map(FlatProcessorRecord::getIteration) - .sorted() - .toList(); - assertEquals(List.of(0, 1, 2), iterations); - } - - @Test - void failedExchange_producesFailedFinalChunk() { - setupExchangeProperties(exchange); - when(exchange.getExchangeId()).thenReturn("ex-fail"); - when(exchange.getFromRouteId()).thenReturn("fail-route"); - - collector.onExchangeCreated(exchange); - collector.onProcessorStart(exchange, "log1", "log", System.nanoTime()); - collector.onProcessorFailed(exchange, "log1", new RuntimeException("test failure")); - collector.onExchangeFailed(exchange); - - ExecutionChunk finalChunk = exporter.getChunks().stream() - .filter(ExecutionChunk::isFinal) - .findFirst() - .orElseThrow(); - assertEquals(ExecutionStatus.FAILED, finalChunk.getStatus()); - assertNotNull(finalChunk.getErrorMessage()); - assertTrue(finalChunk.isFinal()); - } - - private void setupExchangeProperties(Exchange ex) { - Map properties = new HashMap<>(); - lenient().when(ex.getProperty(anyString())).thenAnswer(inv -> properties.get(inv.getArgument(0))); - lenient().when(ex.getProperty(anyString(), any(Class.class))).thenAnswer(inv -> { - Object val = properties.get(inv.getArgument(0)); - return inv.getArgument(1, Class.class).isInstance(val) ? val : null; - }); - lenient().doAnswer(inv -> { - properties.put(inv.getArgument(0), inv.getArgument(1)); - return null; - }).when(ex).setProperty(anyString(), any()); - } -} -``` - -- [x] **Step 3: Run test to verify it fails** - -Run: `mvn test -pl cameleer-agent -Dtest=FlatExecutionCollectorTest -f pom.xml` -Expected: FAIL — `FlatExecutionCollector` class does not exist - -- [x] **Step 4: Implement FlatExecutionCollector** - -Create `FlatExecutionCollector.java` following the patterns of `ExecutionCollector.java` but adapted for flat record emission. The implementor should study `ExecutionCollector.java` (829 lines, path: `cameleer-agent/src/main/java/com/cameleer/agent/collector/ExecutionCollector.java`) and translate its logic: - -Key implementation notes: -- Same constructor signature: `FlatExecutionCollector(CameleerAgentConfig config, Exporter exporter)` -- Same public methods called by `CameleerInterceptStrategy` and `CameleerEventNotifier` -- `activeExecutions` becomes `Map` (not `Map`) -- `processorStacks` becomes `Map>` (seq integers, not ProcessorExecution objects) -- `activeSplits`/`activeMulticasts` replaced by `Map subExchangeContexts` -- `activeLoops` becomes `Map>` -- `onProcessorStart`: assign seq, determine parentSeq/iteration, push seq to stack, create FlatProcessorRecord stub (just seq, processorId, processorType, startNanos — don't add to buffer yet) -- `onProcessorComplete`: pop seq from stack, complete the FlatProcessorRecord (timing, payloads, errors), add to buffer, check flush threshold -- Active (in-flight) processor records stored in `Map> activeRecords` (exchangeId → seq → record) — needed to write completion data onto the record started in onProcessorStart -- Flush method: drain buffer into ExecutionChunk, call `exporter.exportChunk()` -- `onExchangeCompleted`/`onExchangeFailed`: set final status on envelope, flush remaining buffer with `final: true` -- Reuse existing `PayloadCapture`, `ErrorInfo`, `TapEvaluator`, `OtelBridge` — these write to record fields, not tree structure - -- [x] **Step 5: Run test to verify it passes** - -Run: `mvn test -pl cameleer-agent -Dtest=FlatExecutionCollectorTest -f pom.xml` -Expected: PASS (3 tests) - -- [x] **Step 6: Commit** - -```bash -git add cameleer-agent/src/main/java/com/cameleer/agent/collector/FlatExecutionCollector.java cameleer-agent/src/test/java/com/cameleer/agent/collector/FlatExecutionCollectorTest.java cameleer-agent/src/test/java/com/cameleer/agent/test/TestChunkedExporter.java -git commit -m "feat: add FlatExecutionCollector with flat record emission" -``` - ---- - -## Task 6: FlatExecutionCollector — Loop, Multicast, and Nested Scenarios - -**Files:** -- Modify: `cameleer-agent/src/test/java/com/cameleer/agent/collector/FlatExecutionCollectorTest.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/collector/FlatExecutionCollector.java` (if needed) - -Add tests for loop iteration tracking, multicast branches, and nested split-inside-loop scenarios. - -- [x] **Step 1: Add loop iteration test** - -Add to `FlatExecutionCollectorTest`: - -```java -@Test -void loopExchange_producesIterationOnChildRecords() { - setupExchangeProperties(exchange); - when(exchange.getExchangeId()).thenReturn("ex-loop"); - when(exchange.getFromRouteId()).thenReturn("loop-route"); - - Map props = new HashMap<>(); - lenient().when(exchange.getProperty(anyString())).thenAnswer(inv -> props.get(inv.getArgument(0))); - lenient().when(exchange.getProperty(anyString(), any(Class.class))).thenAnswer(inv -> { - Object val = props.get(inv.getArgument(0)); - return inv.getArgument(1, Class.class).isInstance(val) ? val : null; - }); - lenient().doAnswer(inv -> { - props.put(inv.getArgument(0), inv.getArgument(1)); - return null; - }).when(exchange).setProperty(anyString(), any()); - - collector.onExchangeCreated(exchange); - collector.onProcessorStart(exchange, "loop1", "loop", System.nanoTime()); - - // 3 loop iterations - for (int i = 0; i < 3; i++) { - props.put("CamelLoopIndex", i); - collector.onProcessorStart(exchange, "log-in-loop", "log", System.nanoTime()); - collector.onProcessorComplete(exchange, "log-in-loop", 500_000L); - } - - collector.onProcessorComplete(exchange, "loop1", 5_000_000L); - collector.onExchangeCompleted(exchange); - - List allRecords = exporter.getChunks().stream() - .flatMap(c -> c.getProcessors().stream()) - .toList(); - - // loop1 should have iterationSize = 3 - FlatProcessorRecord loopRecord = allRecords.stream() - .filter(r -> "loop1".equals(r.getProcessorId())) - .findFirst().orElseThrow(); - assertEquals(3, loopRecord.getIterationSize()); - - // Each log-in-loop should have parentSeq = loop1.seq, iteration = 0/1/2 - List logRecords = allRecords.stream() - .filter(r -> "log-in-loop".equals(r.getProcessorId())) - .toList(); - assertEquals(3, logRecords.size()); - - for (FlatProcessorRecord logRecord : logRecords) { - assertEquals(loopRecord.getSeq(), logRecord.getParentSeq()); - assertEquals("loop1", logRecord.getParentProcessorId()); - } - - List iterations = logRecords.stream() - .map(FlatProcessorRecord::getIteration) - .sorted() - .toList(); - assertEquals(List.of(0, 1, 2), iterations); -} -``` - -- [x] **Step 2: Add multicast test** - -```java -@Test -void multicastExchange_producesIterationOnBranchRecords() { - setupExchangeProperties(exchange); - when(exchange.getExchangeId()).thenReturn("parent-mc"); - when(exchange.getFromRouteId()).thenReturn("mc-route"); - - collector.onExchangeCreated(exchange); - collector.onProcessorStart(exchange, "multicast1", "multicast", System.nanoTime()); - - for (int i = 0; i < 2; i++) { - Exchange subExchange = mock(Exchange.class); - Message subMessage = mock(Message.class); - Map subProps = new HashMap<>(); - subProps.put("CamelMulticastIndex", i); - subProps.put(Exchange.CORRELATION_ID, "parent-mc"); - - lenient().when(subExchange.getMessage()).thenReturn(subMessage); - lenient().when(subExchange.getExchangeId()).thenReturn("mc-sub-" + i); - lenient().when(subExchange.getFromRouteId()).thenReturn("mc-route"); - lenient().when(subExchange.getProperty(anyString())).thenAnswer(inv -> subProps.get(inv.getArgument(0))); - lenient().when(subExchange.getProperty(anyString(), any(Class.class))).thenAnswer(inv -> { - Object val = subProps.get(inv.getArgument(0)); - return inv.getArgument(1, Class.class).isInstance(val) ? val : null; - }); - lenient().when(subMessage.getHeaders()).thenReturn(new HashMap<>()); - - collector.onExchangeCreated(subExchange); - collector.onProcessorStart(subExchange, "log-in-mc", "log", System.nanoTime()); - collector.onProcessorComplete(subExchange, "log-in-mc", 500_000L); - } - - collector.onProcessorComplete(exchange, "multicast1", 3_000_000L); - collector.onExchangeCompleted(exchange); - - List allRecords = exporter.getChunks().stream() - .flatMap(c -> c.getProcessors().stream()) - .toList(); - - FlatProcessorRecord mcRecord = allRecords.stream() - .filter(r -> "multicast1".equals(r.getProcessorId())) - .findFirst().orElseThrow(); - assertEquals(2, mcRecord.getIterationSize()); - - List logRecords = allRecords.stream() - .filter(r -> "log-in-mc".equals(r.getProcessorId())) - .toList(); - assertEquals(2, logRecords.size()); - - List iterations = logRecords.stream() - .map(FlatProcessorRecord::getIteration) - .sorted() - .toList(); - assertEquals(List.of(0, 1), iterations); -} -``` - -- [x] **Step 3: Run tests** - -Run: `mvn test -pl cameleer-agent -Dtest=FlatExecutionCollectorTest -f pom.xml` -Expected: PASS (5 tests total) - -- [x] **Step 4: Commit** - -```bash -git add cameleer-agent/src/test/java/com/cameleer/agent/collector/FlatExecutionCollectorTest.java -git commit -m "test: add loop and multicast tests for FlatExecutionCollector" -``` - ---- - -## Task 7: Integration Tests with Real Camel Context - -**Files:** -- Create: `cameleer-agent/src/test/java/com/cameleer/agent/collector/FlatExecutionTreeIntegrationTest.java` - -These tests wire a real Camel context with the FlatExecutionCollector + TestChunkedExporter, following the same two-phase wiring pattern as `ExecutionTreeIntegrationTest.java`. - -- [x] **Step 1: Write integration test class** - -```java -package com.cameleer.agent.collector; - -import com.cameleer.agent.config.CameleerAgentConfig; -import com.cameleer.agent.notifier.CameleerEventNotifier; -import com.cameleer.agent.notifier.CameleerInterceptStrategy; -import com.cameleer.agent.test.TestChunkedExporter; -import com.cameleer.common.model.ExecutionChunk; -import com.cameleer.common.model.ExecutionStatus; -import com.cameleer.common.model.FlatProcessorRecord; -import org.apache.camel.CamelContext; -import org.apache.camel.ProducerTemplate; -import org.apache.camel.builder.RouteBuilder; -import org.apache.camel.impl.DefaultCamelContext; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; - -import static org.junit.jupiter.api.Assertions.*; - -class FlatExecutionTreeIntegrationTest { - - private CamelContext camelContext; - private ProducerTemplate producerTemplate; - private TestChunkedExporter testExporter; - private FlatExecutionCollector collector; - private CameleerAgentConfig config; - - @BeforeEach - void setUp() { - System.setProperty("cameleer.enabled", "true"); - System.setProperty("cameleer.engine.level", "REGULAR"); - System.setProperty("cameleer.metrics.enabled", "false"); - System.setProperty("cameleer.diagrams.enabled", "false"); - System.setProperty("cameleer.taps.enabled", "false"); - System.setProperty("cameleer.application.name", "test-app"); - System.setProperty("cameleer.agent.name", "test-agent"); - - config = CameleerAgentConfig.getInstance(); - config.reload(); - - testExporter = new TestChunkedExporter(); - collector = new FlatExecutionCollector(config, testExporter); - - camelContext = new DefaultCamelContext(); - CameleerInterceptStrategy interceptStrategy = new CameleerInterceptStrategy(collector, config); - camelContext.getCamelContextExtension().addInterceptStrategy(interceptStrategy); - } - - @AfterEach - void tearDown() throws Exception { - if (producerTemplate != null) producerTemplate.close(); - if (camelContext != null) camelContext.close(); - } - - private void startContext() throws Exception { - camelContext.start(); - CameleerEventNotifier notifier = new CameleerEventNotifier(collector); - camelContext.getManagementStrategy().addEventNotifier(notifier); - notifier.start(); - producerTemplate = camelContext.createProducerTemplate(); - } - - @Test - void splitWithAggregation_correctFlatRecords() throws Exception { - camelContext.addRoutes(new RouteBuilder() { - @Override - public void configure() { - from("direct:split-test").routeId("split-test") - .split(body()) - .log("item: ${body}") - .end() - .log("after split"); - } - }); - startContext(); - - testExporter.reset(1); - producerTemplate.sendBody("direct:split-test", List.of("a", "b", "c")); - assertTrue(testExporter.awaitFinalChunk(5, TimeUnit.SECONDS)); - - List allRecords = testExporter.getChunks().stream() - .flatMap(c -> c.getProcessors().stream()) - .toList(); - - // Find split processor - FlatProcessorRecord splitProc = allRecords.stream() - .filter(r -> "split".equals(r.getProcessorType())) - .findFirst().orElseThrow(); - assertEquals(3, splitProc.getIterationSize()); - - // Find log processors inside split - List splitLogs = allRecords.stream() - .filter(r -> "log".equals(r.getProcessorType()) - && splitProc.getSeq() == (r.getParentSeq() != null ? r.getParentSeq() : -1)) - .toList(); - assertEquals(3, splitLogs.size()); - - // Verify iterations 0, 1, 2 - List iterations = splitLogs.stream() - .map(FlatProcessorRecord::getIteration) - .sorted() - .toList(); - assertEquals(List.of(0, 1, 2), iterations); - - // Find post-split log (should be root-level, not child of split) - List rootLogs = allRecords.stream() - .filter(r -> "log".equals(r.getProcessorType()) && r.getParentSeq() == null) - .toList(); - assertFalse(rootLogs.isEmpty(), "Post-split log should be root-level"); - - // Final chunk should be COMPLETED - ExecutionChunk finalChunk = testExporter.getChunks().stream() - .filter(ExecutionChunk::isFinal) - .findFirst().orElseThrow(); - assertEquals(ExecutionStatus.COMPLETED, finalChunk.getStatus()); - } - - @Test - void loopExecution_correctFlatRecords() throws Exception { - camelContext.addRoutes(new RouteBuilder() { - @Override - public void configure() { - from("direct:loop-test").routeId("loop-test") - .loop(3) - .log("iter ${header.CamelLoopIndex}") - .end(); - } - }); - startContext(); - - testExporter.reset(1); - producerTemplate.sendBody("direct:loop-test", "test"); - assertTrue(testExporter.awaitFinalChunk(5, TimeUnit.SECONDS)); - - List allRecords = testExporter.getChunks().stream() - .flatMap(c -> c.getProcessors().stream()) - .toList(); - - FlatProcessorRecord loopProc = allRecords.stream() - .filter(r -> "loop".equals(r.getProcessorType())) - .findFirst().orElseThrow(); - assertEquals(3, loopProc.getIterationSize()); - - List loopLogs = allRecords.stream() - .filter(r -> "log".equals(r.getProcessorType()) && loopProc.getSeq() == (r.getParentSeq() != null ? r.getParentSeq() : -1)) - .toList(); - assertEquals(3, loopLogs.size()); - - List iterations = loopLogs.stream() - .map(FlatProcessorRecord::getIteration) - .sorted() - .toList(); - assertEquals(List.of(0, 1, 2), iterations); - } -} -``` - -- [x] **Step 2: Run integration tests** - -Run: `mvn test -pl cameleer-agent -Dtest=FlatExecutionTreeIntegrationTest -f pom.xml` -Expected: PASS - -Note: These tests require `FlatExecutionCollector` to accept the same `CameleerInterceptStrategy` and `CameleerEventNotifier` hooks. The `CameleerInterceptStrategy` constructor takes an `ExecutionCollector`. For `FlatExecutionCollector` to work with it, either: -- Extract an interface that both collectors implement, OR -- `FlatExecutionCollector` extends `ExecutionCollector` and overrides methods, OR -- `CameleerInterceptStrategy` accepts a functional interface - -The simplest approach: extract a `ProcessorTracker` interface with the methods `CameleerInterceptStrategy` calls (`onProcessorStart`, `onProcessorComplete`, `onProcessorFailed`), and have both `ExecutionCollector` and `FlatExecutionCollector` implement it. Similarly for `CameleerEventNotifier` calls (`onExchangeCreated`, `onExchangeCompleted`, `onExchangeFailed`). - -- [x] **Step 3: Commit** - -```bash -git add cameleer-agent/src/test/java/com/cameleer/agent/collector/FlatExecutionTreeIntegrationTest.java -git commit -m "test: add integration tests for flat execution tracking with real Camel context" -``` - ---- - -## Task 8: Deprecations and Wire-Up - -**Files:** -- Modify: `cameleer-common/src/main/java/com/cameleer/common/model/ProcessorExecution.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/export/HttpExporter.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/collector/ExecutionCollector.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/collector/SubExchangeTracker.java` - -- [x] **Step 1: Deprecate ProcessorExecution tree fields** - -In `ProcessorExecution.java`, add `@Deprecated` annotations: - -```java -@Deprecated(since = "2.0", forRemoval = true) -public List getChildren() { return children; } - -@Deprecated(since = "2.0", forRemoval = true) -public void addChild(ProcessorExecution child) { ... } - -@Deprecated(since = "2.0", forRemoval = true) -public Integer getSplitIndex() { return splitIndex; } - -@Deprecated(since = "2.0", forRemoval = true) -public Integer getSplitSize() { return splitSize; } - -@Deprecated(since = "2.0", forRemoval = true) -public Integer getLoopIndex() { return loopIndex; } - -@Deprecated(since = "2.0", forRemoval = true) -public Integer getLoopSize() { return loopSize; } - -@Deprecated(since = "2.0", forRemoval = true) -public Integer getMulticastIndex() { return multicastIndex; } -``` - -- [x] **Step 2: Deprecate old classes** - -Add class-level deprecation: - -```java -// HttpExporter.java -@Deprecated(since = "2.0", forRemoval = true) -public class HttpExporter implements Exporter { ... } - -// ExecutionCollector.java -@Deprecated(since = "2.0", forRemoval = true) -public class ExecutionCollector { ... } - -// SubExchangeTracker.java -@Deprecated(since = "2.0", forRemoval = true) -class SubExchangeTracker { ... } -``` - -Deprecate `exportExecution` on Exporter interface: - -```java -@Deprecated(since = "2.0", forRemoval = true) -void exportExecution(RouteExecution routeExecution); -``` - -- [x] **Step 3: Run all existing tests to verify no breakage** - -Run: `mvn test -f pom.xml` -Expected: All existing tests still pass (deprecations don't break functionality) - -- [x] **Step 4: Commit** - -```bash -git add cameleer-common/src/main/java/com/cameleer/common/model/ProcessorExecution.java cameleer-agent/src/main/java/com/cameleer/agent/export/HttpExporter.java cameleer-agent/src/main/java/com/cameleer/agent/export/Exporter.java cameleer-agent/src/main/java/com/cameleer/agent/collector/ExecutionCollector.java cameleer-agent/src/main/java/com/cameleer/agent/collector/SubExchangeTracker.java -git commit -m "refactor: deprecate tree-based execution tracking classes for removal" -``` - ---- - -## Verification - -After all tasks are complete: - -1. **All existing tests pass**: `mvn test -f pom.xml` — existing v1 tests are unaffected -2. **New model tests pass**: `FlatProcessorRecordSerializationTest`, `ExecutionChunkSerializationTest` — JSON round-trip correctness -3. **New exporter tests pass**: `ChunkedExporterTest` — buffering, flushing, back-pressure -4. **New collector unit tests pass**: `FlatExecutionCollectorTest` — flat record emission for simple, split, loop, multicast -5. **New integration tests pass**: `FlatExecutionTreeIntegrationTest` — real Camel context producing correct flat records -6. **No SonarQube regressions**: Check via SonarQube MCP tools after final commit diff --git a/docs/superpowers/plans/2026-04-01-cameleer-extension-graalvm.md b/docs/superpowers/plans/2026-04-01-cameleer-extension-graalvm.md deleted file mode 100644 index 2ee8e7e..0000000 --- a/docs/superpowers/plans/2026-04-01-cameleer-extension-graalvm.md +++ /dev/null @@ -1,970 +0,0 @@ -# Cameleer Extension for GraalVM Native Support — Implementation Plan - -> **Status: COMPLETED** — Verified 2026-04-09. All modules, source code, and CI workflows implemented. - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. - -**Goal:** Extract shared observability code into `cameleer-core`, build a Quarkus extension (`cameleer-extension`) that provides the same agent hooks via CDI, and create a native-capable example app. - -**Architecture:** The agent's observability logic (collector, notifier, exporter, metrics, diagrams) moves to `cameleer-core`. The agent becomes a thin ByteBuddy wrapper. A new Quarkus extension uses CDI lifecycle hooks to register the same InterceptStrategy + EventNotifier from core, enabling GraalVM native image support. All existing pipelines, Dockerfiles, and apps are updated for the new module structure. - -**Tech Stack:** Java 17, Apache Camel 4.10, Quarkus 3.18, Camel Quarkus 3.18, GraalVM/Mandrel, CDI (ArC), Maven multi-module - -**Spec:** `docs/superpowers/specs/2026-04-01-cameleer-extension-graalvm-design.md` - ---- - -## Phase 1: Extract `cameleer-core` - -### Task 1: Create `cameleer-core` module skeleton - -**Files:** -- Create: `cameleer-core/pom.xml` -- Modify: `pom.xml` (root — add module) - -- [x] **Step 1: Add module to root POM** - -In `pom.xml`, add `cameleer-core` after `cameleer-common`: -```xml - - cameleer-common - cameleer-core - cameleer-agent - ... - -``` - -- [x] **Step 2: Create core POM** - -Create `cameleer-core/pom.xml`. Must mirror the agent's non-ByteBuddy dependencies. Parent is `cameleer-parent`. Dependencies: -- `cameleer-common` -- `jackson-databind` -- `slf4j-api`, `slf4j-simple` -- `opentelemetry-api`, `opentelemetry-sdk`, `opentelemetry-exporter-otlp` -- Apache Camel: `camel-api`, `camel-support`, `camel-core-model`, `camel-core-engine`, `camel-management-api`, `camel-management` (all `provided`) -- Logging: `logback-classic`, `log4j-core` (both `provided`) -- Test: `junit-jupiter`, `mockito-core` (5.14.2), `mockito-junit-jupiter` (5.14.2), `camel-core` (test), `opentelemetry-sdk-testing` (test) - -No shade plugin. No ByteBuddy. - -- [x] **Step 3: Create directory structure** - -```bash -mkdir -p cameleer-core/src/main/java/com/cameleer/core/{collector,notifier,export,connection,command,config,diagram,metrics,tap,otel,health,logging} -mkdir -p cameleer-core/src/test/java/com/cameleer/core/{collector,connection,diagram,export,health,logging,test} -``` - -- [x] **Step 4: Verify skeleton compiles** - -```bash -mvn clean compile -pl cameleer-common,cameleer-core --batch-mode -``` -Expected: BUILD SUCCESS (empty module compiles) - -- [x] **Step 5: Commit** - -```bash -git add cameleer-core/ pom.xml -git commit -m "feat: create cameleer-core module skeleton" -``` - ---- - -### Task 2: Move source files from agent to core - -**Files:** -- Move: 35 Java source files from `cameleer-agent/src/main/java/com/cameleer/agent/` to `cameleer-core/src/main/java/com/cameleer/core/` -- Keep in agent: 11 files (ByteBuddy-dependent) - -This is the bulk mechanical refactoring. Move each package in order. - -- [x] **Step 1: Move files** - -Move these files, changing package from `com.cameleer.agent.*` to `com.cameleer.core.*`: - -**Root:** -- `CameleerAgentConfig.java` → `com.cameleer.core.CameleerAgentConfig` - -**collector/ (6 files):** -- `ExecutionCollector.java`, `FlatExecutionCollector.java`, `ExchangeState.java`, `LoopTrackingState.java`, `PayloadCapture.java`, `SubExchangeContext.java` - -**notifier/ (2 files):** -- `CameleerInterceptStrategy.java`, `CameleerEventNotifier.java` - -**export/ (4 files):** -- `Exporter.java`, `ExporterFactory.java`, `LogExporter.java`, `ChunkedExporter.java` - -**connection/ (5 files):** -- `ServerConnection.java`, `SseClient.java`, `HeartbeatManager.java`, `ConfigVerifier.java`, `TokenRefreshFailedException.java` - -**command/ (3 files):** -- `CommandHandler.java`, `CommandResult.java`, `DefaultCommandHandler.java` - -**config/ (1 file):** -- `ConfigCacheManager.java` - -**diagram/ (3 files):** -- `RouteModelExtractor.java`, `DebugSvgRenderer.java`, `RouteGraphVersionManager.java` - -**metrics/ (3 files):** -- `CamelMetricsBridge.java`, `PrometheusEndpoint.java`, `PrometheusFormatter.java` - -**tap/ (1 file):** -- `TapEvaluator.java` - -**otel/ (1 file):** -- `OtelBridge.java` - -**health/ (2 files):** -- `HealthEndpoint.java`, `StartupReport.java` - -**logging/ (5 files):** -- `LogForwarder.java`, `LogEventBridge.java`, `LogEventConverter.java`, `LogLevelSetter.java`, `CameleerJulHandler.java` - -**instrumentation/ (1 file — NOT ByteBuddy-dependent):** -- `ServerSetup.java` → moves to `com.cameleer.core.connection.ServerSetup` (it orchestrates server connection, zero ByteBuddy deps) - -For each file: copy to core directory, update `package` declaration from `com.cameleer.agent.X` to `com.cameleer.core.X`, update all internal imports between moved classes. Delete originals from agent. - -- [x] **Step 2: Update all cross-references within moved files** - -Every moved file that imports another moved file needs its import updated. Example: -```java -// Old: -import com.cameleer.agent.collector.ExecutionCollector; -// New: -import com.cameleer.core.collector.ExecutionCollector; -``` - -Use find-and-replace across all files in `cameleer-core/src/main/java/`: -- `com.cameleer.agent.collector` → `com.cameleer.core.collector` -- `com.cameleer.agent.notifier` → `com.cameleer.core.notifier` -- `com.cameleer.agent.export` → `com.cameleer.core.export` -- `com.cameleer.agent.connection` → `com.cameleer.core.connection` -- `com.cameleer.agent.command` → `com.cameleer.core.command` -- `com.cameleer.agent.config` → `com.cameleer.core.config` -- `com.cameleer.agent.diagram` → `com.cameleer.core.diagram` -- `com.cameleer.agent.metrics` → `com.cameleer.core.metrics` -- `com.cameleer.agent.tap` → `com.cameleer.core.tap` -- `com.cameleer.agent.otel` → `com.cameleer.core.otel` -- `com.cameleer.agent.health` → `com.cameleer.core.health` -- `com.cameleer.agent.logging` → `com.cameleer.core.logging` -- `com.cameleer.agent.CameleerAgentConfig` → `com.cameleer.core.CameleerAgentConfig` -- `com.cameleer.agent.instrumentation.ServerSetup` → `com.cameleer.core.connection.ServerSetup` - -- [x] **Step 3: Verify core compiles** - -```bash -mvn clean compile -pl cameleer-common,cameleer-core --batch-mode -``` -Expected: BUILD SUCCESS - -- [x] **Step 4: Commit** - -```bash -git add -A -git commit -m "refactor: move observability classes from agent to cameleer-core" -``` - ---- - -### Task 3: Update agent to depend on core - -**Files:** -- Modify: `cameleer-agent/pom.xml` (add core dep, update shade config) -- Modify: All remaining agent source files (update imports) -- Modify: All agent test files (update imports) - -- [x] **Step 1: Add core dependency to agent POM** - -In `cameleer-agent/pom.xml`, add before the ByteBuddy dependency: -```xml - - com.cameleer - cameleer-core - ${project.version} - -``` - -- [x] **Step 2: Update shade plugin to include core** - -The shade plugin includes all compile dependencies by default. Since `cameleer-core` is a compile dependency, its classes will be included in the shaded JAR automatically. No filter changes needed — the existing filters only exclude signature files. - -Verify that the relocations do NOT relocate `com.cameleer.core` (they shouldn't — only `net.bytebuddy`, `org.slf4j`, and `io.opentelemetry` are relocated). - -- [x] **Step 3: Update imports in remaining agent source files** - -The 11 files staying in agent need their imports updated to point to `com.cameleer.core.*`: - -Files in `instrumentation/`: -- `CameleerHookInstaller.java` -- `CamelContextAdvice.java` -- `AgentClassLoader.java` -- `SendDynamicUriAdvice.java` -- `CamelContextTransformer.java` -- `SendDynamicAwareTransformer.java` - -Files in `logging/`: -- `LogForwardingInstaller.java` -- `CameleerLogbackAppender.java` -- `CameleerLog4j2Appender.java` - -Root: -- `CameleerAgent.java` - -Apply the same package renames from Task 2, Step 2 across `cameleer-agent/src/main/java/`. - -- [x] **Step 4: Update imports in all agent test files** - -All 26 test files need import updates. Apply the same package renames across `cameleer-agent/src/test/java/`. - -- [x] **Step 5: Full build and test** - -```bash -mvn clean verify --batch-mode -``` -Expected: BUILD SUCCESS with all existing tests passing. This is the critical regression check. - -- [x] **Step 6: Commit** - -```bash -git add -A -git commit -m "refactor: agent depends on cameleer-core, all imports updated" -``` - ---- - -### Task 4: Move test files to core - -**Files:** -- Move: 12 test files + 2 test support files from agent to core -- Keep in agent: 14 test files (Camel context / instrumentation tests) - -- [x] **Step 1: Move test support files** - -Move to `cameleer-core/src/test/java/com/cameleer/core/test/`: -- `TestChunkedExporter.java` (update package to `com.cameleer.core.test`) -- `TestExporter.java` (update package to `com.cameleer.core.test`) - -- [x] **Step 2: Move unit test files** - -Move these test files, updating packages from `com.cameleer.agent.*` to `com.cameleer.core.*`: - -**connection/ (5 files):** -- `ConfigVerifierTest.java`, `HeartbeatManagerTest.java`, `ServerConnectionTest.java`, `SseClientTest.java` -- `ConfigVerifierNonceBoundTest.java` (from `perf/` — move to `com.cameleer.core.connection` in test dir) - -**collector/ (1 file):** -- `CamelExchangePropertySnapshotTest.java` - -**diagram/ (1 file):** -- `RouteGraphVersionManagerTest.java` - -**export/ (1 file):** -- `ChunkedExporterTest.java` - -**health/ (2 files):** -- `HealthEndpointTest.java`, `StartupReportTest.java` - -**logging/ (2 files):** -- `LogForwarderTest.java` - -Note: `CameleerLogbackAppenderTest.java` stays in agent (tests a ByteBuddy ClassInjector target class). - -- [x] **Step 3: Update imports in moved test files** - -Apply package renames. Also update references to test support classes: -- `com.cameleer.agent.test.TestChunkedExporter` → `com.cameleer.core.test.TestChunkedExporter` -- `com.cameleer.agent.test.TestExporter` → `com.cameleer.core.test.TestExporter` - -- [x] **Step 4: Update remaining agent tests** - -Agent test files that reference `TestChunkedExporter` or `TestExporter` need import updates to the new `com.cameleer.core.test` package. - -- [x] **Step 5: Full build and test** - -```bash -mvn clean verify --batch-mode -``` -Expected: BUILD SUCCESS. All tests pass in both modules. - -- [x] **Step 6: Commit** - -```bash -git add -A -git commit -m "refactor: move unit tests to cameleer-core" -``` - ---- - -## Phase 2: Build `cameleer-extension` - -### Task 5: Create extension module structure - -**Files:** -- Create: `cameleer-extension/pom.xml` (parent) -- Create: `cameleer-extension/runtime/pom.xml` -- Create: `cameleer-extension/deployment/pom.xml` -- Modify: `pom.xml` (root — add module) - -- [x] **Step 1: Add module to root POM** - -In `pom.xml`, add after `cameleer-agent`: -```xml -cameleer-extension -``` - -- [x] **Step 2: Create extension parent POM** - -`cameleer-extension/pom.xml`: -```xml - - - 4.0.0 - - com.cameleer - cameleer-parent - 1.0-SNAPSHOT - - cameleer-extension-parent - pom - Cameleer Extension Parent - - runtime - deployment - - -``` - -- [x] **Step 3: Create runtime POM** - -`cameleer-extension/runtime/pom.xml`: -- artifactId: `cameleer-extension` -- Parent: `cameleer-extension-parent` -- Dependencies: - - `com.cameleer:cameleer-core` - - `io.quarkus:quarkus-arc` - - `org.apache.camel.quarkus:camel-quarkus-core` -- dependencyManagement: import `quarkus-bom` and `camel-quarkus-bom` (same versions as quarkus-app) -- Build: `quarkus-extension-maven-plugin` with `extension-descriptor` goal - -- [x] **Step 4: Create deployment POM** - -`cameleer-extension/deployment/pom.xml`: -- artifactId: `cameleer-extension-deployment` -- Parent: `cameleer-extension-parent` -- Dependencies: - - `com.cameleer:cameleer-extension` (runtime) - - `io.quarkus:quarkus-arc-deployment` - - `org.apache.camel.quarkus:camel-quarkus-core-deployment` -- dependencyManagement: import `quarkus-bom` and `camel-quarkus-bom` - -- [x] **Step 5: Create directory structure** - -```bash -mkdir -p cameleer-extension/runtime/src/main/java/com/cameleer/extension -mkdir -p cameleer-extension/deployment/src/main/java/com/cameleer/extension/deployment -``` - -- [x] **Step 6: Verify skeleton compiles** - -```bash -mvn clean compile -pl cameleer-common,cameleer-core,cameleer-extension --batch-mode -``` -Expected: BUILD SUCCESS - -- [x] **Step 7: Commit** - -```bash -git add cameleer-extension/ pom.xml -git commit -m "feat: create cameleer-extension module skeleton (runtime + deployment)" -``` - ---- - -### Task 6: Implement extension runtime - -**Files:** -- Create: `cameleer-extension/runtime/src/main/java/com/cameleer/extension/CameleerLifecycle.java` -- Create: `cameleer-extension/runtime/src/main/java/com/cameleer/extension/CameleerConfigAdapter.java` - -- [x] **Step 1: Create CameleerConfigAdapter** - -CDI bean that reads Quarkus config properties and adapts them to `CameleerAgentConfig`: - -```java -package com.cameleer.extension; - -import com.cameleer.core.CameleerAgentConfig; -import io.quarkus.arc.Unremovable; -import jakarta.annotation.PostConstruct; -import jakarta.enterprise.context.ApplicationScoped; -import org.eclipse.microprofile.config.inject.ConfigProperty; - -@ApplicationScoped -@Unremovable -public class CameleerConfigAdapter { - - @ConfigProperty(name = "cameleer.export.type", defaultValue = "LOG") - String exportType; - - @ConfigProperty(name = "cameleer.export.endpoint", defaultValue = "") - String exportEndpoint; - - @ConfigProperty(name = "cameleer.agent.name", defaultValue = "cameleer-extension") - String agentName; - - @ConfigProperty(name = "cameleer.agent.application", defaultValue = "") - String applicationId; - - @ConfigProperty(name = "cameleer.engine.level", defaultValue = "REGULAR") - String engineLevel; - - @ConfigProperty(name = "cameleer.payload.capture", defaultValue = "NONE") - String payloadCapture; - - @ConfigProperty(name = "cameleer.routeControl.enabled", defaultValue = "false") - boolean routeControlEnabled; - - @ConfigProperty(name = "cameleer.replay.enabled", defaultValue = "false") - boolean replayEnabled; - - @ConfigProperty(name = "cameleer.execution.compressSuccess", defaultValue = "false") - boolean compressSuccess; - - @ConfigProperty(name = "cameleer.taps.enabled", defaultValue = "true") - boolean tapsEnabled; - - private CameleerAgentConfig config; - - @PostConstruct - void init() { - // Set system properties so CameleerAgentConfig.getInstance() picks them up - setIfNotEmpty("cameleer.export.type", exportType); - setIfNotEmpty("cameleer.export.endpoint", exportEndpoint); - setIfNotEmpty("cameleer.agent.name", agentName); - setIfNotEmpty("cameleer.agent.application", applicationId); - setIfNotEmpty("cameleer.engine.level", engineLevel); - setIfNotEmpty("cameleer.payload.capture", payloadCapture); - System.setProperty("cameleer.routeControl.enabled", String.valueOf(routeControlEnabled)); - System.setProperty("cameleer.replay.enabled", String.valueOf(replayEnabled)); - System.setProperty("cameleer.execution.compressSuccess", String.valueOf(compressSuccess)); - System.setProperty("cameleer.taps.enabled", String.valueOf(tapsEnabled)); - config = CameleerAgentConfig.getInstance(); - } - - public CameleerAgentConfig getConfig() { - return config; - } - - private static void setIfNotEmpty(String key, String value) { - if (value != null && !value.isEmpty()) { - System.setProperty(key, value); - } - } -} -``` - -- [x] **Step 2: Create CameleerLifecycle** - -CDI bean that replaces `CameleerHookInstaller`: - -```java -package com.cameleer.extension; - -import com.cameleer.core.CameleerAgentConfig; -import com.cameleer.core.collector.ExecutionCollector; -import com.cameleer.core.collector.FlatExecutionCollector; -import com.cameleer.core.connection.ServerSetup; -import com.cameleer.core.diagram.RouteModelExtractor; -import com.cameleer.core.export.Exporter; -import com.cameleer.core.export.ExporterFactory; -import com.cameleer.core.export.LogExporter; -import com.cameleer.core.metrics.CamelMetricsBridge; -import com.cameleer.core.notifier.CameleerEventNotifier; -import com.cameleer.core.notifier.CameleerInterceptStrategy; -import io.quarkus.arc.Unremovable; -import io.quarkus.runtime.ShutdownEvent; -import io.quarkus.runtime.StartupEvent; -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.enterprise.event.Observes; -import jakarta.inject.Inject; -import org.apache.camel.CamelContext; -import org.apache.camel.model.ModelCamelContext; -import org.apache.camel.spi.CamelEvent; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@ApplicationScoped -@Unremovable -public class CameleerLifecycle { - - private static final Logger LOG = LoggerFactory.getLogger(CameleerLifecycle.class); - - @Inject - CamelContext camelContext; - - @Inject - CameleerConfigAdapter configAdapter; - - private FlatExecutionCollector collector; - private Exporter exporter; - private CamelMetricsBridge metricsBridge; - - void onStartup(@Observes StartupEvent event) { - CameleerAgentConfig config = configAdapter.getConfig(); - - LOG.info("Cameleer Extension initializing (engine level: {})", config.getEngineLevel()); - - // Create exporter - exporter = ExporterFactory.create(config); - if (exporter == null) { - exporter = new LogExporter(); - } - - // Create collector - collector = new FlatExecutionCollector(config, exporter); - - // Register InterceptStrategy BEFORE routes start - camelContext.setUseMDCLogging(true); - camelContext.getCamelContextExtension() - .addInterceptStrategy(new CameleerInterceptStrategy(collector, config)); - - // Register EventNotifier - CameleerEventNotifier eventNotifier = new CameleerEventNotifier(collector, config); - camelContext.getManagementStrategy().addEventNotifier(eventNotifier); - - LOG.info("Cameleer Extension initialized — InterceptStrategy + EventNotifier registered"); - } - - void onCamelStarted(@Observes CamelEvent.CamelContextStartedEvent event) { - CameleerAgentConfig config = configAdapter.getConfig(); - - // Extract route diagrams - if (config.isDiagramsEnabled() && camelContext instanceof ModelCamelContext mcc) { - RouteModelExtractor.extractAndExport(mcc, exporter); - } - - // Start metrics bridge (JVM mode only) - if (!isNativeImage() && config.isMetricsEnabled()) { - metricsBridge = new CamelMetricsBridge(camelContext, config, exporter); - metricsBridge.start(); - } - - // Server connection setup (HTTP export mode) - if ("HTTP".equalsIgnoreCase(config.getExportType())) { - ServerSetup.setup(camelContext, collector, config); - } - - LOG.info("Cameleer Extension fully started"); - } - - void onShutdown(@Observes ShutdownEvent event) { - LOG.info("Cameleer Extension shutting down"); - if (metricsBridge != null) { - metricsBridge.stop(); - } - if (collector != null) { - collector.shutdown(); - } - if (exporter != null) { - exporter.close(); - } - } - - private static boolean isNativeImage() { - return System.getProperty("org.graalvm.nativeimage.imagecode") != null; - } -} -``` - -Note: The exact constructor signatures and method names for `ExporterFactory.create()`, `RouteModelExtractor.extractAndExport()`, `ServerSetup.setup()`, and `CamelMetricsBridge` will need to match the actual core API. Read those files during implementation and adapt the lifecycle bean accordingly. - -- [x] **Step 3: Verify runtime compiles** - -```bash -mvn clean compile -pl cameleer-common,cameleer-core,cameleer-extension/runtime --batch-mode -``` -Expected: BUILD SUCCESS (may need to adjust method signatures based on actual core API) - -- [x] **Step 4: Commit** - -```bash -git add -A -git commit -m "feat: implement cameleer-extension runtime (CameleerLifecycle + CameleerConfigAdapter)" -``` - ---- - -### Task 7: Implement extension deployment module - -**Files:** -- Create: `cameleer-extension/deployment/src/main/java/com/cameleer/extension/deployment/CameleerExtensionProcessor.java` - -- [x] **Step 1: Create build step processor** - -```java -package com.cameleer.extension.deployment; - -import com.cameleer.common.graph.RouteEdge; -import com.cameleer.common.graph.RouteGraph; -import com.cameleer.common.graph.RouteNode; -import com.cameleer.common.model.*; -import com.cameleer.extension.CameleerConfigAdapter; -import com.cameleer.extension.CameleerLifecycle; -import io.quarkus.arc.deployment.AdditionalBeanBuildItem; -import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.builditem.FeatureBuildItem; -import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; - -public class CameleerExtensionProcessor { - - private static final String FEATURE = "cameleer"; - - @BuildStep - FeatureBuildItem feature() { - return new FeatureBuildItem(FEATURE); - } - - @BuildStep - AdditionalBeanBuildItem registerBeans() { - return AdditionalBeanBuildItem.builder() - .addBeanClasses(CameleerLifecycle.class, CameleerConfigAdapter.class) - .setUnremovable() - .build(); - } - - @BuildStep - ReflectiveClassBuildItem registerReflection() { - return new ReflectiveClassBuildItem(true, true, - ExecutionChunk.class, - FlatProcessorRecord.class, - RouteGraph.class, - RouteNode.class, - RouteEdge.class, - AgentEvent.class, - MetricsSnapshot.class, - ExchangeSnapshot.class, - ErrorInfo.class, - LogEntry.class, - LogBatch.class, - ApplicationConfig.class, - TapDefinition.class, - ExecutionStatus.class, - EngineLevel.class); - } -} -``` - -- [x] **Step 2: Verify full extension compiles** - -```bash -mvn clean compile -pl cameleer-common,cameleer-core,cameleer-extension --batch-mode -``` -Expected: BUILD SUCCESS - -- [x] **Step 3: Full build and test** - -```bash -mvn clean verify --batch-mode -``` -Expected: BUILD SUCCESS — all existing tests still pass - -- [x] **Step 4: Commit** - -```bash -git add -A -git commit -m "feat: implement cameleer-extension deployment (build steps + reflection registration)" -``` - ---- - -## Phase 3: Native Example App - -### Task 8: Create `cameleer-quarkus-native-app` - -**Files:** -- Create: `cameleer-quarkus-native-app/pom.xml` -- Create: `cameleer-quarkus-native-app/src/main/java/com/cameleer/quarkus/native_app/model/Order.java` -- Create: `cameleer-quarkus-native-app/src/main/java/com/cameleer/quarkus/native_app/routes/NativeRestRoute.java` -- Create: `cameleer-quarkus-native-app/src/main/java/com/cameleer/quarkus/native_app/routes/NativeSplitTimerRoute.java` -- Create: `cameleer-quarkus-native-app/src/main/java/com/cameleer/quarkus/native_app/routes/NativeErrorRoute.java` -- Create: `cameleer-quarkus-native-app/src/main/resources/application.properties` -- Create: `cameleer-quarkus-native-app/src/test/resources/application.properties` -- Create: `cameleer-quarkus-native-app/src/test/java/com/cameleer/quarkus/native_app/NativeAppTest.java` -- Modify: `pom.xml` (root — add module) - -Note: Java package uses `native_app` (not `native` which is a reserved keyword). - -- [x] **Step 1: Add module to root POM** - -Add `cameleer-quarkus-native-app` after `cameleer-quarkus-app`. - -- [x] **Step 2: Create module POM** - -Parent: `cameleer-parent`. Dependencies: -- `com.cameleer:cameleer-extension` (the extension runtime — brings in core transitively) -- Same `camel-quarkus-*` extensions as `cameleer-quarkus-app` (minus `camel-quarkus-management` — already in extension) -- `io.quarkus:quarkus-rest-jackson`, `io.quarkus:quarkus-smallrye-health` -- Test: `io.quarkus:quarkus-junit5`, `io.rest-assured:rest-assured` - -Build plugins: `quarkus-maven-plugin`, `maven-surefire-plugin` (with JBoss LogManager). - -Native profile: -```xml - - - native - - true - - - -``` - -- [x] **Step 3: Create Order model, 3 route classes, application.properties** - -Same patterns as `cameleer-quarkus-app` but simpler: -- `NativeRestRoute`: REST DSL + `direct:processOrder` + `direct:getOrder` -- `NativeSplitTimerRoute`: timer → generate list → split → choice → log -- `NativeErrorRoute`: errorHandler(DLC) + onException(handled) + doTry/doCatch/doFinally - -Test properties: `cameleer.export.type=LOG`, disable simulated delays. - -- [x] **Step 4: Create test** - -```java -@QuarkusTest -class NativeAppTest { - @Inject CamelContext camelContext; - - @Test - void contextLoads() { - assertNotNull(camelContext); - assertFalse(camelContext.getRoutes().isEmpty()); - } - - @Test - void expectedRoutesExist() { - assertNotNull(camelContext.getRoute("process-order")); - assertNotNull(camelContext.getRoute("batch-split")); - assertNotNull(camelContext.getRoute("error-test")); - } - - @Test - void postOrder_returnsProcessed() { - given().contentType(ContentType.JSON) - .body("{\"orderId\":\"N-001\",\"type\":\"STANDARD\",\"amount\":50.0}") - .when().post("/api/orders") - .then().statusCode(200); - } -} -``` - -- [x] **Step 5: Build and test** - -```bash -mvn clean verify --batch-mode -``` -Expected: BUILD SUCCESS — all modules pass including new native app (JVM mode tests) - -- [x] **Step 6: Commit** - -```bash -git add -A -git commit -m "feat: add cameleer-quarkus-native-app example application" -``` - ---- - -## Phase 4: Infrastructure Updates - -### Task 9: Update all Dockerfiles - -**Files:** -- Modify: `Dockerfile`, `Dockerfile.backend`, `Dockerfile.caller`, `Dockerfile.quarkus` -- Create: `Dockerfile.quarkus-native` - -- [x] **Step 1: Add POM COPY lines to all 4 existing Dockerfiles** - -Add these lines after the existing POM copies, before `RUN mvn dependency:go-offline`: -```dockerfile -COPY cameleer-core/pom.xml cameleer-core/ -COPY cameleer-extension/pom.xml cameleer-extension/ -COPY cameleer-extension/runtime/pom.xml cameleer-extension/runtime/ -COPY cameleer-extension/deployment/pom.xml cameleer-extension/deployment/ -COPY cameleer-quarkus-native-app/pom.xml cameleer-quarkus-native-app/ -``` - -- [x] **Step 2: Create Dockerfile.quarkus-native** - -```dockerfile -FROM --platform=$BUILDPLATFORM maven:3.9-eclipse-temurin-17 AS build -WORKDIR /build -COPY pom.xml . -COPY cameleer-common/pom.xml cameleer-common/ -COPY cameleer-core/pom.xml cameleer-core/ -COPY cameleer-agent/pom.xml cameleer-agent/ -COPY cameleer-extension/pom.xml cameleer-extension/ -COPY cameleer-extension/runtime/pom.xml cameleer-extension/runtime/ -COPY cameleer-extension/deployment/pom.xml cameleer-extension/deployment/ -COPY cameleer-backend-app/pom.xml cameleer-backend-app/ -COPY cameleer-caller-app/pom.xml cameleer-caller-app/ -COPY cameleer-sample-app/pom.xml cameleer-sample-app/ -COPY cameleer-quarkus-app/pom.xml cameleer-quarkus-app/ -COPY cameleer-quarkus-native-app/pom.xml cameleer-quarkus-native-app/ -RUN mvn dependency:go-offline -B || true -COPY . . -RUN mvn clean package -DskipTests -B -pl cameleer-quarkus-native-app -am - -FROM eclipse-temurin:17-jre -WORKDIR /app -COPY --from=build /build/cameleer-quarkus-native-app/target/quarkus-app/ /app/quarkus-app/ - -ENV CAMELEER_EXPORT_TYPE=HTTP -ENV CAMELEER_EXPORT_ENDPOINT=http://cameleer-server:8081 -ENV CAMELEER_DISPLAY_NAME=quarkus-native-app -ENV CAMELEER_APPLICATION_ID=quarkus-native-app -ENV CAMELEER_ROUTE_CONTROL_ENABLED=false -ENV CAMELEER_REPLAY_ENABLED=false - -EXPOSE 8080 -ENTRYPOINT exec java \ - -Dcameleer.export.type=${CAMELEER_EXPORT_TYPE} \ - -Dcameleer.export.endpoint=${CAMELEER_EXPORT_ENDPOINT} \ - -Dcameleer.agent.name=${HOSTNAME:-${CAMELEER_DISPLAY_NAME}} \ - -Dcameleer.agent.application=${CAMELEER_APPLICATION_ID} \ - -Dcameleer.routeControl.enabled=${CAMELEER_ROUTE_CONTROL_ENABLED} \ - -Dcameleer.replay.enabled=${CAMELEER_REPLAY_ENABLED} \ - -jar /app/quarkus-app/quarkus-run.jar -``` - -Note: This Dockerfile builds JVM mode (not native) for the standard CI pipeline. Native compilation is a separate concern (nightly/manual CI job or local build with `-Pnative`). The JVM-mode image still uses the extension (no `-javaagent`), proving the CDI hook path works. - -- [x] **Step 3: Commit** - -```bash -git add Dockerfile Dockerfile.backend Dockerfile.caller Dockerfile.quarkus Dockerfile.quarkus-native -git commit -m "build: update all Dockerfiles for new module structure" -``` - ---- - -### Task 10: Create K8s manifest and update CI/CD - -**Files:** -- Create: `deploy/quarkus-native-app.yaml` -- Modify: `.gitea/workflows/ci.yml` -- Modify: `.gitea/workflows/camel-compat.yml` -- Modify: `.gitea/workflows/release.yml` - -- [x] **Step 1: Create K8s manifest** - -`deploy/quarkus-native-app.yaml` — same pattern as `quarkus-app.yaml`: -- Deployment: `cameleer-quarkus-native`, 1 replica -- Container: `quarkus-native-app`, image `gitea.siegeln.net/cameleer/cameleer-quarkus-native:latest` -- Port 8080, NodePort 30085 -- Env: `CAMELEER_AUTH_TOKEN` from secret, `CAMELEER_ROUTE_CONTROL_ENABLED=true`, `CAMELEER_REPLAY_ENABLED=true` -- Resources: req 128Mi/50m, limit 256Mi/250m (smaller than JVM apps — native uses less memory) - -- [x] **Step 2: Update CI workflow** - -In `.gitea/workflows/ci.yml`: -- **build job**: add artifact upload for `cameleer-quarkus-native-app/target/quarkus-app/` -- **docker job**: add build+push step for `Dockerfile.quarkus-native` → `cameleer-quarkus-native:$SHA` -- **docker cleanup**: add `cameleer-quarkus-native` to `for pkg in ...` loop -- **deploy job**: add `kubectl apply -f deploy/quarkus-native-app.yaml` + set image + rollout - -- [x] **Step 3: Update compat workflow** - -In `.gitea/workflows/camel-compat.yml`, update the agent compat test command: -```yaml -mvn test "-Dcamel.version=$VERSION" -pl cameleer-common,cameleer-core,cameleer-agent -``` - -- [x] **Step 4: Update release workflow** - -In `.gitea/workflows/release.yml`, update the deploy command to publish core and extension: -```yaml -mvn deploy -DskipTests --batch-mode -pl cameleer-common,cameleer-core,cameleer-extension/runtime -``` - -- [x] **Step 5: Commit** - -```bash -git add deploy/quarkus-native-app.yaml .gitea/workflows/ -git commit -m "build: add K8s manifest and update CI/CD for extension + native app" -``` - ---- - -### Task 11: Update documentation - -**Files:** -- Modify: `CLAUDE.md` - -- [x] **Step 1: Update CLAUDE.md** - -Update Modules section to add: -``` -- `cameleer-core` — shared observability logic (collector, notifier, exporter, metrics, diagrams). Used by both the agent and the Quarkus extension. -- `cameleer-extension` — Quarkus extension providing Cameleer observability via CDI hooks. No `-javaagent` needed. Supports JVM and GraalVM native modes. -- `cameleer-quarkus-native-app` — Quarkus example app using the extension (no agent). Proves CDI hooks work in both JVM and native modes. -``` - -Update Run section to add: -```bash -# Quarkus with extension (no agent needed) -java -jar cameleer-quarkus-native-app/target/quarkus-app/quarkus-run.jar -``` - -Update CI/CD section: add NodePort 30085, mention extension publishing. - -- [x] **Step 2: Commit** - -```bash -git add CLAUDE.md -git commit -m "docs: update CLAUDE.md for core extraction and extension modules" -``` - ---- - -### Task 12: Final verification - -- [x] **Step 1: Full clean build** - -```bash -mvn clean verify --batch-mode -``` -Expected: BUILD SUCCESS — all modules compile, all tests pass. - -- [x] **Step 2: Verify agent still works** - -```bash -java -javaagent:cameleer-agent/target/cameleer-agent-1.0-SNAPSHOT-shaded.jar \ - -Dcameleer.export.type=LOG -Dcameleer.engine.level=COMPLETE \ - -jar cameleer-sample-app/target/cameleer-sample-app-1.0-SNAPSHOT.jar -``` -Expected: Agent hooks activate, `CAMELEER_CHUNK:` lines appear in stdout. - -- [x] **Step 3: Verify extension works (no agent)** - -```bash -java -jar cameleer-quarkus-native-app/target/quarkus-app/quarkus-run.jar -``` -Expected: `Cameleer Extension initialized` log line appears. Routes start. Extension hooks fire without `-javaagent`. - -- [x] **Step 4: Test REST endpoint** - -```bash -curl -X POST http://localhost:8080/api/orders \ - -H 'Content-Type: application/json' \ - -d '{"orderId":"N-001","type":"STANDARD","amount":50.0}' -``` -Expected: 200 OK with processed order. - -- [x] **Step 5: Commit and push** - -```bash -git push -``` diff --git a/docs/superpowers/plans/2026-04-11-log-forwarding-v2.md b/docs/superpowers/plans/2026-04-11-log-forwarding-v2.md deleted file mode 100644 index 5aecb37..0000000 --- a/docs/superpowers/plans/2026-04-11-log-forwarding-v2.md +++ /dev/null @@ -1,2255 +0,0 @@ -# Log Forwarding v2 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace ByteBuddy-injected log appenders with a classpath-based appender JAR, forward all logs (app + agent) via ChunkedExporter to the server, and enrich logs with Cameleer MDC context. - -**Architecture:** New `cameleer-log-appender` module provides Logback/Log4j2 appenders on the app classpath. Appenders convert framework events to `Object[]`, pass through `LogEventBridge` (system CL) to `LogForwarder` (core), which delegates to `ChunkedExporter`. Agent logs captured via custom relocated SLF4J backend with dual-write to stderr. MDC enrichment adds `cameleer.applicationId`, `cameleer.instanceId`, `cameleer.correlationId` per exchange. - -**Tech Stack:** Java 17, SLF4J 2.x, Logback 1.5, Log4j2 2.24, Maven Shade Plugin - -**Spec:** `docs/superpowers/specs/2026-04-11-log-forwarding-v2-design.md` - ---- - -## File Structure - -### New Files - -| File | Module | Purpose | -|------|--------|---------| -| `cameleer-log-appender/pom.xml` | log-appender | Maven module for classpath-based appenders | -| `cameleer-log-appender/src/main/java/com/cameleer/appender/CameleerLogbackAppender.java` | log-appender | Logback appender — converts ILoggingEvent to Object[], passes through bridge | -| `cameleer-log-appender/src/main/java/com/cameleer/appender/CameleerLog4j2Appender.java` | log-appender | Log4j2 appender — converts LogEvent to Object[], passes through bridge | -| `cameleer-log-appender/src/main/java/com/cameleer/appender/LogAppenderRegistrar.java` | log-appender | Detects framework, programmatically registers appender on root logger | -| `cameleer-log-appender/src/test/java/com/cameleer/appender/CameleerLogbackAppenderTest.java` | log-appender | Unit tests for Logback appender | -| `cameleer-log-appender/src/test/java/com/cameleer/appender/CameleerLog4j2AppenderTest.java` | log-appender | Unit tests for Log4j2 appender | -| `cameleer-log-appender/src/test/java/com/cameleer/appender/LogAppenderRegistrarTest.java` | log-appender | Unit tests for registrar | -| `cameleer-core/src/main/java/com/cameleer/core/logging/MdcEnricher.java` | core | Static utility for injecting/clearing Cameleer MDC keys | -| `cameleer-core/src/test/java/com/cameleer/core/logging/MdcEnricherTest.java` | core | Unit tests for MDC enrichment | - -### Modified Files - -| File | Module | Change | -|------|--------|--------| -| `pom.xml` (root) | parent | Add `cameleer-log-appender` to modules list | -| `cameleer-common/.../LogEntry.java` | common | Add `source` field (String) | -| `cameleer-core/.../Exporter.java` | core | Add `default void exportLogs(List)` | -| `cameleer-core/.../ChunkedExporter.java` | core | Add logQueue, exportLogs(), flushLogs() | -| `cameleer-core/.../LogExporter.java` | core | Add exportLogs() implementation (stdout) | -| `cameleer-core/.../LogForwarder.java` | core | Rewrite: use Exporter instead of ServerConnection, accept Object[], remove com.cameleer filter | -| `cameleer-core/.../LogEventBridge.java` | core | Update javadoc (Object[] contract) | -| `cameleer-core/.../CameleerAgentConfig.java` | core | Add `logStderrEnabled` property | -| `cameleer-core/.../ServerSetup.java` | core | Replace LogForwardingInstaller with LogAppenderRegistrar | -| `cameleer-core/.../PostStartSetup.java` | core | Remove LogForwardingSupport param | -| `cameleer-core/.../CameleerEventNotifier.java` | core | Add MDC enrichment calls, remove LogForwardingSupport from shutdown | -| `cameleer-agent/.../CameleerHookInstaller.java` | agent | Remove LogForwardingSupport, add MDC enrichment | -| `cameleer-extension/.../CameleerLifecycle.java` | extension | Wire log appender as Maven dependency | -| `cameleer-core/src/test/.../LogForwarderTest.java` | core | Update tests for new Exporter-based API | -| `cameleer-core/src/test/.../ChunkedExporterTest.java` | core | Add exportLogs() tests | - -### Removed Files - -| File | Module | Reason | -|------|--------|--------| -| `cameleer-agent/.../LogForwardingInstaller.java` | agent | Replaced by LogAppenderRegistrar | -| `cameleer-agent/.../CameleerLogbackAppender.java` | agent | Moved to log-appender module | -| `cameleer-agent/.../CameleerLog4j2Appender.java` | agent | Moved to log-appender module | -| `cameleer-core/.../LogEventConverter.java` | core | Conversion now in appenders | -| `cameleer-core/.../CameleerJulHandler.java` | core | JUL no longer supported | -| `cameleer-core/.../LogForwardingSupport.java` | core | Interface no longer needed | -| `cameleer-common/.../LogBatch.java` | common | Batching handled by ChunkedExporter | - ---- - -### Task 1: Add `source` field to LogEntry - -**Files:** -- Modify: `cameleer-common/src/main/java/com/cameleer/common/model/LogEntry.java` -- Test: `cameleer-common/src/test/java/com/cameleer/common/model/LogEntryTest.java` - -- [ ] **Step 1: Write the failing test** - -Create `cameleer-common/src/test/java/com/cameleer/common/model/LogEntryTest.java`: - -```java -package com.cameleer.common.model; - -import com.cameleer.common.json.CameleerJson; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Test; - -import java.time.Instant; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; - -class LogEntryTest { - - private static final ObjectMapper MAPPER = CameleerJson.mapper(); - - @Test - void source_defaultsToNull() { - LogEntry entry = new LogEntry(Instant.now(), "INFO", "com.example.App", - "hello", "main", null, null); - assertNull(entry.getSource()); - } - - @Test - void source_setAndGet() { - LogEntry entry = new LogEntry(Instant.now(), "INFO", "com.example.App", - "hello", "main", null, null); - entry.setSource("agent"); - assertEquals("agent", entry.getSource()); - } - - @Test - void source_serializedToJson() throws Exception { - LogEntry entry = new LogEntry(Instant.now(), "INFO", "com.example.App", - "hello", "main", null, null); - entry.setSource("app"); - String json = MAPPER.writeValueAsString(entry); - assertTrue(json.contains("\"source\":\"app\"")); - } - - @Test - void source_nullOmittedFromJson() throws Exception { - LogEntry entry = new LogEntry(Instant.now(), "INFO", "com.example.App", - "hello", "main", null, null); - String json = MAPPER.writeValueAsString(entry); - assertFalse(json.contains("source")); - } - - @Test - void source_deserializedFromJson() throws Exception { - String json = "{\"level\":\"INFO\",\"loggerName\":\"com.example.App\"," + - "\"message\":\"hello\",\"source\":\"agent\"}"; - LogEntry entry = MAPPER.readValue(json, LogEntry.class); - assertEquals("agent", entry.getSource()); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `mvn test -pl cameleer-common -Dtest=LogEntryTest -Dsurefire.failIfNoSpecifiedTests=false` -Expected: Compilation error — `getSource()` / `setSource()` don't exist - -- [ ] **Step 3: Add source field to LogEntry** - -In `cameleer-common/src/main/java/com/cameleer/common/model/LogEntry.java`, add after the `mdc` field: - -```java - private String source; -``` - -Add getter/setter after existing getters/setters: - -```java - public String getSource() { return source; } - public void setSource(String source) { this.source = source; } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `mvn test -pl cameleer-common -Dtest=LogEntryTest` -Expected: All 5 tests PASS - -- [ ] **Step 5: Commit** - -```bash -git add cameleer-common/src/main/java/com/cameleer/common/model/LogEntry.java \ - cameleer-common/src/test/java/com/cameleer/common/model/LogEntryTest.java -git commit -m "feat: add source field to LogEntry for app/agent filtering" -``` - ---- - -### Task 2: Add log export to Exporter interface and ChunkedExporter - -**Files:** -- Modify: `cameleer-core/src/main/java/com/cameleer/core/export/Exporter.java` -- Modify: `cameleer-core/src/main/java/com/cameleer/core/export/ChunkedExporter.java` -- Modify: `cameleer-core/src/main/java/com/cameleer/core/export/LogExporter.java` -- Modify: `cameleer-core/src/test/java/com/cameleer/core/export/ChunkedExporterTest.java` - -- [ ] **Step 1: Write the failing test for ChunkedExporter.exportLogs()** - -Add to `ChunkedExporterTest.java`: - -```java - @Test - void exportLogs_queuesAndFlushes() throws Exception { - CountDownLatch latch = new CountDownLatch(1); - when(mockConnection.sendData(eq("/api/v1/data/logs"), anyString())).thenAnswer(inv -> { - latch.countDown(); - return 202; - }); - - LogEntry entry = new LogEntry(Instant.now(), "INFO", "com.example.App", - "test log", "main", null, null); - entry.setSource("app"); - exporter.exportLogs(List.of(entry)); - - assertTrue(latch.await(5, TimeUnit.SECONDS), "Logs should flush within 5 seconds"); - verify(mockConnection).sendData(eq("/api/v1/data/logs"), anyString()); - } - - @Test - void exportLogs_jsonContainsSourceField() throws Exception { - AtomicReference capturedJson = new AtomicReference<>(); - CountDownLatch latch = new CountDownLatch(1); - when(mockConnection.sendData(eq("/api/v1/data/logs"), anyString())).thenAnswer(inv -> { - capturedJson.set(inv.getArgument(1)); - latch.countDown(); - return 202; - }); - - LogEntry entry = new LogEntry(Instant.now(), "ERROR", "com.example.App", - "failure", "main", null, Map.of("camel.exchangeId", "EX-1")); - entry.setSource("app"); - exporter.exportLogs(List.of(entry)); - - assertTrue(latch.await(5, TimeUnit.SECONDS)); - String json = capturedJson.get(); - assertTrue(json.contains("\"source\":\"app\"")); - assertTrue(json.contains("\"loggerName\":\"com.example.App\"")); - assertTrue(json.contains("EX-1")); - } - - @Test - void exportLogs_dropsWhenQueueFull() { - List entries = new ArrayList<>(); - for (int i = 0; i < 1001; i++) { - LogEntry e = new LogEntry(Instant.now(), "INFO", "com.example.App", "msg" + i, "t", null, null); - entries.add(e); - } - assertDoesNotThrow(() -> exporter.exportLogs(entries)); - } - - @Test - void exportLogs_backpressureOn503() throws Exception { - AtomicInteger callCount = new AtomicInteger(0); - when(mockConnection.sendData(eq("/api/v1/data/logs"), anyString())).thenAnswer(inv -> { - callCount.incrementAndGet(); - return 503; - }); - - LogEntry entry = new LogEntry(Instant.now(), "INFO", "com.example.App", "msg", "t", null, null); - exporter.exportLogs(List.of(entry)); - - Thread.sleep(2000); - int countAfterPause = callCount.get(); - - exporter.exportLogs(List.of(new LogEntry(Instant.now(), "INFO", "com.example.App", "msg2", "t", null, null))); - Thread.sleep(1000); - assertTrue(callCount.get() <= countAfterPause + 1); - } -``` - -Add import at top of test file: - -```java -import com.cameleer.common.model.LogEntry; -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `mvn test -pl cameleer-core -Dtest=ChunkedExporterTest#exportLogs_queuesAndFlushes` -Expected: Compilation error — `exportLogs()` method doesn't exist - -- [ ] **Step 3: Add exportLogs() to Exporter interface** - -In `Exporter.java`, add after the existing imports: - -```java -import com.cameleer.common.model.LogEntry; -``` - -Add method after `exportEvent`: - -```java - default void exportLogs(List entries) {} -``` - -- [ ] **Step 4: Add exportLogs() to LogExporter** - -In `LogExporter.java`, add import: - -```java -import com.cameleer.common.model.LogEntry; -``` - -Add method: - -```java - @Override - public void exportLogs(List entries) { - try { - String json = MAPPER.writeValueAsString(entries); - log.info("CAMELEER_LOGS: {}", json); - } catch (JsonProcessingException e) { - log.warn("Cameleer: Failed to serialize logs: {}", e.getMessage()); - } - } -``` - -- [ ] **Step 5: Add exportLogs() to ChunkedExporter** - -In `ChunkedExporter.java`, add import: - -```java -import com.cameleer.common.model.LogEntry; -``` - -Add field after `eventQueue`: - -```java - private final ConcurrentLinkedQueue logQueue = new ConcurrentLinkedQueue<>(); - private final AtomicLong droppedLogs = new AtomicLong(0); -``` - -Add method after `exportEvent()`: - -```java - @Override - public void exportLogs(List entries) { - for (LogEntry entry : entries) { - if (logQueue.size() < MAX_QUEUE_SIZE) { - logQueue.add(entry); - } else { - long dropped = droppedLogs.incrementAndGet(); - long now = System.currentTimeMillis(); - if (now - lastDropWarningMs > 10_000) { - lastDropWarningMs = now; - LOG.warn("Log queue full ({} max), {} logs dropped total", MAX_QUEUE_SIZE, dropped); - } - } - } - } -``` - -Add `flushLogs()` method after `flushEvents()`: - -```java - private void flushLogs() { - List batch = new ArrayList<>(BATCH_SIZE); - for (int i = 0; i < BATCH_SIZE; i++) { - LogEntry entry = logQueue.poll(); - if (entry == null) break; - batch.add(entry); - } - if (batch.isEmpty()) return; - - try { - String json = MAPPER.writeValueAsString(batch); - int status = serverConnection.sendData("/api/v1/data/logs", json); - if (status >= 200 && status < 300) { - LOG.debug("Exported {} log(s) (HTTP {})", batch.size(), status); - } else if (status == 503) { - pauseUntil.set(System.currentTimeMillis() + 10_000); - LOG.warn("Server overloaded (503), pausing log export for 10 seconds"); - } else { - LOG.warn("Log export returned HTTP {} ({} logs lost)", status, batch.size()); - } - } catch (JsonProcessingException e) { - LOG.error("Failed to serialize {} logs", batch.size(), e); - } catch (Exception e) { - LOG.warn("Failed to send {} logs: {}", batch.size(), e.getMessage()); - } - } -``` - -Add `flushLogs()` call in `flush()`: - -```java - private void flush() { - long now = System.currentTimeMillis(); - if (now < pauseUntil.get()) { - return; - } - flushChunks(); - flushMetrics(); - flushEvents(); - flushLogs(); - } -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `mvn test -pl cameleer-core -Dtest=ChunkedExporterTest` -Expected: All tests PASS (existing + 4 new) - -- [ ] **Step 7: Commit** - -```bash -git add cameleer-core/src/main/java/com/cameleer/core/export/Exporter.java \ - cameleer-core/src/main/java/com/cameleer/core/export/ChunkedExporter.java \ - cameleer-core/src/main/java/com/cameleer/core/export/LogExporter.java \ - cameleer-core/src/test/java/com/cameleer/core/export/ChunkedExporterTest.java -git commit -m "feat: add log export support to Exporter/ChunkedExporter" -``` - ---- - -### Task 3: Rewrite LogForwarder to use Exporter - -**Files:** -- Modify: `cameleer-core/src/main/java/com/cameleer/core/logging/LogForwarder.java` -- Modify: `cameleer-core/src/test/java/com/cameleer/core/logging/LogForwarderTest.java` - -- [ ] **Step 1: Write the failing tests** - -Rewrite `LogForwarderTest.java`: - -```java -package com.cameleer.core.logging; - -import com.cameleer.common.model.LogEntry; -import com.cameleer.core.export.Exporter; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; - -import java.time.Instant; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -class LogForwarderTest { - - private Exporter exporter; - private LogForwarder forwarder; - - @BeforeEach - void setUp() { - exporter = mock(Exporter.class); - } - - @AfterEach - void tearDown() { - if (forwarder != null) { - forwarder.close(); - } - } - - @Test - void forward_objectArray_reconstructsLogEntry() throws Exception { - forwarder = new LogForwarder(exporter); - - Instant ts = Instant.now(); - Map mdc = Map.of("camel.exchangeId", "EX-1"); - Object[] data = {ts.toEpochMilli(), "INFO", "com.example.App", "hello", "main", null, mdc, "app"}; - forwarder.forward(data); - - forwarder.close(); - forwarder = null; - - @SuppressWarnings("unchecked") - ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); - verify(exporter, atLeastOnce()).exportLogs(captor.capture()); - List entries = captor.getValue(); - assertFalse(entries.isEmpty()); - LogEntry entry = entries.get(0); - assertEquals("INFO", entry.getLevel()); - assertEquals("com.example.App", entry.getLoggerName()); - assertEquals("hello", entry.getMessage()); - assertEquals("main", entry.getThreadName()); - assertEquals("app", entry.getSource()); - assertEquals("EX-1", entry.getMdc().get("camel.exchangeId")); - } - - @Test - void forward_allLevels_queued() throws Exception { - forwarder = new LogForwarder(exporter); - - forwarder.forward(objectArray("TRACE", "com.example.App", "trace")); - forwarder.forward(objectArray("DEBUG", "com.example.App", "debug")); - forwarder.forward(objectArray("INFO", "com.example.App", "info")); - forwarder.forward(objectArray("WARN", "com.example.App", "warn")); - forwarder.forward(objectArray("ERROR", "com.example.App", "error")); - - forwarder.close(); - forwarder = null; - - verify(exporter, atLeastOnce()).exportLogs(anyList()); - } - - @Test - void forward_agentLogs_notFiltered() throws Exception { - forwarder = new LogForwarder(exporter); - - forwarder.forward(objectArray("ERROR", "com.cameleer.core.export.ChunkedExporter", "error")); - - forwarder.close(); - forwarder = null; - - verify(exporter, atLeastOnce()).exportLogs(anyList()); - } - - @Test - void forward_queueFull_drops() { - forwarder = new LogForwarder(exporter); - - assertDoesNotThrow(() -> { - for (int i = 0; i < 1050; i++) { - forwarder.forward(objectArray("ERROR", "com.example.App", "msg " + i)); - } - }); - } - - @Test - void forward_withStackTrace_included() throws Exception { - forwarder = new LogForwarder(exporter); - - Object[] data = {System.currentTimeMillis(), "ERROR", "com.example.App", "failed", - "main", "java.lang.NPE\n\tat App.run(App.java:42)", null, "app"}; - forwarder.forward(data); - - forwarder.close(); - forwarder = null; - - @SuppressWarnings("unchecked") - ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); - verify(exporter, atLeastOnce()).exportLogs(captor.capture()); - LogEntry entry = captor.getValue().get(0); - assertNotNull(entry.getStackTrace()); - assertTrue(entry.getStackTrace().contains("NPE")); - } - - private static Object[] objectArray(String level, String loggerName, String message) { - return new Object[]{System.currentTimeMillis(), level, loggerName, message, "main", null, null, "app"}; - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `mvn test -pl cameleer-core -Dtest=LogForwarderTest` -Expected: Compilation error — LogForwarder constructor doesn't accept Exporter - -- [ ] **Step 3: Rewrite LogForwarder** - -Replace `cameleer-core/src/main/java/com/cameleer/core/logging/LogForwarder.java`: - -```java -package com.cameleer.core.logging; - -import com.cameleer.common.model.LogEntry; -import com.cameleer.core.export.Exporter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Receives log data (as Object[] from cross-classloader bridge or direct LogEntry), - * buffers, and delegates to Exporter for batched HTTP transport. - */ -public class LogForwarder { - - private static final Logger LOG = LoggerFactory.getLogger(LogForwarder.class); - - private static final int MAX_QUEUE_SIZE = 1000; - private static final int BATCH_SIZE = 50; - - private final Exporter exporter; - private final ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue<>(); - private final ScheduledExecutorService scheduler; - private final AtomicLong droppedLogs = new AtomicLong(0); - private volatile long lastDropWarningMs = 0; - - public LogForwarder(Exporter exporter) { - this.exporter = exporter; - this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> { - Thread t = new Thread(r, "cameleer-log-forwarder"); - t.setDaemon(true); - return t; - }); - this.scheduler.scheduleAtFixedRate(this::flush, 1, 1, TimeUnit.SECONDS); - } - - /** - * Called from the cross-classloader bridge. Data is an Object[] with fields: - * [0] long timestampEpochMs, [1] String level, [2] String loggerName, - * [3] String message, [4] String threadName, [5] String stackTrace (nullable), - * [6] Map mdc (nullable), [7] String source ("app"/"agent") - */ - @SuppressWarnings("unchecked") - public void forward(Object data) { - if (!(data instanceof Object[] arr) || arr.length < 7) return; - - LogEntry entry = new LogEntry( - Instant.ofEpochMilli((long) arr[0]), - (String) arr[1], - (String) arr[2], - (String) arr[3], - (String) arr[4], - (String) arr[5], - (Map) arr[6] - ); - if (arr.length > 7 && arr[7] != null) { - entry.setSource((String) arr[7]); - } - - enqueue(entry); - } - - /** - * Direct forwarding for agent-internal logs (already on system CL). - */ - public void forwardDirect(LogEntry entry) { - enqueue(entry); - } - - private void enqueue(LogEntry entry) { - if (queue.size() < MAX_QUEUE_SIZE) { - queue.add(entry); - } else { - long dropped = droppedLogs.incrementAndGet(); - long now = System.currentTimeMillis(); - if (now - lastDropWarningMs > 10_000) { - lastDropWarningMs = now; - LOG.warn("Cameleer: Log queue full, {} total dropped", dropped); - } - } - } - - public void close() { - scheduler.shutdown(); - try { - flush(); - scheduler.awaitTermination(5, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - private void flush() { - if (queue.isEmpty()) return; - - List batch = new ArrayList<>(BATCH_SIZE); - for (int i = 0; i < BATCH_SIZE; i++) { - LogEntry entry = queue.poll(); - if (entry == null) break; - batch.add(entry); - } - - if (!batch.isEmpty()) { - exporter.exportLogs(batch); - } - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `mvn test -pl cameleer-core -Dtest=LogForwarderTest` -Expected: All 5 tests PASS - -- [ ] **Step 5: Commit** - -```bash -git add cameleer-core/src/main/java/com/cameleer/core/logging/LogForwarder.java \ - cameleer-core/src/test/java/com/cameleer/core/logging/LogForwarderTest.java -git commit -m "refactor: rewrite LogForwarder to use Exporter instead of ServerConnection" -``` - ---- - -### Task 4: Create cameleer-log-appender module - -**Files:** -- Create: `cameleer-log-appender/pom.xml` -- Modify: `pom.xml` (root) - -- [ ] **Step 1: Create module POM** - -Create `cameleer-log-appender/pom.xml`: - -```xml - - - 4.0.0 - - - com.cameleer - cameleer-parent - 1.0-SNAPSHOT - - - cameleer-log-appender - Cameleer Log Appender - Classpath-based logging appenders for Cameleer log forwarding - - - - - ch.qos.logback - logback-classic - ${logback.version} - provided - - - org.apache.logging.log4j - log4j-core - ${log4j2.version} - provided - - - - - org.junit.jupiter - junit-jupiter - test - - - org.mockito - mockito-core - 5.14.2 - test - - - org.slf4j - slf4j-api - test - - - -``` - -- [ ] **Step 2: Add module to root POM** - -In root `pom.xml`, add after `cameleer-core`: - -```xml - cameleer-log-appender -``` - -- [ ] **Step 3: Verify module compiles** - -Run: `mvn compile -pl cameleer-log-appender` -Expected: BUILD SUCCESS (empty module) - -- [ ] **Step 4: Commit** - -```bash -git add cameleer-log-appender/pom.xml pom.xml -git commit -m "feat: add cameleer-log-appender module skeleton" -``` - ---- - -### Task 5: Create Logback appender in new module - -**Files:** -- Create: `cameleer-log-appender/src/main/java/com/cameleer/appender/CameleerLogbackAppender.java` -- Create: `cameleer-log-appender/src/test/java/com/cameleer/appender/CameleerLogbackAppenderTest.java` - -- [ ] **Step 1: Write the failing test** - -Create `cameleer-log-appender/src/test/java/com/cameleer/appender/CameleerLogbackAppenderTest.java`: - -```java -package com.cameleer.appender; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.LoggerContext; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.classic.spi.LoggingEvent; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.lang.reflect.Field; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; - -import static org.junit.jupiter.api.Assertions.*; - -class CameleerLogbackAppenderTest { - - private final List captured = new ArrayList<>(); - private CameleerLogbackAppender appender; - - @BeforeEach - void setUp() { - appender = new CameleerLogbackAppender(); - appender.setContext(new LoggerContext()); - appender.setHandlerForTest(arr -> captured.add((Object[]) arr)); - appender.start(); - } - - @AfterEach - void tearDown() { - appender.stop(); - } - - @Test - void append_convertsToObjectArray() { - LoggerContext ctx = new LoggerContext(); - Logger logger = ctx.getLogger("com.example.App"); - LoggingEvent event = new LoggingEvent("com.example.App", logger, Level.INFO, "test message", null, null); - - appender.doAppend(event); - - assertEquals(1, captured.size()); - Object[] arr = captured.get(0); - assertEquals(8, arr.length); - assertEquals("INFO", arr[1]); - assertEquals("com.example.App", arr[2]); - assertEquals("test message", arr[3]); - assertEquals("app", arr[7]); - } - - @Test - void append_extractsMdc() { - LoggerContext ctx = new LoggerContext(); - Logger logger = ctx.getLogger("com.example.App"); - org.slf4j.MDC.put("camel.exchangeId", "EX-123"); - try { - LoggingEvent event = new LoggingEvent("com.example.App", logger, Level.WARN, "with mdc", null, null); - appender.doAppend(event); - - Object[] arr = captured.get(0); - @SuppressWarnings("unchecked") - Map mdc = (Map) arr[6]; - assertNotNull(mdc); - assertEquals("EX-123", mdc.get("camel.exchangeId")); - } finally { - org.slf4j.MDC.remove("camel.exchangeId"); - } - } - - @Test - void append_filtersCameleerLoggers() { - LoggerContext ctx = new LoggerContext(); - Logger logger = ctx.getLogger("com.cameleer.core.export.ChunkedExporter"); - LoggingEvent event = new LoggingEvent("com.cameleer.core.export.ChunkedExporter", - logger, Level.ERROR, "agent internal", null, null); - - appender.doAppend(event); - - assertTrue(captured.isEmpty(), "Agent logs should be filtered out"); - } - - @Test - void append_nullHandler_doesNotThrow() { - appender.setHandlerForTest(null); - LoggerContext ctx = new LoggerContext(); - Logger logger = ctx.getLogger("com.example.App"); - LoggingEvent event = new LoggingEvent("com.example.App", logger, Level.INFO, "msg", null, null); - - assertDoesNotThrow(() -> appender.doAppend(event)); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `mvn test -pl cameleer-log-appender -Dtest=CameleerLogbackAppenderTest` -Expected: Compilation error — class doesn't exist - -- [ ] **Step 3: Create CameleerLogbackAppender** - -Create `cameleer-log-appender/src/main/java/com/cameleer/appender/CameleerLogbackAppender.java`: - -```java -package com.cameleer.appender; - -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.classic.spi.ThrowableProxyUtil; -import ch.qos.logback.core.AppenderBase; - -import java.lang.reflect.Field; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; - -/** - * Logback appender that forwards log events to the Cameleer agent. - * Lives on the application classpath (not injected via ByteBuddy). - * Converts ILoggingEvent to Object[] and passes through LogEventBridge - * on the system classloader. - * - *

Object[] layout: - * [0] long timestampEpochMs, [1] String level, [2] String loggerName, - * [3] String message, [4] String threadName, [5] String stackTrace (nullable), - * [6] Map mdc (nullable), [7] String source ("app") - */ -public class CameleerLogbackAppender extends AppenderBase { - - private static final AtomicReference bridgeHandlerField = new AtomicReference<>(); - - /** - * Test-only: allows injecting a handler directly without the cross-CL bridge. - */ - private Consumer testHandler; - - public void setHandlerForTest(Consumer handler) { - this.testHandler = handler; - } - - @Override - protected void append(ILoggingEvent event) { - // Filter out agent's own logs (they go through the agent SLF4J backend) - String loggerName = event.getLoggerName(); - if (loggerName != null && loggerName.startsWith("com.cameleer.")) { - return; - } - - try { - String stackTrace = null; - if (event.getThrowableProxy() != null) { - stackTrace = ThrowableProxyUtil.asString(event.getThrowableProxy()); - } - - Map mdc = null; - if (event.getMDCPropertyMap() != null && !event.getMDCPropertyMap().isEmpty()) { - mdc = new HashMap<>(event.getMDCPropertyMap()); - } - - Object[] data = { - event.getTimeStamp(), // [0] timestampEpochMs - event.getLevel().toString(), // [1] level - loggerName, // [2] loggerName - event.getFormattedMessage(), // [3] message - event.getThreadName(), // [4] threadName - stackTrace, // [5] stackTrace - mdc, // [6] mdc - "app" // [7] source - }; - - Consumer handler = testHandler; - if (handler != null) { - handler.accept(data); - return; - } - - forwardViaBridge(data); - } catch (Throwable ignored) { - // Never let log forwarding crash the application - } - } - - @SuppressWarnings("unchecked") - private void forwardViaBridge(Object[] data) throws Exception { - Field f = bridgeHandlerField.get(); - if (f == null) { - Class bridge = ClassLoader.getSystemClassLoader() - .loadClass("com.cameleer.core.logging.LogEventBridge"); - f = bridge.getField("handler"); - bridgeHandlerField.set(f); - } - AtomicReference> ref = - (AtomicReference>) f.get(null); - Consumer h = ref.get(); - if (h != null) { - h.accept(data); - } - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `mvn test -pl cameleer-log-appender -Dtest=CameleerLogbackAppenderTest` -Expected: All 4 tests PASS - -- [ ] **Step 5: Commit** - -```bash -git add cameleer-log-appender/src/main/java/com/cameleer/appender/CameleerLogbackAppender.java \ - cameleer-log-appender/src/test/java/com/cameleer/appender/CameleerLogbackAppenderTest.java -git commit -m "feat: add Logback appender to log-appender module" -``` - ---- - -### Task 6: Create Log4j2 appender in new module - -**Files:** -- Create: `cameleer-log-appender/src/main/java/com/cameleer/appender/CameleerLog4j2Appender.java` -- Create: `cameleer-log-appender/src/test/java/com/cameleer/appender/CameleerLog4j2AppenderTest.java` - -- [ ] **Step 1: Write the failing test** - -Create `cameleer-log-appender/src/test/java/com/cameleer/appender/CameleerLog4j2AppenderTest.java`: - -```java -package com.cameleer.appender; - -import org.apache.logging.log4j.Level; -import org.apache.logging.log4j.core.LogEvent; -import org.apache.logging.log4j.core.impl.Log4jLogEvent; -import org.apache.logging.log4j.message.SimpleMessage; -import org.apache.logging.log4j.util.SortedArrayStringMap; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; - -import static org.junit.jupiter.api.Assertions.*; - -class CameleerLog4j2AppenderTest { - - private final List captured = new ArrayList<>(); - private CameleerLog4j2Appender appender; - - @BeforeEach - void setUp() { - appender = new CameleerLog4j2Appender(); - appender.setHandlerForTest(arr -> captured.add((Object[]) arr)); - appender.start(); - } - - @Test - void append_convertsToObjectArray() { - LogEvent event = Log4jLogEvent.newBuilder() - .setLoggerName("com.example.App") - .setLevel(Level.ERROR) - .setMessage(new SimpleMessage("bad thing")) - .setThreadName("worker-1") - .build(); - - appender.append(event); - - assertEquals(1, captured.size()); - Object[] arr = captured.get(0); - assertEquals(8, arr.length); - assertEquals("ERROR", arr[1]); - assertEquals("com.example.App", arr[2]); - assertEquals("bad thing", arr[3]); - assertEquals("worker-1", arr[4]); - assertEquals("app", arr[7]); - } - - @Test - void append_extractsContextData() { - SortedArrayStringMap contextData = new SortedArrayStringMap(); - contextData.putValue("camel.routeId", "route-1"); - - LogEvent event = Log4jLogEvent.newBuilder() - .setLoggerName("com.example.App") - .setLevel(Level.INFO) - .setMessage(new SimpleMessage("with context")) - .setContextData(contextData) - .build(); - - appender.append(event); - - Object[] arr = captured.get(0); - @SuppressWarnings("unchecked") - Map mdc = (Map) arr[6]; - assertNotNull(mdc); - assertEquals("route-1", mdc.get("camel.routeId")); - } - - @Test - void append_filtersCameleerLoggers() { - LogEvent event = Log4jLogEvent.newBuilder() - .setLoggerName("com.cameleer.core.logging.LogForwarder") - .setLevel(Level.INFO) - .setMessage(new SimpleMessage("internal")) - .build(); - - appender.append(event); - - assertTrue(captured.isEmpty()); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `mvn test -pl cameleer-log-appender -Dtest=CameleerLog4j2AppenderTest` -Expected: Compilation error — class doesn't exist - -- [ ] **Step 3: Create CameleerLog4j2Appender** - -Create `cameleer-log-appender/src/main/java/com/cameleer/appender/CameleerLog4j2Appender.java`: - -```java -package com.cameleer.appender; - -import org.apache.logging.log4j.core.LogEvent; -import org.apache.logging.log4j.core.appender.AbstractAppender; -import org.apache.logging.log4j.core.config.Property; - -import java.io.PrintWriter; -import java.io.StringWriter; -import java.lang.reflect.Field; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; - -/** - * Log4j2 appender that forwards log events to the Cameleer agent. - * Lives on the application classpath (not injected via ByteBuddy). - * Converts LogEvent to Object[] and passes through LogEventBridge - * on the system classloader. Same Object[] layout as CameleerLogbackAppender. - */ -public class CameleerLog4j2Appender extends AbstractAppender { - - private static final AtomicReference bridgeHandlerField = new AtomicReference<>(); - - private Consumer testHandler; - - public CameleerLog4j2Appender() { - super("cameleer-log-forwarder", null, null, true, Property.EMPTY_ARRAY); - } - - public void setHandlerForTest(Consumer handler) { - this.testHandler = handler; - } - - @Override - public void append(LogEvent event) { - String loggerName = event.getLoggerName(); - if (loggerName != null && loggerName.startsWith("com.cameleer.")) { - return; - } - - try { - String stackTrace = null; - if (event.getThrown() != null) { - StringWriter sw = new StringWriter(); - event.getThrown().printStackTrace(new PrintWriter(sw)); - stackTrace = sw.toString(); - } - - Map mdc = null; - if (event.getContextData() != null && !event.getContextData().isEmpty()) { - Map mdcMap = new HashMap<>(); - event.getContextData().forEach((k, v) -> mdcMap.put(k, v != null ? v.toString() : null)); - mdc = mdcMap; - } - - Object[] data = { - event.getTimeMillis(), - event.getLevel().toString(), - loggerName, - event.getMessage().getFormattedMessage(), - event.getThreadName(), - stackTrace, - mdc, - "app" - }; - - Consumer handler = testHandler; - if (handler != null) { - handler.accept(data); - return; - } - - forwardViaBridge(data); - } catch (Throwable ignored) { - } - } - - @SuppressWarnings("unchecked") - private void forwardViaBridge(Object[] data) throws Exception { - Field f = bridgeHandlerField.get(); - if (f == null) { - Class bridge = ClassLoader.getSystemClassLoader() - .loadClass("com.cameleer.core.logging.LogEventBridge"); - f = bridge.getField("handler"); - bridgeHandlerField.set(f); - } - AtomicReference> ref = - (AtomicReference>) f.get(null); - Consumer h = ref.get(); - if (h != null) { - h.accept(data); - } - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `mvn test -pl cameleer-log-appender -Dtest=CameleerLog4j2AppenderTest` -Expected: All 3 tests PASS - -- [ ] **Step 5: Commit** - -```bash -git add cameleer-log-appender/src/main/java/com/cameleer/appender/CameleerLog4j2Appender.java \ - cameleer-log-appender/src/test/java/com/cameleer/appender/CameleerLog4j2AppenderTest.java -git commit -m "feat: add Log4j2 appender to log-appender module" -``` - ---- - -### Task 7: Create LogAppenderRegistrar - -**Files:** -- Create: `cameleer-log-appender/src/main/java/com/cameleer/appender/LogAppenderRegistrar.java` -- Create: `cameleer-log-appender/src/test/java/com/cameleer/appender/LogAppenderRegistrarTest.java` - -- [ ] **Step 1: Write the failing test** - -Create `cameleer-log-appender/src/test/java/com/cameleer/appender/LogAppenderRegistrarTest.java`: - -```java -package com.cameleer.appender; - -import ch.qos.logback.classic.LoggerContext; -import ch.qos.logback.core.Appender; -import org.junit.jupiter.api.Test; - -import java.util.Iterator; - -import static org.junit.jupiter.api.Assertions.*; - -class LogAppenderRegistrarTest { - - @Test - void install_detectsLogback() { - String framework = LogAppenderRegistrar.install(Thread.currentThread().getContextClassLoader()); - assertEquals("Logback", framework); - } - - @Test - void install_registersAppenderOnRootLogger() { - LogAppenderRegistrar.install(Thread.currentThread().getContextClassLoader()); - - LoggerContext ctx = (LoggerContext) org.slf4j.LoggerFactory.getILoggerFactory(); - ch.qos.logback.classic.Logger root = ctx.getLogger("ROOT"); - - boolean found = false; - Iterator> iter = root.iteratorForAppenders(); - while (iter.hasNext()) { - if (iter.next() instanceof CameleerLogbackAppender) { - found = true; - break; - } - } - assertTrue(found, "CameleerLogbackAppender should be on root logger"); - } - - @Test - void install_returnsNullWhenNoFramework() { - // System classloader has no Logback or Log4j2 - String result = LogAppenderRegistrar.install(ClassLoader.getSystemClassLoader()); - // Will be null or framework name depending on test classpath - // In this test env, Logback IS on classpath so this verifies no exception - assertNotNull(result); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `mvn test -pl cameleer-log-appender -Dtest=LogAppenderRegistrarTest` -Expected: Compilation error — class doesn't exist - -- [ ] **Step 3: Create LogAppenderRegistrar** - -Create `cameleer-log-appender/src/main/java/com/cameleer/appender/LogAppenderRegistrar.java`: - -```java -package com.cameleer.appender; - -/** - * Detects the active logging framework and programmatically registers the - * appropriate Cameleer appender on the root logger. Called by the agent - * via reflection from the system classloader. - */ -public class LogAppenderRegistrar { - - private static final String LOGBACK_CONTEXT = "ch.qos.logback.classic.LoggerContext"; - private static final String LOG4J2_CONTEXT = "org.apache.logging.log4j.core.LoggerContext"; - - /** - * Detects the logging framework and registers the Cameleer appender. - * - * @param appClassLoader the application's classloader - * @return "Logback" or "Log4j2", or null if no supported framework found - */ - public static String install(ClassLoader appClassLoader) { - if (isPresent(LOGBACK_CONTEXT, appClassLoader)) { - return installLogback(appClassLoader); - } - if (isPresent(LOG4J2_CONTEXT, appClassLoader)) { - return installLog4j2(appClassLoader); - } - return null; - } - - private static String installLogback(ClassLoader appClassLoader) { - try { - // Use shade-safe lookup: String.join prevents shade plugin from rewriting - Class loggerFactoryClass = appClassLoader.loadClass( - String.join(".", "org", "slf4j", "LoggerFactory")); - Object ctx = loggerFactoryClass.getMethod("getILoggerFactory").invoke(null); - - Class loggerContextClass = appClassLoader.loadClass(LOGBACK_CONTEXT); - Object rootLogger = loggerContextClass.getMethod("getLogger", String.class) - .invoke(ctx, "ROOT"); - - CameleerLogbackAppender appender = new CameleerLogbackAppender(); - Class contextBaseClass = appClassLoader.loadClass("ch.qos.logback.core.Context"); - appender.getClass().getMethod("setContext", contextBaseClass).invoke(appender, ctx); - appender.setName("cameleer-log-forwarder"); - appender.start(); - - Class logbackAppenderClass = appClassLoader.loadClass("ch.qos.logback.core.Appender"); - rootLogger.getClass().getMethod("addAppender", logbackAppenderClass) - .invoke(rootLogger, appender); - - return "Logback"; - } catch (Exception e) { - return null; - } - } - - private static String installLog4j2(ClassLoader appClassLoader) { - try { - Class logManagerClass = appClassLoader.loadClass("org.apache.logging.log4j.LogManager"); - Object loggerContext = logManagerClass.getMethod("getContext", boolean.class).invoke(null, false); - Object configuration = loggerContext.getClass().getMethod("getConfiguration").invoke(loggerContext); - - CameleerLog4j2Appender appender = new CameleerLog4j2Appender(); - appender.start(); - - Class log4jAppenderClass = appClassLoader.loadClass("org.apache.logging.log4j.core.Appender"); - configuration.getClass().getMethod("addAppender", log4jAppenderClass) - .invoke(configuration, appender); - - Object rootLoggerConfig = configuration.getClass().getMethod("getRootLogger").invoke(configuration); - Class levelClass = appClassLoader.loadClass("org.apache.logging.log4j.Level"); - Class filterClass = appClassLoader.loadClass("org.apache.logging.log4j.core.Filter"); - rootLoggerConfig.getClass().getMethod("addAppender", log4jAppenderClass, levelClass, filterClass) - .invoke(rootLoggerConfig, appender, null, null); - - loggerContext.getClass().getMethod("updateLoggers").invoke(loggerContext); - - return "Log4j2"; - } catch (Exception e) { - return null; - } - } - - private static boolean isPresent(String className, ClassLoader cl) { - try { - Class.forName(className, false, cl); - return true; - } catch (ClassNotFoundException e) { - return false; - } - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `mvn test -pl cameleer-log-appender -Dtest=LogAppenderRegistrarTest` -Expected: All 3 tests PASS - -- [ ] **Step 5: Commit** - -```bash -git add cameleer-log-appender/src/main/java/com/cameleer/appender/LogAppenderRegistrar.java \ - cameleer-log-appender/src/test/java/com/cameleer/appender/LogAppenderRegistrarTest.java -git commit -m "feat: add LogAppenderRegistrar for framework-agnostic appender registration" -``` - ---- - -### Task 8: Add MDC enrichment - -**Files:** -- Create: `cameleer-core/src/main/java/com/cameleer/core/logging/MdcEnricher.java` -- Create: `cameleer-core/src/test/java/com/cameleer/core/logging/MdcEnricherTest.java` - -- [ ] **Step 1: Write the failing test** - -Create `cameleer-core/src/test/java/com/cameleer/core/logging/MdcEnricherTest.java`: - -```java -package com.cameleer.core.logging; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.slf4j.MDC; - -import static org.junit.jupiter.api.Assertions.*; - -class MdcEnricherTest { - - @AfterEach - void tearDown() { - MDC.clear(); - } - - @Test - void setExchangeContext_setsAllKeys() { - MdcEnricher.setExchangeContext("my-app", "instance-1", "corr-123"); - - assertEquals("my-app", MDC.get("cameleer.applicationId")); - assertEquals("instance-1", MDC.get("cameleer.instanceId")); - assertEquals("corr-123", MDC.get("cameleer.correlationId")); - } - - @Test - void setExchangeContext_nullCorrelationId_skips() { - MdcEnricher.setExchangeContext("my-app", "instance-1", null); - - assertEquals("my-app", MDC.get("cameleer.applicationId")); - assertEquals("instance-1", MDC.get("cameleer.instanceId")); - assertNull(MDC.get("cameleer.correlationId")); - } - - @Test - void clearExchangeContext_removesAllKeys() { - MdcEnricher.setExchangeContext("my-app", "instance-1", "corr-123"); - MdcEnricher.clearExchangeContext(); - - assertNull(MDC.get("cameleer.applicationId")); - assertNull(MDC.get("cameleer.instanceId")); - assertNull(MDC.get("cameleer.correlationId")); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `mvn test -pl cameleer-core -Dtest=MdcEnricherTest` -Expected: Compilation error — class doesn't exist - -- [ ] **Step 3: Create MdcEnricher** - -Create `cameleer-core/src/main/java/com/cameleer/core/logging/MdcEnricher.java`: - -```java -package com.cameleer.core.logging; - -import org.slf4j.MDC; - -/** - * Injects Cameleer-specific MDC keys during exchange processing. - * These keys are captured by the Cameleer appender alongside Camel's - * built-in MDC keys (camel.exchangeId, camel.routeId, camel.correlationId). - */ -public final class MdcEnricher { - - static final String KEY_APPLICATION_ID = "cameleer.applicationId"; - static final String KEY_INSTANCE_ID = "cameleer.instanceId"; - static final String KEY_CORRELATION_ID = "cameleer.correlationId"; - - private MdcEnricher() {} - - /** - * Sets Cameleer MDC context for the current exchange. - * Called in EventNotifier.onExchangeCreated(). - */ - public static void setExchangeContext(String applicationId, String instanceId, String correlationId) { - MDC.put(KEY_APPLICATION_ID, applicationId); - MDC.put(KEY_INSTANCE_ID, instanceId); - if (correlationId != null) { - MDC.put(KEY_CORRELATION_ID, correlationId); - } - } - - /** - * Clears Cameleer MDC context. - * Called in EventNotifier.onExchangeCompleted() / onExchangeFailed(). - */ - public static void clearExchangeContext() { - MDC.remove(KEY_APPLICATION_ID); - MDC.remove(KEY_INSTANCE_ID); - MDC.remove(KEY_CORRELATION_ID); - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `mvn test -pl cameleer-core -Dtest=MdcEnricherTest` -Expected: All 3 tests PASS - -- [ ] **Step 5: Commit** - -```bash -git add cameleer-core/src/main/java/com/cameleer/core/logging/MdcEnricher.java \ - cameleer-core/src/test/java/com/cameleer/core/logging/MdcEnricherTest.java -git commit -m "feat: add MdcEnricher for Cameleer MDC context injection" -``` - ---- - -### Task 9: Add `cameleer.agent.logs.stderr` config property - -**Files:** -- Modify: `cameleer-core/src/main/java/com/cameleer/core/CameleerAgentConfig.java` - -- [ ] **Step 1: Add property** - -In `CameleerAgentConfig.java`, add field after `applicationLogLevel`: - -```java - private boolean logStderrEnabled; -``` - -In `reload()`, add after the `applicationLogLevel` line: - -```java - this.logStderrEnabled = getBoolProp("cameleer.agent.logs.stderr", true); -``` - -Add getter after `getApplicationLogLevel()`: - -```java - public boolean isLogStderrEnabled() { return logStderrEnabled; } -``` - -- [ ] **Step 2: Verify compilation** - -Run: `mvn compile -pl cameleer-core` -Expected: BUILD SUCCESS - -- [ ] **Step 3: Commit** - -```bash -git add cameleer-core/src/main/java/com/cameleer/core/CameleerAgentConfig.java -git commit -m "feat: add cameleer.agent.logs.stderr config property" -``` - ---- - -### Task 10: Wire new log forwarding in ServerSetup - -**Files:** -- Modify: `cameleer-core/src/main/java/com/cameleer/core/connection/ServerSetup.java` - -This task rewires `installLogForwarding()` to use the new `LogAppenderRegistrar` via reflection on the app classloader (since the registrar lives in `cameleer-log-appender` which is on the app CL), and creates `LogForwarder` with the `ChunkedExporter` instead of `ServerConnection`. - -- [ ] **Step 1: Update ServerSetup.installLogForwarding()** - -Replace the `installLogForwarding` method and update the `ConnectionContext` record to remove `LogForwardingSupport`: - -In `ServerSetup.java`, update the `ConnectionContext` record: - -```java - public record ConnectionContext(CameleerAgentConfig config, CamelContext camelContext, - ExecutionCollector collector, List graphs, - CameleerEventNotifier eventNotifier, CamelMetricsBridge metricsBridge, - PrometheusEndpoint prometheusEndpoint) {} -``` - -Replace the `installLogForwarding` method: - -```java - private static LogForwarder installLogForwarding(CameleerAgentConfig config, - ChunkedExporter exporter, - CamelContext camelContext, - Map capabilities) { - if (!config.isLogForwardingEnabled()) { - return null; - } - - LogForwarder logForwarder = new LogForwarder(exporter); - - // Set the bridge handler so appenders can forward through it - LogEventBridge.handler.set(logForwarder::forward); - - // Try to register appender via LogAppenderRegistrar on app classloader - ClassLoader appClassLoader = camelContext.getClass().getClassLoader(); - String framework = registerAppenderViaReflection(appClassLoader); - - if (framework != null) { - capabilities.put("logForwarding", true); - LOG.info("Cameleer: Log forwarding started (framework={})", framework); - return logForwarder; - } - - LOG.warn("Cameleer: No supported logging framework found, log forwarding disabled"); - LogEventBridge.handler.set(null); - logForwarder.close(); - return null; - } - - private static String registerAppenderViaReflection(ClassLoader appClassLoader) { - try { - Class registrarClass = appClassLoader.loadClass("com.cameleer.appender.LogAppenderRegistrar"); - return (String) registrarClass.getMethod("install", ClassLoader.class) - .invoke(null, appClassLoader); - } catch (ClassNotFoundException e) { - LOG.warn("Cameleer: cameleer-log-appender JAR not found on app classpath"); - return null; - } catch (Exception e) { - LOG.warn("Cameleer: Failed to register log appender: {}", e.getMessage()); - return null; - } - } -``` - -Update the call to `installLogForwarding` in `connect()`: - -```java - // Install log forwarding - LogForwarder logForwarder = installLogForwarding(config, exporter, - ctx.camelContext(), capabilities); -``` - -Update the shutdown hooks call — remove `logForwardingSupport`: - -```java - ctx.eventNotifier().setShutdownHooks(sseClient, heartbeatManager, exporter, - ctx.metricsBridge(), serverConnection, logForwarder); -``` - -Remove the `LogForwardingSupport` import and the `LogForwarder` import from `com.cameleer.core.logging.LogForwarder` is already there. Add the `LogEventBridge` import: - -```java -import com.cameleer.core.logging.LogEventBridge; -``` - -- [ ] **Step 2: Update CameleerEventNotifier shutdown** - -In `CameleerEventNotifier.java`, update `setShutdownHooks` to remove `LogForwardingSupport`: - -```java - public void setShutdownHooks(SseClient sseClient, HeartbeatManager heartbeatManager, - Exporter httpExporter, CamelMetricsBridge metricsBridge, - ServerConnection serverConnection, LogForwarder logForwarder) { - this.sseClient = sseClient; - this.heartbeatManager = heartbeatManager; - this.httpExporter = httpExporter; - this.shutdownMetricsBridge = metricsBridge; - this.serverConnection = serverConnection; - this.logForwarder = logForwarder; - } -``` - -Remove the `logForwardingSupport` field and update `stopLogForwarding()`: - -```java - private void stopLogForwarding() { - // Clear bridge handler to stop appenders from forwarding - try { - com.cameleer.core.logging.LogEventBridge.handler.set(null); - } catch (Exception ignored) {} - if (logForwarder != null) { - try { logForwarder.close(); } catch (Exception e) { - LOG.debug("Cameleer: Error closing log forwarder", e); - } - } - } -``` - -Remove the `LogForwardingSupport` import and field. - -- [ ] **Step 3: Verify compilation** - -Run: `mvn compile -pl cameleer-core` -Expected: BUILD SUCCESS - -- [ ] **Step 4: Commit** - -```bash -git add cameleer-core/src/main/java/com/cameleer/core/connection/ServerSetup.java \ - cameleer-core/src/main/java/com/cameleer/core/notifier/CameleerEventNotifier.java -git commit -m "refactor: wire LogAppenderRegistrar and remove LogForwardingSupport from ServerSetup" -``` - ---- - -### Task 11: Update PostStartSetup and CameleerHookInstaller - -**Files:** -- Modify: `cameleer-core/src/main/java/com/cameleer/core/PostStartSetup.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/instrumentation/CameleerHookInstaller.java` - -- [ ] **Step 1: Remove LogForwardingSupport from PostStartSetup** - -In `PostStartSetup.java`, update the `Context` record to remove `logForwardingSupport`: - -```java - public record Context(CameleerAgentConfig config, CamelContext camelContext, - ExecutionCollector collector, Exporter initialExporter, - CameleerEventNotifier eventNotifier, - boolean skipJmxMetrics) {} -``` - -Update the `ServerSetup.ConnectionContext` construction in `run()`: - -```java - ServerSetup.ConnectionContext connCtx = new ServerSetup.ConnectionContext( - config, ctx.camelContext(), ctx.collector(), graphs, - ctx.eventNotifier(), metricsBridge, prometheusEndpoint); -``` - -Remove the `LogForwardingSupport` import. - -- [ ] **Step 2: Update CameleerHookInstaller** - -In `CameleerHookInstaller.java`, update `postInstall()`: - -Remove the `LogForwardingSupport` block (lines 99-105) and update the `PostStartSetup.Context`: - -```java - // Shared post-start orchestration (diagrams, metrics, server, startup report, health, OTel) - PostStartSetup.Context ctx = new PostStartSetup.Context( - config, camelContext, sharedCollector, sharedExporter, - eventNotifier, false); - PostStartSetup.Result result = PostStartSetup.run(ctx); -``` - -Remove the `LogForwardingInstaller` and `LogForwardingSupport` imports. - -- [ ] **Step 3: Add MDC enrichment call in CameleerHookInstaller** - -Add import to `CameleerHookInstaller.java`: - -```java -import com.cameleer.core.logging.MdcEnricher; -``` - -The MDC enrichment is called per-exchange in the EventNotifier. No changes needed in HookInstaller — the MdcEnricher will be wired in the collector hooks (Task 12). - -- [ ] **Step 4: Update CameleerLifecycle (Quarkus extension)** - -In `CameleerLifecycle.java`, update the `PostStartSetup.Context` in `onCamelContextStarted`: - -```java - PostStartSetup.Context ctx = new PostStartSetup.Context( - config, camelContext, collector, exporter, eventNotifier, isNativeImage); -``` - -Remove the `LogForwardingSupport` import if present. - -- [ ] **Step 5: Verify compilation** - -Run: `mvn compile -pl cameleer-core,cameleer-agent,cameleer-extension/runtime` -Expected: BUILD SUCCESS - -- [ ] **Step 6: Commit** - -```bash -git add cameleer-core/src/main/java/com/cameleer/core/PostStartSetup.java \ - cameleer-agent/src/main/java/com/cameleer/agent/instrumentation/CameleerHookInstaller.java \ - cameleer-extension/runtime/src/main/java/com/cameleer/extension/CameleerLifecycle.java -git commit -m "refactor: remove LogForwardingSupport from PostStartSetup and HookInstaller" -``` - ---- - -### Task 12: Add MDC enrichment to exchange lifecycle - -**Files:** -- Modify: `cameleer-core/src/main/java/com/cameleer/core/notifier/CameleerEventNotifier.java` - -- [ ] **Step 1: Add MDC enrichment in exchange events** - -In `CameleerEventNotifier.java`, add import: - -```java -import com.cameleer.core.logging.MdcEnricher; -``` - -In the `notify()` method, update the exchange event handling: - -```java - if (event instanceof CamelEvent.ExchangeCreatedEvent created) { - org.apache.camel.Exchange exchange = created.getExchange(); - if (config != null) { - String correlationId = exchange.getIn().getHeader("X-Cameleer-CorrelationId", String.class); - MdcEnricher.setExchangeContext(config.getApplicationId(), config.getInstanceId(), correlationId); - } - collector.onExchangeCreated(exchange); - } else if (event instanceof CamelEvent.ExchangeCompletedEvent completed) { - collector.onExchangeCompleted(completed.getExchange()); - MdcEnricher.clearExchangeContext(); - } else if (event instanceof CamelEvent.ExchangeFailedEvent failed) { - collector.onExchangeFailed(failed.getExchange()); - MdcEnricher.clearExchangeContext(); -``` - -- [ ] **Step 2: Verify compilation** - -Run: `mvn compile -pl cameleer-core` -Expected: BUILD SUCCESS - -- [ ] **Step 3: Commit** - -```bash -git add cameleer-core/src/main/java/com/cameleer/core/notifier/CameleerEventNotifier.java -git commit -m "feat: inject Cameleer MDC context during exchange processing" -``` - ---- - -### Task 13: Remove old log forwarding files - -**Files:** -- Delete: `cameleer-agent/src/main/java/com/cameleer/agent/logging/LogForwardingInstaller.java` -- Delete: `cameleer-agent/src/main/java/com/cameleer/agent/logging/CameleerLogbackAppender.java` -- Delete: `cameleer-agent/src/main/java/com/cameleer/agent/logging/CameleerLog4j2Appender.java` -- Delete: `cameleer-core/src/main/java/com/cameleer/core/logging/LogEventConverter.java` -- Delete: `cameleer-core/src/main/java/com/cameleer/core/logging/CameleerJulHandler.java` -- Delete: `cameleer-core/src/main/java/com/cameleer/core/logging/LogForwardingSupport.java` -- Delete: `cameleer-common/src/main/java/com/cameleer/common/model/LogBatch.java` -- Delete: related test files - -- [ ] **Step 1: Delete old agent logging files** - -```bash -git rm cameleer-agent/src/main/java/com/cameleer/agent/logging/LogForwardingInstaller.java -git rm cameleer-agent/src/main/java/com/cameleer/agent/logging/CameleerLogbackAppender.java -git rm cameleer-agent/src/main/java/com/cameleer/agent/logging/CameleerLog4j2Appender.java -``` - -- [ ] **Step 2: Delete old core logging files** - -```bash -git rm cameleer-core/src/main/java/com/cameleer/core/logging/LogEventConverter.java -git rm cameleer-core/src/main/java/com/cameleer/core/logging/CameleerJulHandler.java -git rm cameleer-core/src/main/java/com/cameleer/core/logging/LogForwardingSupport.java -``` - -- [ ] **Step 3: Delete LogBatch model** - -```bash -git rm cameleer-common/src/main/java/com/cameleer/common/model/LogBatch.java -``` - -- [ ] **Step 4: Delete old test files for removed classes** - -```bash -git rm -f cameleer-agent/src/test/java/com/cameleer/agent/logging/CameleerLogbackAppenderTest.java -git rm -f cameleer-agent/src/test/java/com/cameleer/agent/logging/LogForwardingInstallerTest.java -``` - -- [ ] **Step 5: Remove any remaining references** - -Search for and remove any remaining imports of deleted classes in any file that wasn't already updated. Check: -- `ServerSetup.java` — remove `LogForwardingSupport` import (should be done in Task 10) -- `PostStartSetup.java` — remove `LogForwardingSupport` import (should be done in Task 11) -- `CameleerHookInstaller.java` — remove `LogForwardingInstaller` import (should be done in Task 11) - -- [ ] **Step 6: Verify full build compiles** - -Run: `mvn compile` -Expected: BUILD SUCCESS across all modules - -- [ ] **Step 7: Commit** - -```bash -git commit -m "refactor: remove old ByteBuddy log forwarding files (12 files -> 5)" -``` - ---- - -### Task 14: Run full test suite and fix breakages - -**Files:** -- Various test files that may reference removed classes - -- [ ] **Step 1: Run full test suite** - -Run: `mvn clean verify` -Expected: Identify any failing tests referencing removed classes (LogBatch, LogEventConverter, etc.) - -- [ ] **Step 2: Fix any compilation/test failures** - -Common fixes: -- Tests importing `LogBatch` → remove or update -- Tests importing `LogEventConverter` → remove -- Tests importing `LogForwardingSupport` → remove -- Tests referencing `LogForwarder(ServerConnection)` constructor → update to `LogForwarder(Exporter)` - -- [ ] **Step 3: Run full test suite again** - -Run: `mvn clean verify` -Expected: All tests PASS - -- [ ] **Step 4: Commit any fixes** - -```bash -git add -u -git commit -m "fix: update tests for log forwarding v2 refactor" -``` - ---- - -### Task 15: Update LogEventBridge javadoc - -**Files:** -- Modify: `cameleer-core/src/main/java/com/cameleer/core/logging/LogEventBridge.java` - -- [ ] **Step 1: Update javadoc to document Object[] contract** - -```java -package com.cameleer.core.logging; - -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; - -/** - * Cross-classloader bridge for log event forwarding. - * - *

This class lives on the system classloader (in the agent shaded JAR). - * The appender (on the app CL, from cameleer-log-appender.jar) accesses - * the handler via reflection. The agent sets the handler to LogForwarder::forward. - * - *

Data format: Object[] with fields: - * [0] long timestampEpochMs, [1] String level, [2] String loggerName, - * [3] String message, [4] String threadName, [5] String stackTrace (nullable), - * [6] Map<String,String> mdc (nullable), [7] String source ("app"/"agent") - */ -public class LogEventBridge { - - public static final AtomicReference> handler = new AtomicReference<>(); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add cameleer-core/src/main/java/com/cameleer/core/logging/LogEventBridge.java -git commit -m "docs: update LogEventBridge javadoc for Object[] contract" -``` - ---- - -### Task 16: Update sample app Dockerfiles and K8s manifests - -**Files:** -- Modify: `Dockerfile` (if appender JAR needs to be included) -- Modify: `deploy/*.yaml` (if env vars need updating) -- Modify: `CLAUDE.md` and `PROTOCOL.md` (doc updates) - -- [ ] **Step 1: Update Dockerfile to include appender JAR** - -In the Dockerfile, after the agent JAR COPY, add: - -```dockerfile -COPY cameleer-log-appender/target/cameleer-log-appender-1.0-SNAPSHOT.jar /app/cameleer-log-appender.jar -``` - -Update the ENTRYPOINT/CMD to include loader.path for Spring Boot apps: - -```dockerfile -ENTRYPOINT ["java", \ - "-javaagent:/app/cameleer-agent.jar", \ - "-Dloader.path=/app/cameleer-log-appender.jar", \ - "-jar", "/app/app.jar"] -``` - -- [ ] **Step 2: Update K8s manifests for Quarkus apps** - -For Quarkus deployments, the appender JAR needs to be in `quarkus-app/lib/main/`. Update the Quarkus Dockerfile accordingly. - -- [ ] **Step 3: Update CLAUDE.md** - -Add the new module to the Modules section. Update the Run section with the new command format including `-Dloader.path`. Update Working Features to reflect the simplified log forwarding. - -- [ ] **Step 4: Commit** - -```bash -git add Dockerfile deploy/ CLAUDE.md -git commit -m "docs: update deployment configs for log forwarding v2" -``` - ---- - -### Task 17: Agent log capture via custom SLF4J backend (optional, can be follow-up) - -This task replaces `slf4j-simple` with a custom SLF4J backend in the agent's shaded JAR that dual-writes to `LogForwarder` (tagged `source: "agent"`) and stderr. - -**Files:** -- Create: `cameleer-agent/src/main/java/com/cameleer/agent/logging/CameleerLoggerFactory.java` -- Create: `cameleer-agent/src/main/java/com/cameleer/agent/logging/CameleerLogger.java` -- Create: `cameleer-agent/src/main/java/com/cameleer/agent/logging/CameleerServiceProvider.java` -- Create: `cameleer-agent/src/main/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider` -- Modify: `cameleer-agent/pom.xml` (remove `slf4j-simple` dependency) - -- [ ] **Step 1: Create CameleerLogger** - -Create `cameleer-agent/src/main/java/com/cameleer/agent/logging/CameleerLogger.java`: - -```java -package com.cameleer.agent.logging; - -import com.cameleer.common.model.LogEntry; -import com.cameleer.core.logging.LogForwarder; -import org.slf4j.Marker; -import org.slf4j.event.Level; -import org.slf4j.helpers.AbstractLogger; - -import java.io.PrintWriter; -import java.io.StringWriter; -import java.time.Instant; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.concurrent.atomic.AtomicReference; - -/** - * SLF4J logger that dual-writes: - * 1. To LogForwarder (tagged source: "agent") for server forwarding - * 2. To stderr for local visibility (toggleable) - */ -public class CameleerLogger extends AbstractLogger { - - private static final DateTimeFormatter TIMESTAMP_FMT = - DateTimeFormatter.ofPattern("HH:mm:ss.SSS").withZone(ZoneId.systemDefault()); - - static final AtomicReference FORWARDER = new AtomicReference<>(); - static volatile boolean stderrEnabled = true; - static volatile Level threshold = Level.INFO; - - private final String name; - private final String shortName; - - CameleerLogger(String name) { - this.name = name; - int lastDot = name.lastIndexOf('.'); - this.shortName = lastDot >= 0 ? name.substring(lastDot + 1) : name; - } - - @Override - protected String getFullyQualifiedCallerName() { return null; } - - @Override - protected void handleNormalizedLoggingCall(Level level, Marker marker, String msg, - Object[] args, Throwable t) { - if (!isLevelEnabled(level)) return; - - String formatted = msg; - if (args != null) { - formatted = org.slf4j.helpers.MessageFormatter.arrayFormat(msg, args).getMessage(); - } - - String stackTrace = null; - if (t != null) { - StringWriter sw = new StringWriter(); - t.printStackTrace(new PrintWriter(sw)); - stackTrace = sw.toString(); - } - - // Forward to server - LogForwarder fwd = FORWARDER.get(); - if (fwd != null) { - LogEntry entry = new LogEntry(Instant.now(), level.name(), name, formatted, - Thread.currentThread().getName(), stackTrace, null); - entry.setSource("agent"); - fwd.forwardDirect(entry); - } - - // Write to stderr - if (stderrEnabled) { - String ts = TIMESTAMP_FMT.format(Instant.now()); - System.err.printf("[%s] %s %s - %s%n", ts, level.name(), shortName, formatted); - if (t != null) { - t.printStackTrace(System.err); - } - } - } - - private boolean isLevelEnabled(Level level) { - return level.toInt() >= threshold.toInt(); - } - - @Override public boolean isTraceEnabled() { return isLevelEnabled(Level.TRACE); } - @Override public boolean isTraceEnabled(Marker m) { return isTraceEnabled(); } - @Override public boolean isDebugEnabled() { return isLevelEnabled(Level.DEBUG); } - @Override public boolean isDebugEnabled(Marker m) { return isDebugEnabled(); } - @Override public boolean isInfoEnabled() { return isLevelEnabled(Level.INFO); } - @Override public boolean isInfoEnabled(Marker m) { return isInfoEnabled(); } - @Override public boolean isWarnEnabled() { return isLevelEnabled(Level.WARN); } - @Override public boolean isWarnEnabled(Marker m) { return isWarnEnabled(); } - @Override public boolean isErrorEnabled() { return isLevelEnabled(Level.ERROR); } - @Override public boolean isErrorEnabled(Marker m) { return isErrorEnabled(); } -} -``` - -- [ ] **Step 2: Create CameleerLoggerFactory** - -Create `cameleer-agent/src/main/java/com/cameleer/agent/logging/CameleerLoggerFactory.java`: - -```java -package com.cameleer.agent.logging; - -import org.slf4j.ILoggerFactory; -import org.slf4j.Logger; - -import java.util.concurrent.ConcurrentHashMap; - -public class CameleerLoggerFactory implements ILoggerFactory { - - private final ConcurrentHashMap loggers = new ConcurrentHashMap<>(); - - @Override - public Logger getLogger(String name) { - return loggers.computeIfAbsent(name, CameleerLogger::new); - } -} -``` - -- [ ] **Step 3: Create CameleerServiceProvider** - -Create `cameleer-agent/src/main/java/com/cameleer/agent/logging/CameleerServiceProvider.java`: - -```java -package com.cameleer.agent.logging; - -import org.slf4j.ILoggerFactory; -import org.slf4j.IMarkerFactory; -import org.slf4j.helpers.BasicMDCAdapter; -import org.slf4j.helpers.BasicMarkerFactory; -import org.slf4j.spi.MDCAdapter; -import org.slf4j.spi.SLF4JServiceProvider; - -public class CameleerServiceProvider implements SLF4JServiceProvider { - - private ILoggerFactory loggerFactory; - private IMarkerFactory markerFactory; - private MDCAdapter mdcAdapter; - - @Override - public ILoggerFactory getLoggerFactory() { return loggerFactory; } - - @Override - public IMarkerFactory getMarkerFactory() { return markerFactory; } - - @Override - public MDCAdapter getMDCAdapter() { return mdcAdapter; } - - @Override - public String getRequestedApiVersion() { return "2.0.99"; } - - @Override - public void initialize() { - loggerFactory = new CameleerLoggerFactory(); - markerFactory = new BasicMarkerFactory(); - mdcAdapter = new BasicMDCAdapter(); - } -} -``` - -- [ ] **Step 4: Create SPI registration file** - -Create `cameleer-agent/src/main/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider`: - -``` -com.cameleer.agent.logging.CameleerServiceProvider -``` - -- [ ] **Step 5: Update POMs to avoid SLF4J provider conflict** - -In `cameleer-agent/pom.xml`, change `slf4j-simple` scope to `test` (our custom provider replaces it): - -```xml - - org.slf4j - slf4j-simple - test - -``` - -In `cameleer-core/pom.xml`, also change `slf4j-simple` to `test` scope (it's only used for test logging): - -```xml - - org.slf4j - slf4j-simple - test - -``` - -This prevents `slf4j-simple` from being included in the shaded agent JAR alongside our custom `CameleerServiceProvider`, which would cause an SLF4J "multiple providers" conflict. - -- [ ] **Step 6: Wire LogForwarder into CameleerLogger** - -In `CameleerHookInstaller.java`, after `PostStartSetup.Result result = ...`: - -```java - // Wire agent log forwarder for server-side visibility - if (logForwarder != null) { - CameleerLogger.FORWARDER.set(logForwarder); - } -``` - -Add import: - -```java -import com.cameleer.agent.logging.CameleerLogger; -``` - -- [ ] **Step 7: Verify agent builds** - -Run: `mvn clean compile -pl cameleer-agent` -Expected: BUILD SUCCESS - -- [ ] **Step 8: Commit** - -```bash -git add cameleer-agent/src/main/java/com/cameleer/agent/logging/CameleerLogger.java \ - cameleer-agent/src/main/java/com/cameleer/agent/logging/CameleerLoggerFactory.java \ - cameleer-agent/src/main/java/com/cameleer/agent/logging/CameleerServiceProvider.java \ - cameleer-agent/src/main/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider \ - cameleer-agent/pom.xml \ - cameleer-agent/src/main/java/com/cameleer/agent/instrumentation/CameleerHookInstaller.java -git commit -m "feat: add custom SLF4J backend for agent log capture with stderr dual-write" -``` - ---- - -### Task 18: Final verification - -- [ ] **Step 1: Run full build with tests** - -Run: `mvn clean verify` -Expected: All tests PASS, all modules compile - -- [ ] **Step 2: Verify shaded agent JAR** - -Run: `mvn clean package -DskipTests -pl cameleer-agent` - -Verify the shaded JAR contains the new SPI file: - -```bash -jar tf cameleer-agent/target/cameleer-agent-1.0-SNAPSHOT-shaded.jar | grep -i service -``` - -Expected: Should contain the relocated SLF4J service provider - -- [ ] **Step 3: Verify appender JAR is clean** - -```bash -jar tf cameleer-log-appender/target/cameleer-log-appender-1.0-SNAPSHOT.jar -``` - -Expected: Only `com/cameleer/appender/` classes, no shaded dependencies - -- [ ] **Step 4: Commit any remaining fixes** - -```bash -git add -u -git commit -m "chore: final verification fixes for log forwarding v2" -``` diff --git a/docs/superpowers/plans/2026-04-12-early-log-capture.md b/docs/superpowers/plans/2026-04-12-early-log-capture.md deleted file mode 100644 index 182628f..0000000 --- a/docs/superpowers/plans/2026-04-12-early-log-capture.md +++ /dev/null @@ -1,1042 +0,0 @@ -# Early Log Capture Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Capture application logs from Spring's `ApplicationContext.refresh()` onward (instead of after server connection) by buffering early entries and flushing them when the server connects. - -**Architecture:** Three layers — BridgeAccess buffers Object[] entries before the handler is set; LogForwarder buffers LogEntry objects before an exporter is set; a new ByteBuddy SpringContextTransformer registers the appender at Spring context refresh time. For Quarkus extension mode, the appender registers in CameleerConfigAdapter's @PostConstruct. - -**Tech Stack:** Java 17, ByteBuddy, SLF4J 2.x, Logback 1.5, Log4j2 2.24, JUL, Quarkus CDI - ---- - -### Task 1: BridgeAccess Early Buffer - -Add buffering to `BridgeAccess` so entries are captured before the bridge handler is set, then drained when the handler becomes available. - -**Files:** -- Modify: `cameleer-log-appender/src/main/java/com/cameleer/appender/BridgeAccess.java` -- Test: `cameleer-log-appender/src/test/java/com/cameleer/appender/BridgeAccessTest.java` - -- [ ] **Step 1: Write the test** - -Create `cameleer-log-appender/src/test/java/com/cameleer/appender/BridgeAccessTest.java`: - -```java -package com.cameleer.appender; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; - -import java.util.ArrayList; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - -class BridgeAccessTest { - - @AfterEach - void reset() { - BridgeAccess.resetForTest(); - } - - @Test - void forward_buffersWhenNoHandler() { - Object[] data = {1L, "INFO", "com.example.App", "hello", "main", null, null, "app"}; - BridgeAccess.forward(data); - - assertEquals(1, BridgeAccess.getBufferSizeForTest()); - } - - @Test - void forward_drainsBufferWhenHandlerSet() { - List captured = new ArrayList<>(); - Object[] early1 = {1L, "INFO", "com.example.A", "msg1", "main", null, null, "app"}; - Object[] early2 = {2L, "WARN", "com.example.B", "msg2", "main", null, null, "app"}; - - // Buffer two entries before handler exists - BridgeAccess.forward(early1); - BridgeAccess.forward(early2); - assertEquals(2, BridgeAccess.getBufferSizeForTest()); - - // Set handler — should drain buffer - BridgeAccess.setHandler(captured::add); - - // Trigger drain by forwarding one more entry - Object[] live = {3L, "DEBUG", "com.example.C", "msg3", "main", null, null, "app"}; - BridgeAccess.forward(live); - - // All 3 entries should arrive: 2 buffered + 1 live - assertEquals(3, captured.size()); - assertEquals("msg1", ((Object[]) captured.get(0))[3]); - assertEquals("msg2", ((Object[]) captured.get(1))[3]); - assertEquals("msg3", ((Object[]) captured.get(2))[3]); - - // Buffer should be empty now - assertEquals(0, BridgeAccess.getBufferSizeForTest()); - } - - @Test - void forward_liveModeAfterDrain() { - List captured = new ArrayList<>(); - BridgeAccess.setHandler(captured::add); - - Object[] data = {1L, "INFO", "com.example.App", "live", "main", null, null, "app"}; - BridgeAccess.forward(data); - - assertEquals(1, captured.size()); - assertEquals(0, BridgeAccess.getBufferSizeForTest()); - } - - @Test - void forward_bufferCapped() { - for (int i = 0; i < 5100; i++) { - BridgeAccess.forward(new Object[]{(long) i, "INFO", "x", "m", "t", null, null, "app"}); - } - assertTrue(BridgeAccess.getBufferSizeForTest() <= 5000, - "Buffer should cap at 5000 entries"); - } - - @Test - void setHandler_null_doesNotDrain() { - Object[] data = {1L, "INFO", "com.example.App", "buffered", "main", null, null, "app"}; - BridgeAccess.forward(data); - - BridgeAccess.setHandler(null); - - // Buffer should still contain the entry - assertEquals(1, BridgeAccess.getBufferSizeForTest()); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `mvn test -pl cameleer-log-appender -Dtest=BridgeAccessTest -B` -Expected: FAIL — `forward()`, `setHandler()`, `resetForTest()`, `getBufferSizeForTest()` don't exist yet. - -- [ ] **Step 3: Implement BridgeAccess with buffer** - -Replace `cameleer-log-appender/src/main/java/com/cameleer/appender/BridgeAccess.java` with: - -```java -package com.cameleer.appender; - -import java.lang.reflect.Field; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; - -/** - * Resolves the LogEventBridge handler across classloader boundaries and buffers - * log entries received before the handler is available. - * - *

Two resolution strategies for reading the handler, tried in order: - *

    - *
  1. Own classloader ({@code Class.forName}) — extension mode
  2. - *
  3. System classloader — agent mode
  4. - *
- * - *

Early buffer: entries forwarded before the handler is set are stored in a bounded - * queue (max 5000). When {@link #setHandler} is called, the buffer is drained through - * the handler before switching to live mode. - */ -final class BridgeAccess { - - private static final String BRIDGE_CLASS = "com.cameleer.core.logging.LogEventBridge"; - private static final int MAX_EARLY_BUFFER = 5000; - - private static volatile Field cachedField; - private static volatile Consumer directHandler; - private static volatile boolean bufferDrained = false; - private static final ConcurrentLinkedQueue earlyBuffer = new ConcurrentLinkedQueue<>(); - - private BridgeAccess() {} - - /** - * Forwards a log entry. If a handler is set, forwards directly (draining any - * buffered entries first). If no handler, buffers the entry for later drain. - */ - static void forward(Object[] data) { - Consumer h = directHandler; - if (h == null) { - h = resolveHandler(); - } - - if (h != null) { - if (!bufferDrained) { - drainBuffer(h); - } - h.accept(data); - } else { - if (earlyBuffer.size() < MAX_EARLY_BUFFER) { - earlyBuffer.add(data); - } - } - } - - /** - * Sets the handler directly. Called by ServerSetup.setBridgeHandler() in extension mode, - * or via the LogEventBridge AtomicReference in agent mode. - * When a non-null handler is set, the early buffer is drained on the next forward() call. - */ - static void setHandler(Consumer handler) { - directHandler = handler; - if (handler != null) { - // Reset drain flag so next forward() triggers drain - bufferDrained = false; - } - } - - /** - * Returns the current bridge handler, or null if not set or not resolvable. - * Checks both the direct handler and the LogEventBridge on system/own classloader. - */ - @SuppressWarnings("unchecked") - static Consumer getHandler() { - Consumer h = directHandler; - if (h != null) return h; - return resolveHandler(); - } - - @SuppressWarnings("unchecked") - private static Consumer resolveHandler() { - try { - Field f = cachedField; - if (f == null) { - f = resolveField(); - cachedField = f; - } - AtomicReference> ref = - (AtomicReference>) f.get(null); - return ref.get(); - } catch (Exception e) { - return null; - } - } - - private static Field resolveField() throws Exception { - try { - Class bridge = Class.forName(BRIDGE_CLASS); - return bridge.getField("handler"); - } catch (ClassNotFoundException ignored) { - } - Class bridge = ClassLoader.getSystemClassLoader().loadClass(BRIDGE_CLASS); - return bridge.getField("handler"); - } - - private static synchronized void drainBuffer(Consumer h) { - if (bufferDrained) return; - Object[] entry; - while ((entry = earlyBuffer.poll()) != null) { - h.accept(entry); - } - bufferDrained = true; - } - - // -- Test support -- - - static void resetForTest() { - directHandler = null; - bufferDrained = false; - cachedField = null; - earlyBuffer.clear(); - } - - static int getBufferSizeForTest() { - return earlyBuffer.size(); - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `mvn test -pl cameleer-log-appender -Dtest=BridgeAccessTest -B` -Expected: all 5 tests PASS - -- [ ] **Step 5: Update all three appenders to use `BridgeAccess.forward()`** - -In `CameleerLogbackAppender.java`, change `forwardViaBridge` to: - -```java - private void forwardViaBridge(Object[] data) { - BridgeAccess.forward(data); - } -``` - -Remove the diagnostic `forwardedCount` and `droppedNullHandler` fields and their imports. - -In `CameleerLog4j2Appender.java`, change `forwardViaBridge` to: - -```java - private void forwardViaBridge(Object[] data) { - BridgeAccess.forward(data); - } -``` - -In `CameleerJulHandler.java`, change `forwardViaBridge` to: - -```java - private void forwardViaBridge(Object[] data) { - BridgeAccess.forward(data); - } -``` - -- [ ] **Step 6: Run all log-appender tests** - -Run: `mvn test -pl cameleer-log-appender -B` -Expected: all tests PASS (BridgeAccessTest + existing appender tests) - -- [ ] **Step 7: Commit** - -```bash -git add cameleer-log-appender/ -git commit -m "feat: add early buffer to BridgeAccess for pre-handler log capture" -``` - ---- - -### Task 2: LogForwarder Deferred Exporter - -Make `LogForwarder` work without an exporter at construction time. The exporter is set later when the server connection is established. - -**Files:** -- Modify: `cameleer-core/src/main/java/com/cameleer/core/logging/LogForwarder.java` -- Modify: `cameleer-core/src/test/java/com/cameleer/core/logging/LogForwarderTest.java` - -- [ ] **Step 1: Write the test for deferred exporter** - -Add to `LogForwarderTest.java`: - -```java -@Test -void deferredExporter_buffersUntilExporterSet() throws Exception { - forwarder = new LogForwarder(); // no-arg — no exporter - - forwarder.forward(objectArray("INFO", "com.example.App", "early msg")); - forwarder.forward(objectArray("WARN", "com.example.App", "early warn")); - - // Wait for scheduler to attempt flush — should not throw or lose entries - Thread.sleep(1500); - - // Now set the exporter - Exporter late = mock(Exporter.class); - forwarder.setExporter(late); - - // Wait for scheduler to flush - Thread.sleep(1500); - - @SuppressWarnings("unchecked") - ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); - verify(late, atLeastOnce()).exportLogs(captor.capture()); - List allEntries = captor.getAllValues().stream() - .flatMap(List::stream).toList(); - assertTrue(allEntries.size() >= 2, "Both buffered entries should be flushed"); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `mvn test -pl cameleer-core -Dtest=LogForwarderTest#deferredExporter_buffersUntilExporterSet -B` -Expected: FAIL — no-arg constructor doesn't exist. - -- [ ] **Step 3: Implement deferred exporter support** - -Replace `LogForwarder.java` with: - -```java -package com.cameleer.core.logging; - -import com.cameleer.common.model.LogEntry; -import com.cameleer.core.export.Exporter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; - -/** - * Receives log data (as Object[] from cross-classloader bridge or direct LogEntry), - * buffers, and delegates to Exporter for batched HTTP transport. - * - *

Supports deferred exporter: create with no-arg constructor, entries buffer in - * the queue, and flushing starts when {@link #setExporter} is called. - */ -public class LogForwarder { - - private static final Logger LOG = LoggerFactory.getLogger(LogForwarder.class); - private static final int MAX_QUEUE_SIZE = 1000; - private static final int BATCH_SIZE = 50; - - private final AtomicReference exporter = new AtomicReference<>(); - private final ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue<>(); - private final ScheduledExecutorService scheduler; - private final AtomicLong droppedLogs = new AtomicLong(0); - private volatile long lastDropWarningMs = 0; - - /** - * Creates a LogForwarder with no exporter. Entries buffer in the queue - * until {@link #setExporter} is called. - */ - public LogForwarder() { - this(null); - } - - /** - * Creates a LogForwarder with the given exporter. If null, entries buffer - * until {@link #setExporter} is called. - */ - public LogForwarder(Exporter exporter) { - this.exporter.set(exporter); - this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> { - Thread t = new Thread(r, "cameleer-log-forwarder"); - t.setDaemon(true); - return t; - }); - this.scheduler.scheduleAtFixedRate(this::flush, 1, 1, TimeUnit.SECONDS); - } - - /** - * Sets or replaces the exporter. Buffered entries will be flushed on the - * next scheduler tick. - */ - public void setExporter(Exporter exporter) { - this.exporter.set(exporter); - } - - /** - * Called from the cross-classloader bridge. Data is an Object[] with fields: - * [0] long timestampEpochMs, [1] String level, [2] String loggerName, - * [3] String message, [4] String threadName, [5] String stackTrace (nullable), - * [6] Map mdc (nullable), [7] String source ("app"/"agent") - */ - @SuppressWarnings("unchecked") - public void forward(Object data) { - if (!(data instanceof Object[] arr) || arr.length < 7) return; - - LogEntry entry = new LogEntry( - Instant.ofEpochMilli((long) arr[0]), - (String) arr[1], - (String) arr[2], - (String) arr[3], - (String) arr[4], - (String) arr[5], - (Map) arr[6] - ); - if (arr.length > 7 && arr[7] != null) { - entry.setSource((String) arr[7]); - } - - enqueue(entry); - } - - /** - * Direct forwarding for agent-internal logs (already on system CL). - */ - public void forwardDirect(LogEntry entry) { - enqueue(entry); - } - - private void enqueue(LogEntry entry) { - if (queue.size() < MAX_QUEUE_SIZE) { - queue.add(entry); - } else { - long dropped = droppedLogs.incrementAndGet(); - long now = System.currentTimeMillis(); - if (now - lastDropWarningMs > 10_000) { - lastDropWarningMs = now; - LOG.warn("Cameleer: Log queue full, {} total dropped", dropped); - } - } - } - - public void close() { - scheduler.shutdown(); - try { - flush(); - scheduler.awaitTermination(5, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - private void flush() { - Exporter exp = exporter.get(); - if (exp == null || queue.isEmpty()) return; - - List batch = new ArrayList<>(BATCH_SIZE); - for (int i = 0; i < BATCH_SIZE; i++) { - LogEntry entry = queue.poll(); - if (entry == null) break; - batch.add(entry); - } - - if (!batch.isEmpty()) { - exp.exportLogs(batch); - } - } -} -``` - -- [ ] **Step 4: Run all LogForwarder tests** - -Run: `mvn test -pl cameleer-core -Dtest=LogForwarderTest -B` -Expected: all tests PASS (existing + new deferred test) - -- [ ] **Step 5: Commit** - -```bash -git add cameleer-core/src/ -git commit -m "feat: support deferred exporter in LogForwarder for early log capture" -``` - ---- - -### Task 3: Refactor ServerSetup.installLogForwarding() - -Change `installLogForwarding()` to accept an existing LogForwarder (from early registration) and just activate its exporter, or create a new one as fallback. - -**Files:** -- Modify: `cameleer-core/src/main/java/com/cameleer/core/connection/ServerSetup.java` - -- [ ] **Step 1: Refactor installLogForwarding** - -Change the method signature and body. The method now accepts an optional pre-existing `LogForwarder`: - -```java -private static LogForwarder installLogForwarding(CameleerAgentConfig config, - ChunkedExporter exporter, - CamelContext camelContext, - Map capabilities, - LogForwarder existingForwarder) { - if (!config.isLogForwardingEnabled()) { - return null; - } - - if (existingForwarder != null) { - // Early registration already happened — just activate the exporter - existingForwarder.setExporter(exporter); - capabilities.put("logForwarding", true); - LOG.info("Cameleer: Log forwarding activated (early-registered, exporter connected)"); - return existingForwarder; - } - - // Fallback: no early registration — create LogForwarder and register appender now - LogForwarder logForwarder = new LogForwarder(exporter); - setBridgeHandler(logForwarder::forward); - - ClassLoader appClassLoader = camelContext.getClass().getClassLoader(); - String framework = registerAppenderViaReflection(appClassLoader); - - if (framework != null) { - capabilities.put("logForwarding", true); - LOG.info("Cameleer: Log forwarding started (framework={})", framework); - return logForwarder; - } - LOG.warn("Cameleer: No supported logging framework found, log forwarding disabled"); - setBridgeHandler(null); - logForwarder.close(); - return null; -} -``` - -- [ ] **Step 2: Update the call site in connect()** - -Update the `ConnectionContext` record to include an optional `LogForwarder`: - -```java -public record ConnectionContext(CameleerAgentConfig config, CamelContext camelContext, - ExecutionCollector collector, List graphs, - CameleerEventNotifier eventNotifier, CamelMetricsBridge metricsBridge, - PrometheusEndpoint prometheusEndpoint, LogForwarder earlyLogForwarder) {} -``` - -Update the `installLogForwarding` call at line 136: - -```java -LogForwarder logForwarder = installLogForwarding(config, exporter, - ctx.camelContext(), capabilities, ctx.earlyLogForwarder()); -``` - -- [ ] **Step 3: Update PostStartSetup.Context to carry earlyLogForwarder** - -In `PostStartSetup.java`, update the `Context` record: - -```java -public record Context(CameleerAgentConfig config, CamelContext camelContext, - ExecutionCollector collector, Exporter initialExporter, - CameleerEventNotifier eventNotifier, - boolean skipJmxMetrics, LogForwarder earlyLogForwarder) {} -``` - -And pass it through to `ServerSetup.ConnectionContext`: - -```java -ServerSetup.ConnectionContext connCtx = new ServerSetup.ConnectionContext( - config, ctx.camelContext(), ctx.collector(), graphs, - ctx.eventNotifier(), metricsBridge, prometheusEndpoint, ctx.earlyLogForwarder()); -``` - -- [ ] **Step 4: Update all callers of PostStartSetup.Context** - -In `CameleerHookInstaller.postInstall()`: - -```java -PostStartSetup.Context ctx = new PostStartSetup.Context( - config, camelContext, sharedCollector, sharedExporter, - eventNotifier, false, logForwarder); -``` - -In `CameleerLifecycle.onCamelContextStarted()`: - -```java -PostStartSetup.Context ctx = new PostStartSetup.Context( - config, camelContext, collector, exporter, eventNotifier, isNativeImage, earlyLogForwarder); -``` - -(The `earlyLogForwarder` field will be set in Tasks 4 and 5.) - -- [ ] **Step 5: Build to verify compilation** - -Run: `mvn clean compile -B -pl cameleer-common,cameleer-core,cameleer-agent -am` -Expected: BUILD SUCCESS (pass null for earlyLogForwarder temporarily) - -- [ ] **Step 6: Commit** - -```bash -git add cameleer-core/src/ cameleer-agent/src/ -git commit -m "refactor: ServerSetup accepts existing LogForwarder for early registration" -``` - ---- - -### Task 4: Early Appender Registration — Agent Mode (SpringContextTransformer) - -Add a ByteBuddy transformer that registers the log appender when Spring's `AbstractApplicationContext.refresh()` is entered. - -**Files:** -- Create: `cameleer-agent/src/main/java/com/cameleer/agent/instrumentation/SpringContextTransformer.java` -- Create: `cameleer-agent/src/main/java/com/cameleer/agent/instrumentation/SpringContextAdvice.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/CameleerAgent.java` -- Modify: `cameleer-agent/src/main/java/com/cameleer/agent/instrumentation/CameleerHookInstaller.java` - -- [ ] **Step 1: Create SpringContextTransformer** - -Create `cameleer-agent/src/main/java/com/cameleer/agent/instrumentation/SpringContextTransformer.java`: - -```java -package com.cameleer.agent.instrumentation; - -import net.bytebuddy.agent.builder.AgentBuilder; -import net.bytebuddy.asm.Advice; -import net.bytebuddy.description.type.TypeDescription; -import net.bytebuddy.dynamic.DynamicType; -import net.bytebuddy.matcher.ElementMatchers; -import net.bytebuddy.utility.JavaModule; - -import java.security.ProtectionDomain; - -/** - * ByteBuddy transformer that instruments Spring's AbstractApplicationContext.refresh() - * to register the Cameleer log appender as early as possible in the Spring lifecycle. - * - *

At refresh() entry, Logback/Log4j2 is already initialized by Spring Boot. - * The appender starts buffering log entries; they are flushed when the server - * connection is established later. - * - *

For non-Spring apps, this transformer never matches — the appender is - * registered in preInstall() instead. - */ -public class SpringContextTransformer implements AgentBuilder.Transformer { - - @Override - public DynamicType.Builder transform(DynamicType.Builder builder, - TypeDescription typeDescription, - ClassLoader classLoader, - JavaModule module, - ProtectionDomain protectionDomain) { - return builder.visit( - Advice.to(SpringContextAdvice.class) - .on(ElementMatchers.named("refresh") - .and(ElementMatchers.takesNoArguments())) - ); - } -} -``` - -- [ ] **Step 2: Create SpringContextAdvice** - -Create `cameleer-agent/src/main/java/com/cameleer/agent/instrumentation/SpringContextAdvice.java`: - -```java -package com.cameleer.agent.instrumentation; - -import net.bytebuddy.asm.Advice; - -/** - * ByteBuddy advice that registers the Cameleer log appender on entry to - * {@code AbstractApplicationContext.refresh()}. - * - *

At this point in the Spring Boot lifecycle, the logging framework (Logback) - * is already fully initialized. The appender starts capturing all application - * logs — bean creation, autoconfiguration, Tomcat init, etc. - * - *

A {@code LogForwarder} is created without an exporter (deferred mode). - * Entries buffer in its queue until {@code ServerSetup.connect()} provides - * a {@code ChunkedExporter}. - * - *

All field access uses reflection to avoid classloader coupling — - * this advice class is loaded by the system classloader, but the target - * classes are on the app classloader. - */ -public class SpringContextAdvice { - - public static volatile boolean initialized = false; - /** Stores the LogForwarder created during early registration. */ - public static volatile Object earlyLogForwarder = null; - - @Advice.OnMethodEnter - public static void onEnter(@Advice.This Object context) { - if (initialized) return; - initialized = true; - - try { - ClassLoader appCL = context.getClass().getClassLoader(); - - // Register appender on the logging framework - Class registrar = appCL.loadClass("com.cameleer.appender.LogAppenderRegistrar"); - String framework = (String) registrar.getMethod("install", ClassLoader.class) - .invoke(null, appCL); - - if (framework == null) { - System.err.println("Cameleer: Early log registration — no framework detected"); - initialized = false; - return; - } - - // Create LogForwarder in deferred mode (no exporter yet) - Class forwarderClass = ClassLoader.getSystemClassLoader() - .loadClass("com.cameleer.core.logging.LogForwarder"); - Object logForwarder = forwarderClass.getDeclaredConstructor().newInstance(); - earlyLogForwarder = logForwarder; - - // Get the forward method reference and set as bridge handler - java.lang.reflect.Method forwardMethod = forwarderClass.getMethod("forward", Object.class); - java.util.function.Consumer handler = data -> { - try { - forwardMethod.invoke(logForwarder, data); - } catch (Exception ignored) { - } - }; - - // Set bridge handler on system CL's LogEventBridge - Class bridgeClass = ClassLoader.getSystemClassLoader() - .loadClass("com.cameleer.core.logging.LogEventBridge"); - java.lang.reflect.Field handlerField = bridgeClass.getField("handler"); - @SuppressWarnings("unchecked") - java.util.concurrent.atomic.AtomicReference> ref = - (java.util.concurrent.atomic.AtomicReference>) - handlerField.get(null); - ref.set(handler); - - System.err.println("Cameleer: Early log appender registered (framework=" + framework + ")"); - } catch (ClassNotFoundException e) { - // Log appender JAR not on classpath — will be handled later in preInstall - System.err.println("Cameleer: Early log registration skipped — log-appender not on classpath"); - initialized = false; - } catch (Exception e) { - System.err.println("Cameleer: Early log registration failed: " + e.getMessage()); - initialized = false; - } - } -} -``` - -- [ ] **Step 3: Register SpringContextTransformer in premain()** - -In `CameleerAgent.java`, add the third transformer after the SendDynamic one (before the final LOG line): - -```java - // Instrument Spring's AbstractApplicationContext.refresh() to register log - // appender as early as possible — captures bean creation, autoconfiguration, etc. - new AgentBuilder.Default() - .type(ElementMatchers.named( - "org.springframework.context.support.AbstractApplicationContext")) - .transform(new SpringContextTransformer()) - .with(AgentBuilder.Listener.StreamWriting.toSystemOut().withErrorsOnly()) - .installOn(inst); -``` - -Add the import: - -```java -import com.cameleer.agent.instrumentation.SpringContextTransformer; -``` - -- [ ] **Step 4: Wire early LogForwarder in CameleerHookInstaller** - -In `CameleerHookInstaller.java`, update `preInstall()` to check for early registration and register if it didn't happen: - -After the existing MDC and InterceptStrategy setup (after line 65), add: - -```java - // Early log appender registration — if SpringContextAdvice didn't already do it - if (!SpringContextAdvice.initialized && config.isLogForwardingEnabled()) { - try { - ClassLoader appCL = camelContext.getClass().getClassLoader(); - Class registrar = appCL.loadClass("com.cameleer.appender.LogAppenderRegistrar"); - String framework = (String) registrar.getMethod("install", ClassLoader.class) - .invoke(null, appCL); - if (framework != null) { - logForwarder = new LogForwarder(); - com.cameleer.core.connection.ServerSetup.setBridgeHandler(logForwarder::forward); - LOG.info("Cameleer: Early log appender registered in preInstall (framework={})", framework); - } - } catch (ClassNotFoundException e) { - LOG.warn("Cameleer: cameleer-log-appender JAR not found on app classpath"); - } catch (Exception e) { - LOG.warn("Cameleer: Failed to register early log appender: {}", e.getMessage()); - } - } -``` - -Update `postInstall()` to use early LogForwarder if available: - -Replace the logForwarder assignment at line 105: - -```java - // Use early-registered LogForwarder if available - if (logForwarder == null && SpringContextAdvice.earlyLogForwarder != null) { - logForwarder = (LogForwarder) SpringContextAdvice.earlyLogForwarder; - } - - sharedExporter = result.activeExporter(); - // logForwarder is already set from preInstall or SpringContextAdvice - - if (result.logForwarder() != null) { - logForwarder = result.logForwarder(); - } -``` - -Update the PostStartSetup.Context call to pass the early LogForwarder: - -```java - PostStartSetup.Context ctx = new PostStartSetup.Context( - config, camelContext, sharedCollector, sharedExporter, - eventNotifier, false, logForwarder); -``` - -- [ ] **Step 5: Build to verify compilation** - -Run: `mvn clean compile -B -pl cameleer-common,cameleer-core,cameleer-log-appender,cameleer-agent -am` -Expected: BUILD SUCCESS - -- [ ] **Step 6: Run all agent tests** - -Run: `mvn test -B -pl cameleer-agent` -Expected: all tests PASS - -- [ ] **Step 7: Commit** - -```bash -git add cameleer-agent/src/ -git commit -m "feat: register log appender at Spring context refresh via ByteBuddy" -``` - ---- - -### Task 5: Early Appender Registration — Extension Mode (Quarkus) - -Register the log appender in `CameleerConfigAdapter.@PostConstruct` and wire the LogForwarder through to `CameleerLifecycle`. - -**Files:** -- Modify: `cameleer-extension/runtime/pom.xml` -- Modify: `cameleer-extension/runtime/src/main/java/com/cameleer/extension/CameleerConfigAdapter.java` -- Modify: `cameleer-extension/runtime/src/main/java/com/cameleer/extension/CameleerLifecycle.java` - -- [ ] **Step 1: Add log-appender dependency to extension runtime POM** - -In `cameleer-extension/runtime/pom.xml`, add in the `` section after `camel-quarkus-core`: - -```xml - - com.cameleer - cameleer-log-appender - ${project.version} - -``` - -- [ ] **Step 2: Add early registration to CameleerConfigAdapter** - -Add to `CameleerConfigAdapter.java`: - -```java -import com.cameleer.appender.LogAppenderRegistrar; -import com.cameleer.core.connection.ServerSetup; -import com.cameleer.core.logging.LogForwarder; -``` - -Add a field: - -```java - private LogForwarder earlyLogForwarder; -``` - -Add at the end of `init()` (after `config = CameleerAgentConfig.getInstance();`): - -```java - // Register log appender early — captures CDI init, CamelContext creation, etc. - if (config.isLogForwardingEnabled()) { - String framework = LogAppenderRegistrar.install( - Thread.currentThread().getContextClassLoader()); - if (framework != null) { - earlyLogForwarder = new LogForwarder(); - ServerSetup.setBridgeHandler(earlyLogForwarder::forward); - System.err.println("Cameleer Extension: Early log appender registered (framework=" + framework + ")"); - } - } -``` - -Add getter: - -```java - public LogForwarder getEarlyLogForwarder() { - return earlyLogForwarder; - } -``` - -- [ ] **Step 3: Wire early LogForwarder in CameleerLifecycle** - -In `CameleerLifecycle.onCamelContextStarted()`, pass the early LogForwarder to PostStartSetup: - -```java - void onCamelContextStarted(@Observes CamelEvent.CamelContextStartedEvent event) { - CamelContext camelContext = event.getContext(); - CameleerAgentConfig config = configAdapter.getConfig(); - boolean isNativeImage = System.getProperty("org.graalvm.nativeimage.imagecode") != null; - - PostStartSetup.Context ctx = new PostStartSetup.Context( - config, camelContext, collector, exporter, eventNotifier, isNativeImage, - configAdapter.getEarlyLogForwarder()); - PostStartSetup.Result result = PostStartSetup.run(ctx); - - exporter = result.activeExporter(); - } -``` - -Add import: - -```java -import com.cameleer.core.logging.LogForwarder; -``` - -- [ ] **Step 4: Build extension to verify compilation** - -Run: `mvn clean compile -B -pl cameleer-extension/runtime -am` -Expected: BUILD SUCCESS - -- [ ] **Step 5: Commit** - -```bash -git add cameleer-extension/ -git commit -m "feat: register log appender early in Quarkus extension via @PostConstruct" -``` - ---- - -### Task 6: Remove Diagnostic Lines - -Remove all temporary `System.err.println` diagnostic counters added during classloader debugging. - -**Files:** -- Modify: `cameleer-log-appender/src/main/java/com/cameleer/appender/CameleerLogbackAppender.java` -- Modify: `cameleer-core/src/main/java/com/cameleer/core/export/ChunkedExporter.java` - -- [ ] **Step 1: Clean up CameleerLogbackAppender** - -Remove the `forwardedCount` and `droppedNullHandler` fields and their import. The `forwardViaBridge` method should now be just: - -```java - private void forwardViaBridge(Object[] data) { - BridgeAccess.forward(data); - } -``` - -(This was already done in Task 1 step 5 — verify no diagnostic lines remain.) - -- [ ] **Step 2: Clean up ChunkedExporter** - -In `ChunkedExporter.java`, remove: -- The `logsSentCount` field (around line 228) -- The `System.err.println("Cameleer-DIAG: ChunkedExporter POST ...")` line in `flushLogs()` - -The `flushLogs()` method should look like the original without diagnostics: - -```java - private void flushLogs() { - List batch = new ArrayList<>(BATCH_SIZE); - for (int i = 0; i < BATCH_SIZE; i++) { - LogEntry entry = logQueue.poll(); - if (entry == null) break; - batch.add(entry); - } - if (batch.isEmpty()) return; - - try { - String json = MAPPER.writeValueAsString(batch); - int status = serverConnection.sendData("/api/v1/data/logs", json); - if (status >= 200 && status < 300) { - LOG.debug("Exported {} log entries (HTTP {})", batch.size(), status); - } else if (status == 503) { - pauseUntil.set(System.currentTimeMillis() + 10_000); - LOG.warn("Server overloaded (503), pausing export for 10 seconds"); - } else { - LOG.warn("Log export returned HTTP {} ({} log entries lost)", status, batch.size()); - } - } catch (JsonProcessingException e) { - LOG.error("Failed to serialize {} log entries", batch.size(), e); - } catch (Exception e) { - LOG.warn("Failed to send {} log entries: {}", batch.size(), e.getMessage()); - } - } -``` - -- [ ] **Step 3: Build full project** - -Run: `mvn clean verify -B -pl cameleer-common,cameleer-core,cameleer-log-appender,cameleer-agent -am` -Expected: BUILD SUCCESS, all tests pass - -- [ ] **Step 4: Commit** - -```bash -git add cameleer-log-appender/src/ cameleer-core/src/ -git commit -m "chore: remove temporary diagnostic System.err.println lines" -``` - ---- - -### Task 7: Full Build Verification - -Final integration verification across all modules. - -- [ ] **Step 1: Full build with all modules** - -Run: `mvn clean verify -B` -Expected: BUILD SUCCESS across all modules - -- [ ] **Step 2: Verify no remaining diagnostic lines** - -Run: `grep -r "Cameleer-DIAG" cameleer-*/src/main/ || echo "clean"` -Expected: "clean" — no diagnostic lines remain - -- [ ] **Step 3: Final commit if any cleanup needed** - -If any remaining issues found, fix and commit. diff --git a/docs/superpowers/plans/2026-04-12-micrometer-metrics-integration.md b/docs/superpowers/plans/2026-04-12-micrometer-metrics-integration.md deleted file mode 100644 index 0c20a6c..0000000 --- a/docs/superpowers/plans/2026-04-12-micrometer-metrics-integration.md +++ /dev/null @@ -1,1360 +0,0 @@ -# Micrometer Metrics Integration — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace JMX-based metrics with Micrometer when available, register cameleer.* agent metrics, add Prometheus discovery labels/annotations to all deployments. - -**Architecture:** Detect Micrometer on classpath at startup via reflection. If found, piggyback on the app's MeterRegistry for agent metrics and skip the custom PrometheusEndpoint. Fall back to JMX-based metrics (renamed JmxMetricsBridge) for plain-app scenarios. CDI MeterBinder in the extension for Quarkus. - -**Tech Stack:** Micrometer Core (reflection), camel-micrometer-starter, micrometer-registry-prometheus, quarkus-micrometer-registry-prometheus, camel-quarkus-micrometer - -**Spec:** `docs/superpowers/specs/2026-04-12-micrometer-metrics-integration-design.md` - ---- - -## File Structure - -### New Files -| File | Responsibility | -|------|---------------| -| `cameleer-core/src/main/java/com/cameleer/core/metrics/MetricsBridge.java` | Interface for both metrics bridge implementations | -| `cameleer-core/src/main/java/com/cameleer/core/metrics/MicrometerMetricsBridge.java` | Micrometer-based bridge using reflection | -| `cameleer-core/src/test/java/com/cameleer/core/metrics/MicrometerMetricsBridgeTest.java` | Unit tests for Micrometer bridge | -| `cameleer-extension/runtime/src/main/java/com/cameleer/extension/CameleerMeterBinder.java` | CDI MeterBinder for extension mode | - -### Modified Files -| File | Change | -|------|--------| -| `cameleer-core/.../metrics/CamelMetricsBridge.java` | Rename to JmxMetricsBridge, implement MetricsBridge | -| `cameleer-core/.../metrics/PrometheusEndpoint.java` | Accept MetricsBridge instead of CamelMetricsBridge | -| `cameleer-core/.../PostStartSetup.java` | Detection logic, use MetricsBridge type, conditional Prometheus | -| `cameleer-core/.../notifier/CameleerEventNotifier.java` | Use MetricsBridge type | -| `cameleer-agent/src/test/.../CamelMetricsBridgeTest.java` | Rename to JmxMetricsBridgeTest, update class refs | -| Sample app POMs (3 Spring Boot + 2 Quarkus) | Add Micrometer dependencies | -| Sample app application.properties (5 files) | Add management port config | -| Dockerfiles (7 files) | Add LABEL + EXPOSE | -| K8s manifests (7 files) | Add annotations + ports | - ---- - -### Task 1: MetricsBridge Interface - -**Files:** -- Create: `cameleer-core/src/main/java/com/cameleer/core/metrics/MetricsBridge.java` - -- [ ] **Step 1: Create MetricsBridge interface** - -```java -package com.cameleer.core.metrics; - -import com.cameleer.common.model.MetricsSnapshot; -import com.cameleer.core.export.Exporter; - -import java.util.List; - -/** - * Abstraction for metrics collection. Two implementations: - * - JmxMetricsBridge: polls Camel JMX MBeans (fallback when Micrometer is absent) - * - MicrometerMetricsBridge: piggybacks on the app's Micrometer MeterRegistry - */ -public interface MetricsBridge { - - void start(); - - void stop(); - - List getLatestSnapshots(); - - boolean isAvailable(); - - void setExporter(Exporter exporter); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add cameleer-core/src/main/java/com/cameleer/core/metrics/MetricsBridge.java -git commit -m "feat: add MetricsBridge interface for metrics abstraction" -``` - ---- - -### Task 2: Rename CamelMetricsBridge to JmxMetricsBridge - -**Files:** -- Modify: `cameleer-core/src/main/java/com/cameleer/core/metrics/CamelMetricsBridge.java` → rename to `JmxMetricsBridge.java` -- Modify: `cameleer-core/src/main/java/com/cameleer/core/metrics/PrometheusEndpoint.java` (update constructor param type) -- Modify: `cameleer-core/src/main/java/com/cameleer/core/PostStartSetup.java` (update type references) -- Modify: `cameleer-core/src/main/java/com/cameleer/core/notifier/CameleerEventNotifier.java` (update field/method types) -- Modify: `cameleer-agent/src/test/java/com/cameleer/agent/metrics/CamelMetricsBridgeTest.java` → rename to `JmxMetricsBridgeTest.java` - -This task is a pure rename + implement interface. No behavioral changes. - -- [ ] **Step 1: Rename CamelMetricsBridge.java to JmxMetricsBridge.java** - -Rename the file and update the class declaration to implement `MetricsBridge`: - -```java -public class JmxMetricsBridge implements MetricsBridge { - // All existing code unchanged, just class name and implements clause -``` - -The existing methods `start()`, `stop()`, `getLatestSnapshots()`, `isAvailable()`, `setExporter()` already match the interface — they just need the `@Override` annotations added. - -- [ ] **Step 2: Update PrometheusEndpoint to use MetricsBridge** - -In `PrometheusEndpoint.java`, change the constructor parameter and field from `CamelMetricsBridge` to `MetricsBridge`: - -```java -private final MetricsBridge metricsBridge; - -public PrometheusEndpoint(MetricsBridge metricsBridge, String instanceId, int port, String path) { - this.metricsBridge = metricsBridge; - // rest unchanged -``` - -- [ ] **Step 3: Update PostStartSetup to use MetricsBridge type** - -In `PostStartSetup.java`: - -1. Change the `Result` record field from `CamelMetricsBridge` to `MetricsBridge`: -```java -public record Result(Exporter activeExporter, List graphs, - MetricsBridge metricsBridge, LogForwarder logForwarder) {} -``` - -2. Change `setupMetrics` return type and local variable: -```java -private static MetricsBridge setupMetrics(Context ctx) { - // ... existing check ... - JmxMetricsBridge metricsBridge = new JmxMetricsBridge( - config, ctx.camelContext(), ctx.initialExporter(), instanceId); - // rest unchanged -``` - -3. Update the import from `CamelMetricsBridge` to `JmxMetricsBridge` and `MetricsBridge`. - -4. In `setupPrometheus`, change parameter type: -```java -private static PrometheusEndpoint setupPrometheus(CameleerAgentConfig config, - MetricsBridge metricsBridge) { -``` - -5. In `buildStartupReport`, change parameter type: -```java -private static StartupReport buildStartupReport(CameleerAgentConfig config, Exporter activeExporter, - List graphs, MetricsBridge metricsBridge, -``` - -- [ ] **Step 4: Update CameleerEventNotifier to use MetricsBridge type** - -In `CameleerEventNotifier.java`, change the field and setter: - -```java -import com.cameleer.core.metrics.MetricsBridge; - -// Field (line 40): -private MetricsBridge metricsBridge; - -// Setter (line 63): -public void setMetricsBridge(MetricsBridge metricsBridge) { - this.metricsBridge = metricsBridge; -} -``` - -Also update the `shutdownMetricsBridge` field (line 47): -```java -private MetricsBridge shutdownMetricsBridge; -``` - -Search for all other references to `CamelMetricsBridge` in CameleerEventNotifier and update them to `MetricsBridge`. - -- [ ] **Step 5: Rename the test file** - -Rename `CamelMetricsBridgeTest.java` to `JmxMetricsBridgeTest.java`. Update: -- Class name: `JmxMetricsBridgeTest` -- Field type: `private JmxMetricsBridge bridge;` -- All `new CamelMetricsBridge(...)` → `new JmxMetricsBridge(...)` -- Import: `com.cameleer.core.metrics.JmxMetricsBridge` - -- [ ] **Step 6: Verify compilation** - -Run: `mvn clean compile -pl cameleer-common,cameleer-core,cameleer-agent -DskipTests` -Expected: BUILD SUCCESS - -- [ ] **Step 7: Run existing tests** - -Run: `mvn test -pl cameleer-common,cameleer-core,cameleer-agent` -Expected: All tests pass (same behavior, just renamed) - -- [ ] **Step 8: Commit** - -```bash -git add -A -git commit -m "refactor: rename CamelMetricsBridge to JmxMetricsBridge, introduce MetricsBridge interface" -``` - ---- - -### Task 3: MicrometerMetricsBridge - -**Files:** -- Create: `cameleer-core/src/main/java/com/cameleer/core/metrics/MicrometerMetricsBridge.java` - -- [ ] **Step 1: Create MicrometerMetricsBridge** - -This class uses reflection to interact with Micrometer. All `Method` objects are resolved once at construction and cached. - -```java -package com.cameleer.core.metrics; - -import com.cameleer.common.model.MetricsSnapshot; -import com.cameleer.core.CameleerAgentConfig; -import com.cameleer.core.export.Exporter; -import org.apache.camel.CamelContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.lang.reflect.Method; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; - -/** - * Metrics bridge that piggybacks on the application's Micrometer MeterRegistry. - * Registers cameleer.* agent metrics and periodically scrapes the registry - * to produce MetricsSnapshot DTOs for server push. - * - *

All Micrometer interaction is via reflection — cameleer-core has no - * compile-time Micrometer dependency. - */ -public class MicrometerMetricsBridge implements MetricsBridge { - - private static final Logger LOG = LoggerFactory.getLogger(MicrometerMetricsBridge.class); - - private final CameleerAgentConfig config; - private final String instanceId; - private final Object meterRegistry; // MeterRegistry instance (held as Object) - private final AtomicReference exporter = new AtomicReference<>(); - private final AtomicReference> latestSnapshots = new AtomicReference<>(List.of()); - private ScheduledExecutorService scheduler; - private volatile boolean available = false; - - // Cached reflection handles — resolved once at construction - private final Method getMetersMethod; // MeterRegistry.getMeters() - private final Method getIdMethod; // Meter.getId() - private final Method getNameMethod; // Meter.Id.getName() - private final Method getTagsMethod; // Meter.Id.getTags() - private final Method measureMethod; // Meter.measure() - private final Method getTagKeyMethod; // Tag.getKey() - private final Method getTagValueMethod; // Tag.getValue() - private final Method getStatisticMethod; // Measurement.getStatistic() - private final Method getMeasureValueMethod; // Measurement.getValue() - private final Method statisticNameMethod; // Statistic.name() (enum) - - // Counter registration handles - private final Method counterBuilderMethod; // Counter.builder(String) - private final Method counterTagMethod; // Counter.Builder.tag(String, String) - private final Method counterDescMethod; // Counter.Builder.description(String) - private final Method counterRegisterMethod; // Counter.Builder.register(MeterRegistry) - private final Method counterIncrementMethod; // Counter.increment() - - // Registered agent counters (held as Object to avoid compile dep) - private Object chunksExportedCounter; - private Object chunksDroppedQueueFullCounter; - private Object chunksDroppedServerErrorCounter; - private Object chunksDroppedBackpressureCounter; - private Object sseReconnectsCounter; - private Object tapsEvaluatedCounter; - private Object metricsExportedCounter; - - // Agent metric tracking for JMX-equivalent counters - private final AtomicLong chunksExportedCount = new AtomicLong(); - private final AtomicLong sseReconnectsCount = new AtomicLong(); - - public MicrometerMetricsBridge(Object meterRegistry, CameleerAgentConfig config, - Exporter initialExporter, String instanceId) throws ReflectiveOperationException { - this.meterRegistry = meterRegistry; - this.config = config; - this.exporter.set(initialExporter); - this.instanceId = instanceId; - - // Cache all reflection handles - ClassLoader cl = meterRegistry.getClass().getClassLoader(); - - Class meterRegistryClass = cl.loadClass("io.micrometer.core.instrument.MeterRegistry"); - Class meterClass = cl.loadClass("io.micrometer.core.instrument.Meter"); - Class meterIdClass = cl.loadClass("io.micrometer.core.instrument.Meter$Id"); - Class tagClass = cl.loadClass("io.micrometer.core.instrument.Tag"); - Class measurementClass = cl.loadClass("io.micrometer.core.instrument.Measurement"); - Class statisticClass = cl.loadClass("io.micrometer.core.instrument.Statistic"); - Class counterClass = cl.loadClass("io.micrometer.core.instrument.Counter"); - Class counterBuilderClass = cl.loadClass("io.micrometer.core.instrument.Counter$Builder"); - - getMetersMethod = meterRegistryClass.getMethod("getMeters"); - getIdMethod = meterClass.getMethod("getId"); - getNameMethod = meterIdClass.getMethod("getName"); - getTagsMethod = meterIdClass.getMethod("getTags"); - measureMethod = meterClass.getMethod("measure"); - getTagKeyMethod = tagClass.getMethod("getKey"); - getTagValueMethod = tagClass.getMethod("getValue"); - getStatisticMethod = measurementClass.getMethod("getStatistic"); - getMeasureValueMethod = measurementClass.getMethod("getValue"); - statisticNameMethod = statisticClass.getMethod("name"); - - counterBuilderMethod = counterClass.getMethod("builder", String.class); - counterTagMethod = counterBuilderClass.getMethod("tag", String.class, String.class); - counterDescMethod = counterBuilderClass.getMethod("description", String.class); - counterRegisterMethod = counterBuilderClass.getMethod("register", meterRegistryClass); - counterIncrementMethod = counterClass.getMethod("increment"); - - registerAgentMetrics(); - } - - private void registerAgentMetrics() throws ReflectiveOperationException { - chunksExportedCounter = buildCounter("cameleer.chunks.exported", - "Execution chunks sent to server", Map.of()); - chunksDroppedQueueFullCounter = buildCounter("cameleer.chunks.dropped", - "Chunks dropped due to queue full", Map.of("reason", "queue_full")); - chunksDroppedServerErrorCounter = buildCounter("cameleer.chunks.dropped", - "Chunks dropped due to server error", Map.of("reason", "server_error")); - chunksDroppedBackpressureCounter = buildCounter("cameleer.chunks.dropped", - "Chunks dropped due to backpressure", Map.of("reason", "backpressure")); - sseReconnectsCounter = buildCounter("cameleer.sse.reconnects", - "SSE reconnection count", Map.of()); - tapsEvaluatedCounter = buildCounter("cameleer.taps.evaluated", - "Tap expression evaluations", Map.of()); - metricsExportedCounter = buildCounter("cameleer.metrics.exported", - "Metric batches pushed to server", Map.of()); - - LOG.info("Cameleer: Registered cameleer.* agent metrics on MeterRegistry"); - } - - private Object buildCounter(String name, String description, - Map extraTags) throws ReflectiveOperationException { - Object builder = counterBuilderMethod.invoke(null, name); - builder = counterTagMethod.invoke(builder, "instanceId", instanceId); - for (Map.Entry tag : extraTags.entrySet()) { - builder = counterTagMethod.invoke(builder, tag.getKey(), tag.getValue()); - } - builder = counterDescMethod.invoke(builder, description); - return counterRegisterMethod.invoke(builder, meterRegistry); - } - - /** Increment a counter via reflection. */ - public void incrementCounter(Object counter) { - try { - counterIncrementMethod.invoke(counter); - } catch (Exception e) { - LOG.debug("Failed to increment counter: {}", e.getMessage()); - } - } - - // --- Public accessors for counter objects (used by ChunkedExporter, SseClient, etc.) --- - - public Object getChunksExportedCounter() { return chunksExportedCounter; } - public Object getChunksDroppedQueueFullCounter() { return chunksDroppedQueueFullCounter; } - public Object getChunksDroppedServerErrorCounter() { return chunksDroppedServerErrorCounter; } - public Object getChunksDroppedBackpressureCounter() { return chunksDroppedBackpressureCounter; } - public Object getSseReconnectsCounter() { return sseReconnectsCounter; } - public Object getTapsEvaluatedCounter() { return tapsEvaluatedCounter; } - public Object getMetricsExportedCounter() { return metricsExportedCounter; } - - @Override - public void setExporter(Exporter exporter) { - this.exporter.set(exporter); - } - - @Override - public void start() { - available = true; - - // Verify JVM binders are active - verifyJvmBinders(); - - scheduler = Executors.newSingleThreadScheduledExecutor(r -> { - Thread t = new Thread(r, "cameleer-micrometer-metrics"); - t.setDaemon(true); - return t; - }); - - scheduler.scheduleAtFixedRate(this::scrapeAndExport, - config.getMetricsIntervalSeconds(), - config.getMetricsIntervalSeconds(), - TimeUnit.SECONDS); - - LOG.info("Cameleer: Micrometer metrics bridge started (interval={}s)", config.getMetricsIntervalSeconds()); - } - - private void verifyJvmBinders() { - try { - Class searchClass = meterRegistry.getClass().getClassLoader() - .loadClass("io.micrometer.core.instrument.search.MeterSelector"); - } catch (ClassNotFoundException e) { - // Expected — not all versions have MeterSelector - } - // Simple check: see if jvm.memory.used gauge exists by iterating meters - boolean foundJvmMetric = false; - try { - Iterable meters = (Iterable) getMetersMethod.invoke(meterRegistry); - for (Object meter : meters) { - Object id = getIdMethod.invoke(meter); - String name = (String) getNameMethod.invoke(id); - if ("jvm.memory.used".equals(name)) { - foundJvmMetric = true; - break; - } - } - } catch (Exception e) { - LOG.debug("Could not verify JVM binders: {}", e.getMessage()); - } - if (!foundJvmMetric) { - LOG.warn("Cameleer: JVM memory binders not detected on MeterRegistry — " - + "JVM metrics may be missing. Ensure your framework auto-registers Micrometer JVM binders."); - } - } - - @Override - public void stop() { - if (scheduler != null) { - scheduler.shutdownNow(); - try { - scheduler.awaitTermination(5, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - LOG.info("Cameleer: Micrometer metrics bridge stopped"); - } - } - - @Override - public List getLatestSnapshots() { - return latestSnapshots.get(); - } - - @Override - public boolean isAvailable() { - return available; - } - - /** - * Scrape all meters from the MeterRegistry and convert to MetricsSnapshot DTOs - * for server push via the Exporter. - */ - private void scrapeAndExport() { - try { - Instant collectedAt = Instant.now(); - List snapshots = new ArrayList<>(); - - Iterable meters = (Iterable) getMetersMethod.invoke(meterRegistry); - for (Object meter : meters) { - Object id = getIdMethod.invoke(meter); - String name = (String) getNameMethod.invoke(id); - - // Collect tags - Map tags = new LinkedHashMap<>(); - Iterable meterTags = (Iterable) getTagsMethod.invoke(id); - for (Object tag : meterTags) { - String key = (String) getTagKeyMethod.invoke(tag); - String value = (String) getTagValueMethod.invoke(tag); - tags.put(key, value); - } - - // Each meter can have multiple measurements (e.g., Timer has count, total, max) - Iterable measurements = (Iterable) measureMethod.invoke(meter); - for (Object measurement : measurements) { - double value = (double) getMeasureValueMethod.invoke(measurement); - Object statistic = getStatisticMethod.invoke(measurement); - String statisticName = ((String) statisticNameMethod.invoke(statistic)).toLowerCase(); - - // Build metric name: name.statistic (e.g., "jvm.gc.pause.count") - String metricName = name + "." + statisticName; - snapshots.add(new MetricsSnapshot(instanceId, collectedAt, metricName, value, tags)); - } - } - - latestSnapshots.set(List.copyOf(snapshots)); - - Exporter exp = exporter.get(); - if (exp != null) { - exp.exportMetrics(snapshots); - incrementCounter(metricsExportedCounter); - } - } catch (Exception e) { - LOG.warn("Cameleer: Failed to scrape Micrometer metrics: {}", e.getMessage(), e); - } - } - - /** - * Detect whether Micrometer is on the classpath. - */ - public static boolean isMicrometerAvailable(ClassLoader classLoader) { - try { - classLoader.loadClass("io.micrometer.core.instrument.MeterRegistry"); - return true; - } catch (ClassNotFoundException e) { - return false; - } - } - - /** - * Locate the MeterRegistry from the CamelContext's registry (Spring beans / CDI beans). - * Returns null if not found. - */ - @SuppressWarnings("unchecked") - public static Object findMeterRegistry(CamelContext camelContext) { - try { - ClassLoader cl = camelContext.getClass().getClassLoader(); - Class registryClass = cl.loadClass("io.micrometer.core.instrument.MeterRegistry"); - // CamelContext.getRegistry().findByType(Class) returns Set - var found = camelContext.getRegistry().findByType(registryClass); - if (found != null && !found.isEmpty()) { - Object registry = found.iterator().next(); - LOG.info("Cameleer: Found MeterRegistry in Camel registry: {}", registry.getClass().getName()); - return registry; - } - - // Fallback: Micrometer's global static registry - Class metricsClass = cl.loadClass("io.micrometer.core.instrument.Metrics"); - Method globalRegistryMethod = metricsClass.getMethod("globalRegistry"); - Object globalRegistry = globalRegistryMethod.invoke(null); - LOG.info("Cameleer: Using Micrometer global registry (no registry found in Camel context)"); - return globalRegistry; - } catch (Exception e) { - LOG.warn("Cameleer: Failed to locate MeterRegistry: {}", e.getMessage()); - return null; - } - } -} -``` - -- [ ] **Step 2: Verify compilation** - -Run: `mvn clean compile -pl cameleer-common,cameleer-core -DskipTests` -Expected: BUILD SUCCESS - -- [ ] **Step 3: Commit** - -```bash -git add cameleer-core/src/main/java/com/cameleer/core/metrics/MicrometerMetricsBridge.java -git commit -m "feat: add MicrometerMetricsBridge with reflection-based Micrometer integration" -``` - ---- - -### Task 4: Update PostStartSetup Detection Logic - -**Files:** -- Modify: `cameleer-core/src/main/java/com/cameleer/core/PostStartSetup.java` - -- [ ] **Step 1: Update setupMetrics to detect Micrometer** - -Replace the `setupMetrics` method (lines 133-152) with detection logic: - -```java -private static MetricsBridge setupMetrics(Context ctx) { - CameleerAgentConfig config = ctx.config(); - if (!config.isMetricsEnabled()) { - return null; - } - String instanceId = config.getInstanceId(); - - // Try Micrometer first (unless running in native image where JMX is unavailable anyway) - if (!ctx.skipJmxMetrics()) { - ClassLoader cl = ctx.camelContext().getClass().getClassLoader(); - if (MicrometerMetricsBridge.isMicrometerAvailable(cl)) { - Object registry = MicrometerMetricsBridge.findMeterRegistry(ctx.camelContext()); - if (registry != null) { - try { - MicrometerMetricsBridge bridge = new MicrometerMetricsBridge( - registry, config, ctx.initialExporter(), instanceId); - bridge.start(); - ctx.eventNotifier().setMetricsBridge(bridge); - LOG.info("Cameleer: Micrometer metrics bridge active"); - return bridge; - } catch (ReflectiveOperationException e) { - LOG.warn("Cameleer: Failed to initialize Micrometer bridge, falling back to JMX: {}", - e.getMessage()); - } - } - } - } - - // Fallback: JMX-based metrics - if (ctx.skipJmxMetrics()) { - LOG.info("Cameleer: JMX metrics skipped (native image)"); - return null; - } - JmxMetricsBridge metricsBridge = new JmxMetricsBridge( - config, ctx.camelContext(), ctx.initialExporter(), instanceId); - metricsBridge.start(); - if (!metricsBridge.isAvailable()) { - return null; - } - ctx.eventNotifier().setMetricsBridge(metricsBridge); - LOG.info("Cameleer: JMX metrics bridge active (Micrometer not found)"); - return metricsBridge; -} -``` - -- [ ] **Step 2: Update run() to skip PrometheusEndpoint when Micrometer is active** - -In the `run()` method (around line 77-81), change the Prometheus condition: - -```java -// 2. Start metrics bridge + Prometheus endpoint -PrometheusEndpoint prometheusEndpoint = null; -MetricsBridge metricsBridge = setupMetrics(ctx); -// Only start standalone Prometheus endpoint in JMX fallback mode -if (metricsBridge instanceof JmxMetricsBridge && config.isPrometheusEnabled()) { - prometheusEndpoint = setupPrometheus(config, metricsBridge); -} -``` - -- [ ] **Step 3: Update imports** - -Add import for `MicrometerMetricsBridge` and `JmxMetricsBridge`, remove import for `CamelMetricsBridge`. - -- [ ] **Step 4: Verify compilation** - -Run: `mvn clean compile -pl cameleer-common,cameleer-core -DskipTests` -Expected: BUILD SUCCESS - -- [ ] **Step 5: Commit** - -```bash -git add cameleer-core/src/main/java/com/cameleer/core/PostStartSetup.java -git commit -m "feat: add Micrometer detection in PostStartSetup, skip PrometheusEndpoint when Micrometer active" -``` - ---- - -### Task 5: MicrometerMetricsBridge Unit Tests - -**Files:** -- Create: `cameleer-core/src/test/java/com/cameleer/core/metrics/MicrometerMetricsBridgeTest.java` - -Note: These tests add `micrometer-core` as a test-scope dependency in `cameleer-core/pom.xml` to verify the reflection-based bridge works correctly. The production code still has no compile-time dependency. - -- [ ] **Step 1: Add micrometer-core test dependency to cameleer-core POM** - -Add to `cameleer-core/pom.xml` `` section: - -```xml - - - io.micrometer - micrometer-core - 1.13.6 - test - -``` - -Check the parent POM for a managed Micrometer version first. If Spring Boot BOM is not imported in core, use an explicit version compatible with Spring Boot 3.4.3 (Micrometer 1.13.x). - -- [ ] **Step 2: Write unit tests** - -```java -package com.cameleer.core.metrics; - -import com.cameleer.core.CameleerAgentConfig; -import com.cameleer.core.export.Exporter; -import com.cameleer.common.model.MetricsSnapshot; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class MicrometerMetricsBridgeTest { - - @Mock - private CameleerAgentConfig config; - - private SimpleMeterRegistry registry; - private MicrometerMetricsBridge bridge; - - @BeforeEach - void setUp() { - lenient().when(config.getMetricsIntervalSeconds()).thenReturn(1); - registry = new SimpleMeterRegistry(); - } - - @AfterEach - void tearDown() { - if (bridge != null) { - bridge.stop(); - } - registry.close(); - } - - @Test - void registersAgentMetrics() throws Exception { - CapturingExporter exporter = new CapturingExporter(); - bridge = new MicrometerMetricsBridge(registry, config, exporter, "test-instance"); - - // Verify cameleer.* counters are registered - assertNotNull(registry.find("cameleer.chunks.exported").counter(), - "Should register cameleer.chunks.exported counter"); - assertNotNull(registry.find("cameleer.sse.reconnects").counter(), - "Should register cameleer.sse.reconnects counter"); - assertNotNull(registry.find("cameleer.taps.evaluated").counter(), - "Should register cameleer.taps.evaluated counter"); - assertNotNull(registry.find("cameleer.metrics.exported").counter(), - "Should register cameleer.metrics.exported counter"); - - // Verify dropped counters with reason tags - assertNotNull(registry.find("cameleer.chunks.dropped").tag("reason", "queue_full").counter()); - assertNotNull(registry.find("cameleer.chunks.dropped").tag("reason", "server_error").counter()); - assertNotNull(registry.find("cameleer.chunks.dropped").tag("reason", "backpressure").counter()); - } - - @Test - void agentCountersHaveInstanceIdTag() throws Exception { - CapturingExporter exporter = new CapturingExporter(); - bridge = new MicrometerMetricsBridge(registry, config, exporter, "my-host-123"); - - var counter = registry.find("cameleer.chunks.exported").tag("instanceId", "my-host-123").counter(); - assertNotNull(counter, "Counter should have instanceId tag"); - } - - @Test - void incrementCounterWorks() throws Exception { - CapturingExporter exporter = new CapturingExporter(); - bridge = new MicrometerMetricsBridge(registry, config, exporter, "test-instance"); - - bridge.incrementCounter(bridge.getChunksExportedCounter()); - bridge.incrementCounter(bridge.getChunksExportedCounter()); - - var counter = registry.find("cameleer.chunks.exported").counter(); - assertEquals(2.0, counter.count(), 0.001); - } - - @Test - void scrapeProducesSnapshots() throws Exception { - // Pre-populate registry with a gauge - registry.gauge("test.gauge", 42.0); - - CapturingExporter exporter = new CapturingExporter(); - bridge = new MicrometerMetricsBridge(registry, config, exporter, "test-instance"); - bridge.start(); - - assertTrue(bridge.isAvailable()); - assertTrue(exporter.awaitExport(5, TimeUnit.SECONDS), - "Should have exported at least one batch"); - - List snapshots = bridge.getLatestSnapshots(); - assertFalse(snapshots.isEmpty()); - - // All snapshots should have our instanceId - for (MetricsSnapshot s : snapshots) { - assertEquals("test-instance", s.getInstanceId()); - } - } - - @Test - void isMicrometerAvailable_returnsTrue() { - assertTrue(MicrometerMetricsBridge.isMicrometerAvailable(getClass().getClassLoader())); - } - - private static class CapturingExporter implements Exporter { - private final List exported = new CopyOnWriteArrayList<>(); - private final CountDownLatch latch = new CountDownLatch(1); - - @Override - public void exportMetrics(List snapshots) { - exported.addAll(snapshots); - latch.countDown(); - } - - boolean awaitExport(long timeout, TimeUnit unit) throws InterruptedException { - return latch.await(timeout, unit); - } - } -} -``` - -- [ ] **Step 3: Run tests** - -Run: `mvn test -pl cameleer-core -Dtest=MicrometerMetricsBridgeTest` -Expected: All tests pass - -- [ ] **Step 4: Commit** - -```bash -git add cameleer-core/pom.xml cameleer-core/src/test/java/com/cameleer/core/metrics/MicrometerMetricsBridgeTest.java -git commit -m "test: add MicrometerMetricsBridge unit tests" -``` - ---- - -### Task 6: Extension CDI MeterBinder - -**Files:** -- Create: `cameleer-extension/runtime/src/main/java/com/cameleer/extension/CameleerMeterBinder.java` -- Modify: `cameleer-extension/runtime/pom.xml` (add optional Micrometer dependency) - -- [ ] **Step 1: Add optional Micrometer dependency to extension runtime POM** - -Add to `cameleer-extension/runtime/pom.xml` dependencies: - -```xml - - - io.micrometer - micrometer-core - true - -``` - -The version is managed by the Quarkus BOM already imported in the POM. - -- [ ] **Step 2: Create CameleerMeterBinder** - -```java -package com.cameleer.extension; - -import com.cameleer.core.CameleerAgentConfig; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.binder.MeterBinder; -import io.quarkus.arc.Unremovable; -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * CDI MeterBinder that registers cameleer.* agent metrics on the application's - * Micrometer MeterRegistry. Quarkus auto-discovers MeterBinder beans and calls - * {@link #bindTo(MeterRegistry)}. - * - *

If quarkus-micrometer is not on the classpath, this bean won't activate - * because the MeterBinder interface will be missing. - */ -@ApplicationScoped -@Unremovable -public class CameleerMeterBinder implements MeterBinder { - - private static final Logger LOG = LoggerFactory.getLogger(CameleerMeterBinder.class); - - private final String instanceId; - - private Counter chunksExportedCounter; - private Counter chunksDroppedQueueFullCounter; - private Counter chunksDroppedServerErrorCounter; - private Counter chunksDroppedBackpressureCounter; - private Counter sseReconnectsCounter; - private Counter tapsEvaluatedCounter; - private Counter metricsExportedCounter; - - @Inject - CameleerMeterBinder(CameleerConfigAdapter configAdapter) { - this.instanceId = configAdapter.getConfig().getInstanceId(); - } - - @Override - public void bindTo(MeterRegistry registry) { - chunksExportedCounter = Counter.builder("cameleer.chunks.exported") - .tag("instanceId", instanceId) - .description("Execution chunks sent to server") - .register(registry); - - chunksDroppedQueueFullCounter = Counter.builder("cameleer.chunks.dropped") - .tag("instanceId", instanceId).tag("reason", "queue_full") - .description("Chunks dropped due to queue full") - .register(registry); - - chunksDroppedServerErrorCounter = Counter.builder("cameleer.chunks.dropped") - .tag("instanceId", instanceId).tag("reason", "server_error") - .description("Chunks dropped due to server error") - .register(registry); - - chunksDroppedBackpressureCounter = Counter.builder("cameleer.chunks.dropped") - .tag("instanceId", instanceId).tag("reason", "backpressure") - .description("Chunks dropped due to backpressure") - .register(registry); - - sseReconnectsCounter = Counter.builder("cameleer.sse.reconnects") - .tag("instanceId", instanceId) - .description("SSE reconnection count") - .register(registry); - - tapsEvaluatedCounter = Counter.builder("cameleer.taps.evaluated") - .tag("instanceId", instanceId) - .description("Tap expression evaluations") - .register(registry); - - metricsExportedCounter = Counter.builder("cameleer.metrics.exported") - .tag("instanceId", instanceId) - .description("Metric batches pushed to server") - .register(registry); - - LOG.info("Cameleer Extension: Registered cameleer.* agent metrics via MeterBinder"); - } - - // Accessors for other extension components to increment counters - public Counter getChunksExportedCounter() { return chunksExportedCounter; } - public Counter getChunksDroppedQueueFullCounter() { return chunksDroppedQueueFullCounter; } - public Counter getChunksDroppedServerErrorCounter() { return chunksDroppedServerErrorCounter; } - public Counter getChunksDroppedBackpressureCounter() { return chunksDroppedBackpressureCounter; } - public Counter getSseReconnectsCounter() { return sseReconnectsCounter; } - public Counter getTapsEvaluatedCounter() { return tapsEvaluatedCounter; } - public Counter getMetricsExportedCounter() { return metricsExportedCounter; } -} -``` - -- [ ] **Step 3: Verify compilation** - -Run: `mvn clean compile -pl cameleer-extension/runtime -DskipTests` -Expected: BUILD SUCCESS - -- [ ] **Step 4: Commit** - -```bash -git add cameleer-extension/runtime/pom.xml -git add cameleer-extension/runtime/src/main/java/com/cameleer/extension/CameleerMeterBinder.java -git commit -m "feat: add CameleerMeterBinder CDI bean for extension Micrometer integration" -``` - ---- - -### Task 7: Spring Boot Sample App Dependencies - -**Files:** -- Modify: `cameleer-sample-app/pom.xml` -- Modify: `cameleer-sample-app/src/main/resources/application.properties` -- Modify: `cameleer-backend-app/pom.xml` -- Modify: `cameleer-backend-app/src/main/resources/application.properties` -- Modify: `cameleer-caller-app/pom.xml` -- Modify: `cameleer-caller-app/src/main/resources/application.properties` - -- [ ] **Step 1: Add Micrometer deps to sample-app POM** - -Add after the `camel-resilience4j-starter` dependency: - -```xml - - - org.apache.camel.springboot - camel-micrometer-starter - - - io.micrometer - micrometer-registry-prometheus - -``` - -- [ ] **Step 2: Update sample-app application.properties** - -Replace the management line: -```properties -management.endpoints.web.exposure.include=health,info,camelroutes,metrics -``` -with: -```properties -management.server.port=8081 -management.endpoints.web.exposure.include=health,info,camelroutes,metrics,prometheus -``` - -- [ ] **Step 3: Add Micrometer deps to backend-app POM** - -Add after the `camel-management-starter` dependency: - -```xml - - - org.apache.camel.springboot - camel-micrometer-starter - - - io.micrometer - micrometer-registry-prometheus - -``` - -- [ ] **Step 4: Update backend-app application.properties** - -Replace: -```properties -management.endpoints.web.exposure.include=health,info,camelroutes,metrics -``` -with: -```properties -management.server.port=8081 -management.endpoints.web.exposure.include=health,info,camelroutes,metrics,prometheus -``` - -- [ ] **Step 5: Add Micrometer deps to caller-app POM** - -Add after the `camel-management-starter` dependency: - -```xml - - - org.apache.camel.springboot - camel-micrometer-starter - - - io.micrometer - micrometer-registry-prometheus - -``` - -- [ ] **Step 6: Update caller-app application.properties** - -Change `server.port` from 8081 to 8080, and update management: - -Replace: -```properties -server.port=8081 -``` -with: -```properties -server.port=8080 -``` - -Replace: -```properties -management.endpoints.web.exposure.include=health,info,camelroutes,metrics -``` -with: -```properties -management.server.port=8081 -management.endpoints.web.exposure.include=health,info,camelroutes,metrics,prometheus -``` - -- [ ] **Step 7: Verify compilation** - -Run: `mvn clean compile -pl cameleer-sample-app,cameleer-backend-app,cameleer-caller-app -DskipTests` -Expected: BUILD SUCCESS - -- [ ] **Step 8: Commit** - -```bash -git add cameleer-sample-app/pom.xml cameleer-sample-app/src/main/resources/application.properties -git add cameleer-backend-app/pom.xml cameleer-backend-app/src/main/resources/application.properties -git add cameleer-caller-app/pom.xml cameleer-caller-app/src/main/resources/application.properties -git commit -m "feat: add Micrometer/Prometheus deps to Spring Boot sample apps, set management port 8081" -``` - ---- - -### Task 8: Quarkus Sample App Dependencies - -**Files:** -- Modify: `cameleer-quarkus-app/pom.xml` -- Modify: `cameleer-quarkus-app/src/main/resources/application.properties` -- Modify: `cameleer-quarkus-native-app/pom.xml` -- Modify: `cameleer-quarkus-native-app/src/main/resources/application.properties` - -- [ ] **Step 1: Add Micrometer deps to quarkus-app POM** - -Add after the `camel-quarkus-management` dependency: - -```xml - - - org.apache.camel.quarkus - camel-quarkus-micrometer - - - io.quarkus - quarkus-micrometer-registry-prometheus - -``` - -- [ ] **Step 2: Update quarkus-app application.properties** - -Add after the `camel.main.jmx-enabled=true` line: - -```properties -# Management interface (separate port for metrics/health) -quarkus.management.enabled=true -``` - -- [ ] **Step 3: Add Micrometer deps to quarkus-native-app POM** - -Add after the `camel-quarkus-management` dependency: - -```xml - - - org.apache.camel.quarkus - camel-quarkus-micrometer - - - io.quarkus - quarkus-micrometer-registry-prometheus - -``` - -- [ ] **Step 4: Update quarkus-native-app application.properties** - -Add after the `camel.main.jmx-enabled=true` line: - -```properties -# Management interface (separate port for metrics/health) -quarkus.management.enabled=true -``` - -- [ ] **Step 5: Verify compilation** - -Run: `mvn clean compile -pl cameleer-quarkus-app,cameleer-quarkus-native-app -DskipTests` -Expected: BUILD SUCCESS - -- [ ] **Step 6: Commit** - -```bash -git add cameleer-quarkus-app/pom.xml cameleer-quarkus-app/src/main/resources/application.properties -git add cameleer-quarkus-native-app/pom.xml cameleer-quarkus-native-app/src/main/resources/application.properties -git commit -m "feat: add Micrometer/Prometheus deps to Quarkus sample apps, enable management interface" -``` - ---- - -### Task 9: Dockerfiles — Labels and Ports - -**Files:** -- Modify: `Dockerfile`, `Dockerfile.backend`, `Dockerfile.caller`, `Dockerfile.perf` -- Modify: `Dockerfile.quarkus`, `Dockerfile.quarkus-native` -- Modify: `Dockerfile.plain` - -- [ ] **Step 1: Update Spring Boot Dockerfiles** - -For each of `Dockerfile`, `Dockerfile.backend`, `Dockerfile.perf`, add labels and update EXPOSE. Insert after the `ENV` block, before the existing `EXPOSE` line: - -```dockerfile -LABEL prometheus.scrape="true" -LABEL prometheus.path="/actuator/prometheus" -LABEL prometheus.port="8081" -``` - -Change `EXPOSE 8080` to: -```dockerfile -EXPOSE 8080 8081 -``` - -- [ ] **Step 2: Update Dockerfile.caller** - -Add same labels as step 1. Change: -```dockerfile -EXPOSE 8081 -``` -to: -```dockerfile -EXPOSE 8080 8081 -``` - -(The app port changed from 8081 to 8080 in Task 7.) - -- [ ] **Step 3: Update Quarkus Dockerfiles** - -For `Dockerfile.quarkus` and `Dockerfile.quarkus-native`, add labels and update EXPOSE: - -```dockerfile -LABEL prometheus.scrape="true" -LABEL prometheus.path="/q/metrics" -LABEL prometheus.port="9000" -``` - -Change `EXPOSE 8080` to: -```dockerfile -EXPOSE 8080 9000 -``` - -- [ ] **Step 4: Update Dockerfile.plain** - -Add labels (no port change — plain app has no EXPOSE): - -```dockerfile -LABEL prometheus.scrape="true" -LABEL prometheus.path="/metrics" -LABEL prometheus.port="9464" -``` - -- [ ] **Step 5: Commit** - -```bash -git add Dockerfile Dockerfile.backend Dockerfile.caller Dockerfile.perf -git add Dockerfile.quarkus Dockerfile.quarkus-native Dockerfile.plain -git commit -m "feat: add Prometheus discovery labels and management port EXPOSE to all Dockerfiles" -``` - ---- - -### Task 10: K8s Manifests — Annotations and Ports - -**Files:** -- Modify: `deploy/sample-app.yaml`, `deploy/backend-app.yaml`, `deploy/caller-app.yaml`, `deploy/perf-app.yaml` -- Modify: `deploy/quarkus-app.yaml`, `deploy/quarkus-native-app.yaml` -- Modify: `deploy/plain-app.yaml` - -- [ ] **Step 1: Update Spring Boot manifests (sample, backend, perf)** - -For each of `deploy/sample-app.yaml`, `deploy/backend-app.yaml`, `deploy/perf-app.yaml`: - -Add annotations to the pod template metadata (under `template.metadata`, after `labels`): -```yaml - annotations: - prometheus.io/scrape: "true" - prometheus.io/path: "/actuator/prometheus" - prometheus.io/port: "8081" -``` - -Update the container ports section from: -```yaml - ports: - - containerPort: 8080 -``` -to: -```yaml - ports: - - name: http - containerPort: 8080 - - name: management - containerPort: 8081 -``` - -- [ ] **Step 2: Update caller-app manifest** - -In `deploy/caller-app.yaml`, add same annotations as step 1. Also fix the ports: - -Change container port from 8081 to 8080: -```yaml - ports: - - name: http - containerPort: 8080 - - name: management - containerPort: 8081 -``` - -Update the Service to target port 8080: -```yaml - ports: - - port: 8080 - targetPort: 8080 - nodePort: 30083 -``` - -- [ ] **Step 3: Update Quarkus manifests** - -For `deploy/quarkus-app.yaml` and `deploy/quarkus-native-app.yaml`: - -Add annotations: -```yaml - annotations: - prometheus.io/scrape: "true" - prometheus.io/path: "/q/metrics" - prometheus.io/port: "9000" -``` - -Update container ports: -```yaml - ports: - - name: http - containerPort: 8080 - - name: management - containerPort: 9000 -``` - -- [ ] **Step 4: Update plain-app manifest** - -In `deploy/plain-app.yaml`, add annotations and a ports section (currently has none): - -Under `template.metadata`, add: -```yaml - annotations: - prometheus.io/scrape: "true" - prometheus.io/path: "/metrics" - prometheus.io/port: "9464" -``` - -Add ports to the container spec: -```yaml - ports: - - name: metrics - containerPort: 9464 -``` - -- [ ] **Step 5: Commit** - -```bash -git add deploy/ -git commit -m "feat: add Prometheus annotations and management ports to all K8s manifests" -``` - ---- - -### Task 11: Full Build Verification - -- [ ] **Step 1: Run full compilation** - -Run: `mvn clean compile -DskipTests` -Expected: BUILD SUCCESS across all modules - -- [ ] **Step 2: Run all tests** - -Run: `mvn clean verify` -Expected: All tests pass. The existing JmxMetricsBridgeTest should still pass. The new MicrometerMetricsBridgeTest should pass. - -If any sample-app tests fail due to the management port change (e.g., tests hitting port 8081 on caller-app), update the test configuration to use the new port 8080. - -- [ ] **Step 3: Commit any test fixes if needed** - -```bash -git add -A -git commit -m "fix: update tests for management port changes" -``` - ---- - -### Task 12: Update CLAUDE.md - -**Files:** -- Modify: `CLAUDE.md` - -- [ ] **Step 1: Update the metrics section in CLAUDE.md** - -Find the line "Metrics reuse Camel's built-in JMX MBeans — no custom counters" in Key Conventions and replace with: - -``` -- Metrics: Micrometer-first when available (auto-detected via reflection), falls back to JMX MBeans for plain-app -- Agent metrics use `cameleer.*` prefix (chunks.exported, chunks.dropped, sse.reconnects, taps.evaluated, metrics.exported) -- Management ports: Spring Boot 8081 (/actuator/prometheus), Quarkus 9000 (/q/metrics), plain-app 9464 (/metrics via custom endpoint) -``` - -Also update the "MetricsBridge polls JMX" line under Known Limitations to: - -``` -- MetricsBridge falls back to JMX polling when Micrometer is not on classpath — periodic summaries only -``` - -- [ ] **Step 2: Commit** - -```bash -git add CLAUDE.md -git commit -m "docs: update CLAUDE.md with Micrometer metrics integration details" -``` diff --git a/docs/superpowers/plans/2026-04-16-env-scoped-config-url-agent.md b/docs/superpowers/plans/2026-04-16-env-scoped-config-url-agent.md deleted file mode 100644 index a888dd2..0000000 --- a/docs/superpowers/plans/2026-04-16-env-scoped-config-url-agent.md +++ /dev/null @@ -1,437 +0,0 @@ -# Env-Scoped Config URL (Agent Side) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate the agent's single env-sensitive HTTP call (`fetchApplicationConfig`) from `/api/v1/config/{app}` to `/api/v1/environments/{env}/apps/{app}/config`, and strictly validate that the server's returned `environment` matches the agent's registered env. - -**Architecture:** One method changes in `cameleer-core/src/main/java/com/cameleer/core/connection/ServerConnection.java`. Signature is unchanged — env comes from the connection's existing `registeredEnvironmentId` state populated by `register()`. Version-zero "no config stored" semantics are preserved and env is only validated when `version > 0`. Hard cut — no backward-compat fallback. - -**Tech Stack:** Java 17, Maven, JUnit 5, Mockito, Jackson. - -**Spec:** `docs/superpowers/specs/2026-04-16-env-scoped-config-url-agent-design.md` - ---- - -## Task 1: Move the URL, add strict env validation, and update tests - -**Files:** -- Modify: `cameleer-core/src/main/java/com/cameleer/core/connection/ServerConnection.java` (method `fetchApplicationConfig` at approx. lines 356-391 and surrounding `LOG.trace` + log comment) -- Modify: `cameleer-core/src/test/java/com/cameleer/core/connection/ServerConnectionTest.java` (three existing `fetchApplicationConfig_*` tests at lines 251-348 need env fixtures; three new tests appended) - -### Step 1: Write the new URL-shape test (failing) - -Append this test method to `ServerConnectionTest.java`, immediately after `fetchApplicationConfig_acceptsUnwrappedLegacyResponse` (currently ending at line 348). Use the same `@SuppressWarnings("unchecked")` and Mockito style as the surrounding tests. - -```java - @SuppressWarnings("unchecked") - @Test - void fetchApplicationConfig_usesEnvPrefixedUrl() throws Exception { - String registerJson = """ - {"instanceId":"a1","accessToken":"jwt","refreshToken":"r","heartbeatIntervalMs":30000} - """; - HttpResponse registerResp = mock(HttpResponse.class); - when(registerResp.statusCode()).thenReturn(200); - when(registerResp.body()).thenReturn(registerJson); - - HttpResponse configResp = mock(HttpResponse.class); - when(configResp.statusCode()).thenReturn(200); - when(configResp.body()).thenReturn(""" - {"config":{"application":"orders","environment":"dev","version":1},"mergedSensitiveKeys":[]} - """); - - var captor = org.mockito.ArgumentCaptor.forClass(HttpRequest.class); - when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(registerResp) - .thenReturn(configResp); - - connection.register("a1", "orders", "dev", "1.0", null, null); - connection.fetchApplicationConfig("orders"); - - verify(mockClient, times(2)).send(captor.capture(), any(HttpResponse.BodyHandler.class)); - HttpRequest configRequest = captor.getAllValues().get(1); - assertEquals( - "http://localhost:8081/api/v1/environments/dev/apps/orders/config", - configRequest.uri().toString(), - "Config fetch must use the env-prefixed URL shape"); - } -``` - -### Step 2: Write the strict env-mismatch test (failing) - -Append this immediately after the test from Step 1. - -```java - @SuppressWarnings("unchecked") - @Test - void fetchApplicationConfig_rejectsEnvMismatch() throws Exception { - String registerJson = """ - {"instanceId":"a1","accessToken":"jwt","refreshToken":"r","heartbeatIntervalMs":30000} - """; - HttpResponse registerResp = mock(HttpResponse.class); - when(registerResp.statusCode()).thenReturn(200); - when(registerResp.body()).thenReturn(registerJson); - - HttpResponse configResp = mock(HttpResponse.class); - when(configResp.statusCode()).thenReturn(200); - when(configResp.body()).thenReturn(""" - {"config":{"application":"orders","environment":"prod","version":5,"engineLevel":"REGULAR"}, - "mergedSensitiveKeys":[]} - """); - - when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(registerResp) - .thenReturn(configResp); - - connection.register("a1", "orders", "dev", "1.0", null, null); - - RuntimeException ex = assertThrows(RuntimeException.class, - () -> connection.fetchApplicationConfig("orders"), - "Agent registered as 'dev' must reject a config marked 'prod'"); - assertTrue(ex.getMessage().contains("prod") && ex.getMessage().contains("dev"), - "Error message should name both environments; got: " + ex.getMessage()); - } -``` - -### Step 3: Write the strict env-null (version > 0) test (failing) - -Append immediately after Step 2's test. - -```java - @SuppressWarnings("unchecked") - @Test - void fetchApplicationConfig_rejectsMissingEnvOnRealConfig() throws Exception { - String registerJson = """ - {"instanceId":"a1","accessToken":"jwt","refreshToken":"r","heartbeatIntervalMs":30000} - """; - HttpResponse registerResp = mock(HttpResponse.class); - when(registerResp.statusCode()).thenReturn(200); - when(registerResp.body()).thenReturn(registerJson); - - HttpResponse configResp = mock(HttpResponse.class); - when(configResp.statusCode()).thenReturn(200); - // version > 0 but no `environment` field — server bug, must be rejected - when(configResp.body()).thenReturn(""" - {"config":{"application":"orders","version":5,"engineLevel":"REGULAR"}, - "mergedSensitiveKeys":[]} - """); - - when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(registerResp) - .thenReturn(configResp); - - connection.register("a1", "orders", "dev", "1.0", null, null); - - assertThrows(RuntimeException.class, - () -> connection.fetchApplicationConfig("orders"), - "A real config (version > 0) without environment field must be rejected"); - } -``` - -### Step 4: Fix fixtures in the three existing fetchApplicationConfig tests - -Three existing tests pass `null` as the environment to `register()` and omit the `environment` field from the mocked config JSON. Under the new behavior the URL requires a real env and validation rejects missing env on real configs. Update them. - -#### Step 4a: `fetchApplicationConfig_unwrapsEnvelopeAndMergesSensitiveKeys` (around lines 251-294) - -Change the `register(...)` call at line 285 to pass environment `"dev"`: - -```java - connection.register("a1", "sample-app", "dev", null, null, null); -``` - -Add `"environment": "dev",` to the mocked envelope's `config` object. Replace the `envelope` string literal (lines 261-276) with: - -```java - String envelope = """ - { - "config": { - "application": "sample-app", - "environment": "dev", - "version": 5, - "engineLevel": "REGULAR", - "payloadCaptureMode": "BOTH", - "metricsEnabled": true, - "samplingRate": 1.0, - "applicationLogLevel": "INFO", - "agentLogLevel": "INFO" - }, - "globalSensitiveKeys": ["key1"], - "mergedSensitiveKeys": ["key1", "key2"] - } - """; -``` - -#### Step 4b: `fetchApplicationConfig_returnsNullWhenVersionZero` (around lines 297-319) - -Version-0 bypasses env validation, but `register()` still needs a non-null env because the URL builder uses it. Change the `register(...)` call at line 316 to: - -```java - connection.register("a1", "sample-app", "dev", null, null, null); -``` - -The config JSON body does **not** need an `environment` field — version 0 skips validation. Leave the body as-is: - -```java - when(configResp.body()).thenReturn(""" - {"config":{"application":"sample-app","version":0},"mergedSensitiveKeys":[]} - """); -``` - -#### Step 4c: `fetchApplicationConfig_acceptsUnwrappedLegacyResponse` (around lines 322-348) - -Change the `register(...)` call at line 342 to: - -```java - connection.register("a1", "sample-app", "dev", null, null, null); -``` - -Add `"environment":"dev",` to the bare (unwrapped) config body at line 334-336. Replace those three lines with: - -```java - when(configResp.body()).thenReturn(""" - {"application":"sample-app","environment":"dev","version":3,"engineLevel":"MINIMAL","sensitiveKeys":["Authorization"]} - """); -``` - -### Step 5: Run the tests — expect the three new tests to fail - -Run: - -```bash -mvn -pl cameleer-core test -Dtest=ServerConnectionTest -``` - -Expected: the three new tests fail for the following reasons: - -- `fetchApplicationConfig_usesEnvPrefixedUrl` — assertion mismatch: actual URI is `http://localhost:8081/api/v1/config/orders`, expected `.../api/v1/environments/dev/apps/orders/config`. -- `fetchApplicationConfig_rejectsEnvMismatch` — `assertThrows` fails because current code does not validate env and returns the (wrong-env) config normally. -- `fetchApplicationConfig_rejectsMissingEnvOnRealConfig` — `assertThrows` fails for the same reason. - -The three updated tests (`fetchApplicationConfig_unwrapsEnvelopeAndMergesSensitiveKeys`, `fetchApplicationConfig_returnsNullWhenVersionZero`, `fetchApplicationConfig_acceptsUnwrappedLegacyResponse`) will still **pass** at this point — Mockito matches `any(HttpRequest.class)` so the unchanged URL shape doesn't break them, and their fixtures (now registered as `"dev"` and, where applicable, carrying `"environment":"dev"`) already align with the env the code will later check against. - -If any of the three new tests passes unexpectedly, or if any existing test fails at this step, stop and investigate before continuing to Step 6. - -### Step 6: Implement the URL change and env validation in `fetchApplicationConfig` - -Open `cameleer-core/src/main/java/com/cameleer/core/connection/ServerConnection.java`. Replace the method `fetchApplicationConfig` (currently lines 356-391) with this implementation: - -```java - /** - * Fetches the application config from the server using the env-prefixed URL - * {@code GET /api/v1/environments/{environmentId}/apps/{applicationId}/config}. - * Returns null if no config is stored (version 0). - * Throws on network/auth errors, and on any response whose {@code environment} - * field does not match the env the agent is registered for (when {@code version > 0}). - * - *

Response envelope (see PROTOCOL.md §3): - *

-     * { "config": { ...ApplicationConfig... },
-     *   "globalSensitiveKeys": [...],
-     *   "mergedSensitiveKeys": [...] }
-     * 
- * The envelope's {@code mergedSensitiveKeys} populates {@code ApplicationConfig.sensitiveKeys} - * when the inner config does not carry its own list. - */ - public ApplicationConfig fetchApplicationConfig(String application) throws Exception { - String path = "/api/v1/environments/" + registeredEnvironmentId - + "/apps/" + application + "/config"; - LOG.trace("Cameleer: >>> GET {}", path); - - HttpRequest request = authenticatedRequest(path) - .GET() - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (LOG.isTraceEnabled()) { - LOG.trace("Cameleer: <<< GET config → HTTP {}\n{}", response.statusCode(), response.body()); - } - if (response.statusCode() == 401 || response.statusCode() == 403) { - refreshToken(); - HttpRequest retry = authenticatedRequest(path) - .GET() - .build(); - response = httpClient.send(retry, HttpResponse.BodyHandlers.ofString()); - } - - if (response.statusCode() != 200) { - throw new RuntimeException("Config fetch failed: HTTP " + response.statusCode()); - } - - JsonNode root = MAPPER.readTree(response.body()); - JsonNode configNode = root.has("config") ? root.get("config") : root; - ApplicationConfig config = MAPPER.treeToValue(configNode, ApplicationConfig.class); - - if (config.getSensitiveKeys() == null && root.has("mergedSensitiveKeys")) { - List merged = MAPPER.convertValue(root.get("mergedSensitiveKeys"), - MAPPER.getTypeFactory().constructCollectionType(List.class, String.class)); - config.setSensitiveKeys(merged); - } - - // version 0 = no config stored on server (default response); env not validated - if (config.getVersion() <= 0) { - return null; - } - - if (config.getEnvironment() == null - || !config.getEnvironment().equals(registeredEnvironmentId)) { - throw new RuntimeException( - "Config fetch returned env '" + config.getEnvironment() - + "' but agent is registered for '" + registeredEnvironmentId + "'"); - } - - return config; - } -``` - -Key differences from the old implementation: -- URL is built once into a local `path` variable from `registeredEnvironmentId` + `application`, and reused for both initial request and the 401/403 retry. -- Log trace uses the built path (so logs show the exact URL hit) rather than a hard-coded string. -- Version check reordered to precede env validation and simplified from `getVersion() > 0 ? config : null` to an early `return null`. -- New env-validation block throws `RuntimeException` with both envs in the message. - -### Step 7: Run the tests — expect all to pass - -```bash -mvn -pl cameleer-core test -Dtest=ServerConnectionTest -``` - -Expected: all `fetchApplicationConfig_*` tests pass (six total: three new, three pre-existing but with updated fixtures). All other `ServerConnectionTest` tests unaffected. - -### Step 8: Run the full `cameleer-core` test suite — verify no regression elsewhere - -```bash -mvn -pl cameleer-core test -``` - -Expected: BUILD SUCCESS. No tests fail. - -### Step 9: Commit - -```bash -git add cameleer-core/src/main/java/com/cameleer/core/connection/ServerConnection.java \ - cameleer-core/src/test/java/com/cameleer/core/connection/ServerConnectionTest.java -git commit -m "$(cat <<'EOF' -feat(agent): fetch config via env-prefixed URL with strict env validation - -fetchApplicationConfig now GETs -/api/v1/environments/{env}/apps/{app}/config, using the env stashed at -registration. Any response with version > 0 whose environment does not -match the agent's registered env is rejected; version 0 (no config -stored) still returns null and is not env-validated. - -Implements the agent side of the env-scoped config refactor; see -docs/superpowers/specs/2026-04-16-env-scoped-config-url-agent-design.md. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 2: Update PROTOCOL.md - -**Files:** -- Modify: `cameleer-common/PROTOCOL.md` (Section 3 endpoint table and subsection heading; Section 4 Reconnection bullet) - -### Step 1: Update the endpoint table row for config fetch - -In `cameleer-common/PROTOCOL.md`, find line 58 (inside the table in Section 3 "Agent→Server Endpoints"): - -``` -| GET | `/api/v1/config/{applicationId}` | — | Fetch latest config (on startup and reconnect). Response is an envelope (see below). | -``` - -Replace with: - -``` -| GET | `/api/v1/environments/{environmentId}/apps/{applicationId}/config` | — | Fetch latest config (on startup and reconnect). Response is an envelope (see below). | -``` - -### Step 2: Update the "Config Endpoint Response Envelope" subsection - -In the same file, find the paragraph at line 68: - -``` -`GET /api/v1/config/{applicationId}` returns an envelope, not a bare `ApplicationConfig`: -``` - -Replace with: - -``` -`GET /api/v1/environments/{environmentId}/apps/{applicationId}/config` returns an envelope, not a bare `ApplicationConfig`: -``` - -### Step 3: Add an "Env-scoping and validation" paragraph under the envelope spec - -Immediately after line 98 (currently ending with "...or the cached config if present)."), insert a new paragraph: - -``` -The agent strictly validates `config.environment == ` when `version > 0`. A `version=0` response is the "no config stored" signal and is **not** env-validated (agents fall back to their prior state). A `version>0` response with a missing or mismatched `environment` field is a server bug; the agent throws and keeps its prior config rather than risk applying mis-scoped settings. -``` - -### Step 4: Update the SSE reconnection bullet in Section 4 - -In `cameleer-common/PROTOCOL.md`, find line 125 (inside the numbered list under "### Reconnection"): - -``` -1. Fetch latest config via `GET /api/v1/config/{applicationId}` -``` - -Replace with: - -``` -1. Fetch latest config via `GET /api/v1/environments/{environmentId}/apps/{applicationId}/config` -``` - -### Step 5: Commit - -```bash -git add cameleer-common/PROTOCOL.md -git commit -m "$(cat <<'EOF' -docs(protocol): document env-prefixed config URL - -Section 3: endpoint table and envelope subsection now reference -/api/v1/environments/{env}/apps/{app}/config. Adds a paragraph -describing strict env validation and version-0 semantics. Section 4: -SSE reconnection bullet updated to the new URL. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 3: Full build verification - -**Files:** none - -### Step 1: Run the full build with tests - -```bash -mvn clean verify -``` - -Expected: BUILD SUCCESS across all modules. No test failures. - -### Step 2: If any non-`ServerConnection` test breaks, stop and investigate - -The spec identifies only one production call site (`ServerSetup.java:414`) and GitNexus confirms no other callers. A failing test elsewhere means an unexpected dependency — do not auto-fix; stop and surface the finding. - -### Step 3: Re-run GitNexus to refresh the knowledge graph - -```bash -npx gitnexus analyze -``` - -The index was stale from the commit in Task 1. This keeps it in sync for future work. - ---- - -## Completion checklist - -- [ ] Task 1 committed — one commit, code + tests together. -- [ ] Task 2 committed — one commit, PROTOCOL.md only. -- [ ] Task 3 passed — `mvn clean verify` green. -- [ ] GitNexus re-indexed. diff --git a/docs/superpowers/plans/2026-04-17-agent-config-endpoint-flat.md b/docs/superpowers/plans/2026-04-17-agent-config-endpoint-flat.md deleted file mode 100644 index cbd3fbb..0000000 --- a/docs/superpowers/plans/2026-04-17-agent-config-endpoint-flat.md +++ /dev/null @@ -1,382 +0,0 @@ -# Agent Config Endpoint — Flat, JWT-Resolved (Implementation Plan) - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Flip `ServerConnection.fetchApplicationConfig` from the env-scoped URL `GET /api/v1/environments/{env}/apps/{app}/config` to the flat JWT-scoped URL `GET /api/v1/agents/config`, and drop the now-unused `application` parameter from the method. - -**Architecture:** Single-file code change in `ServerConnection.java` (URL string + method signature) plus a one-line caller update in `ServerSetup.java`. Tests in `ServerConnectionTest.java` update six call sites to drop the argument and one URL assertion. `PROTOCOL.md` updates four URL references. Response body shape and validation behavior are unchanged — version-0 → null, version-0 → env match check. - -**Tech Stack:** Java 17, JUnit 5, Mockito (with `ArgumentCaptor`), Maven. - -**Spec:** `docs/superpowers/specs/2026-04-17-agent-config-endpoint-flat.md` - ---- - -## File Structure - -**Files to modify:** - -- `cameleer-core/src/main/java/com/cameleer/core/connection/ServerConnection.java` - - Responsibility: HTTP conduit to server. Owns the config-fetch URL and response validation. - - Change: method signature `(String application)` → `()`; URL `/api/v1/environments/{env}/apps/{app}/config` → `/api/v1/agents/config`; javadoc to match. -- `cameleer-core/src/main/java/com/cameleer/core/connection/ServerSetup.java` - - Responsibility: agent startup orchestration. Calls `fetchApplicationConfig` once at boot. - - Change: drop the argument at the single call site (line 414). `application` local stays (used for logging on lines 418 and 421). -- `cameleer-core/src/test/java/com/cameleer/core/connection/ServerConnectionTest.java` - - Responsibility: unit tests for `ServerConnection`. - - Change: six `fetchApplicationConfig(...)` call sites drop their argument; one URL-shape test renames to `fetchApplicationConfig_usesFlatAgentConfigUrl` and asserts the new URL. -- `cameleer-common/PROTOCOL.md` - - Responsibility: agent-server wire protocol reference. - - Change: four references to the env-scoped URL replaced with the flat URL. - -No new files. No file deletions. - ---- - -## Task 1: Flip URL and drop parameter (code + tests) - -**Files:** -- Modify: `cameleer-core/src/main/java/com/cameleer/core/connection/ServerConnection.java:342-406` -- Modify: `cameleer-core/src/main/java/com/cameleer/core/connection/ServerSetup.java:414` -- Modify: `cameleer-core/src/test/java/com/cameleer/core/connection/ServerConnectionTest.java:287,318,344,353-381,385-411,415-440` - -### Step-by-step - -- [ ] **Step 1: Rewrite URL-shape test to expect the flat URL** - -Replace the existing `fetchApplicationConfig_usesEnvPrefixedUrl` test (currently at lines 351-381 of `ServerConnectionTest.java`) with this one. The rename reflects the new behavior; the body changes to (a) call `fetchApplicationConfig()` with no argument, and (b) assert the flat URL. - -```java - @SuppressWarnings("unchecked") - @Test - void fetchApplicationConfig_usesFlatAgentConfigUrl() throws Exception { - String registerJson = """ - {"instanceId":"a1","accessToken":"jwt","refreshToken":"r","heartbeatIntervalMs":30000} - """; - HttpResponse registerResp = mock(HttpResponse.class); - when(registerResp.statusCode()).thenReturn(200); - when(registerResp.body()).thenReturn(registerJson); - - HttpResponse configResp = mock(HttpResponse.class); - when(configResp.statusCode()).thenReturn(200); - when(configResp.body()).thenReturn(""" - {"config":{"application":"orders","environment":"dev","version":1},"mergedSensitiveKeys":[]} - """); - - var captor = org.mockito.ArgumentCaptor.forClass(HttpRequest.class); - when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(registerResp) - .thenReturn(configResp); - - connection.register("a1", "orders", "dev", "1.0", null, null); - connection.fetchApplicationConfig(); - - verify(mockClient, times(2)).send(captor.capture(), any(HttpResponse.BodyHandler.class)); - HttpRequest configRequest = captor.getAllValues().get(1); - assertEquals( - "http://localhost:8081/api/v1/agents/config", - configRequest.uri().toString(), - "Config fetch must use the flat JWT-scoped agent config URL"); - } -``` - -- [ ] **Step 2: Drop the argument from the other five `fetchApplicationConfig(...)` call sites in the test file** - -In `ServerConnectionTest.java`: - -- Line 287 — change `ApplicationConfig config = connection.fetchApplicationConfig("sample-app");` to `ApplicationConfig config = connection.fetchApplicationConfig();` -- Line 318 — change `assertNull(connection.fetchApplicationConfig("sample-app"),` to `assertNull(connection.fetchApplicationConfig(),` -- Line 344 — change `ApplicationConfig config = connection.fetchApplicationConfig("sample-app");` to `ApplicationConfig config = connection.fetchApplicationConfig();` -- Line 407 — change `() -> connection.fetchApplicationConfig("orders"),` to `() -> connection.fetchApplicationConfig(),` -- Line 438 — change `() -> connection.fetchApplicationConfig("orders"),` to `() -> connection.fetchApplicationConfig(),` - -Each replacement is a deletion of the string literal argument and the matching opening/closing space — no other content on those lines changes. - -- [ ] **Step 3: Drop the argument at the single production call site** - -In `cameleer-core/src/main/java/com/cameleer/core/connection/ServerSetup.java`, change line 414 from: - -```java - ApplicationConfig appConfig = serverConnection.fetchApplicationConfig(application); -``` - -to: - -```java - ApplicationConfig appConfig = serverConnection.fetchApplicationConfig(); -``` - -Keep the `String application = config.getApplicationId();` local on line 411 — it's still used at lines 418 and 421 for log messages. - -- [ ] **Step 4: Run the tests — expect COMPILE FAILURE** - -Run: - -```bash -mvn -pl cameleer-core test -Dtest=ServerConnectionTest -``` - -Expected: compile error, e.g. `method fetchApplicationConfig in class ServerConnection cannot be applied to given types; required: java.lang.String; found: no arguments`. This confirms the tests are now pointing at the new signature and the impl has not caught up. (Classic RED.) - -- [ ] **Step 5: Update `ServerConnection.fetchApplicationConfig` — new signature, new URL, new javadoc** - -In `cameleer-core/src/main/java/com/cameleer/core/connection/ServerConnection.java`, replace the javadoc+method block currently at lines 342-406 with this version. The post-response logic (envelope unwrap, sensitive-keys merge, version-0 short-circuit, env validation) is unchanged — only the URL, the signature, and the javadoc change. - -```java - /** - * Fetches the agent's config from the server using the flat JWT-scoped URL - * {@code GET /api/v1/agents/config}. The server resolves the target - * (application, environment) from the agent's JWT subject → registry entry, - * with the JWT env claim as fallback. - * Returns null if no config is stored (version 0). - * Throws on network/auth errors, and on any response whose {@code environment} - * field does not match the env the agent is registered for (when {@code version > 0}). - * - *

Response envelope (see PROTOCOL.md §3): - *

-     * { "config": { ...ApplicationConfig... },
-     *   "globalSensitiveKeys": [...],
-     *   "mergedSensitiveKeys": [...] }
-     * 
- * The envelope's {@code mergedSensitiveKeys} populates {@code ApplicationConfig.sensitiveKeys} - * when the inner config does not carry its own list. - */ - public ApplicationConfig fetchApplicationConfig() throws Exception { - String path = "/api/v1/agents/config"; - LOG.trace("Cameleer: >>> GET {}", path); - - HttpRequest request = authenticatedRequest(path) - .GET() - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (LOG.isTraceEnabled()) { - LOG.trace("Cameleer: <<< GET config → HTTP {}\n{}", response.statusCode(), response.body()); - } - if (response.statusCode() == 401 || response.statusCode() == 403) { - refreshToken(); - HttpRequest retry = authenticatedRequest(path) - .GET() - .build(); - response = httpClient.send(retry, HttpResponse.BodyHandlers.ofString()); - } - - if (response.statusCode() != 200) { - throw new RuntimeException("Config fetch failed: HTTP " + response.statusCode()); - } - - JsonNode root = MAPPER.readTree(response.body()); - JsonNode configNode = root.has("config") ? root.get("config") : root; - ApplicationConfig config = MAPPER.treeToValue(configNode, ApplicationConfig.class); - - if (config.getSensitiveKeys() == null && root.has("mergedSensitiveKeys")) { - List merged = MAPPER.convertValue(root.get("mergedSensitiveKeys"), - MAPPER.getTypeFactory().constructCollectionType(List.class, String.class)); - config.setSensitiveKeys(merged); - } - - // version 0 = no config stored on server (default response); env not validated - if (config.getVersion() <= 0) { - return null; - } - - if (config.getEnvironment() == null - || !config.getEnvironment().equals(registeredEnvironmentId)) { - throw new RuntimeException( - "Config fetch returned env '" + config.getEnvironment() - + "' but agent is registered for '" + registeredEnvironmentId + "'"); - } - - return config; - } -``` - -- [ ] **Step 6: Run `ServerConnectionTest` — expect all tests pass** - -Run: - -```bash -mvn -pl cameleer-core test -Dtest=ServerConnectionTest -``` - -Expected: all tests green (should be 11 tests: 6 `fetchApplicationConfig_*` tests plus the 5 unrelated `register_*`, `sendData_*`, `reRegister_*`, `deregister_*`, and `heartbeat_*` tests). No failures, no errors. - -- [ ] **Step 7: Run the full `cameleer-core` test suite — expect all tests pass** - -Run: - -```bash -mvn -pl cameleer-core test -``` - -Expected: BUILD SUCCESS, no test failures. This catches any other callers of `fetchApplicationConfig` in the module (there are none today — `ServerSetup.java:414` is the only one — but the module-wide run verifies it). - -- [ ] **Step 8: Commit** - -```bash -git add cameleer-core/src/main/java/com/cameleer/core/connection/ServerConnection.java \ - cameleer-core/src/main/java/com/cameleer/core/connection/ServerSetup.java \ - cameleer-core/src/test/java/com/cameleer/core/connection/ServerConnectionTest.java -git commit -m "$(cat <<'EOF' -fix: flip agent config fetch to flat JWT-scoped URL - -The env-scoped /api/v1/environments/{env}/apps/{app}/config route -returns 403 for agents — the server's only AGENT-role config-read -endpoint is /api/v1/agents/config, which resolves target app and -environment from the JWT. fetchApplicationConfig now GETs the flat -URL and drops its now-unused `application` parameter. Strict env -validation on the response body is retained. - -Spec: docs/superpowers/specs/2026-04-17-agent-config-endpoint-flat.md - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 2: PROTOCOL.md - -**Files:** -- Modify: `cameleer-common/PROTOCOL.md:58,68,94,127` - -### Step-by-step - -- [ ] **Step 1: Update the endpoint table row (line 58)** - -In `cameleer-common/PROTOCOL.md`, change the config row in the endpoint table from: - -``` -| GET | `/api/v1/environments/{environmentId}/apps/{applicationId}/config` | — | Fetch latest config (on startup and reconnect). Response is an envelope (see below). | -``` - -to: - -``` -| GET | `/api/v1/agents/config` | — | Fetch per-agent config (server resolves application and environment from the agent's JWT). Response is an envelope (see below). | -``` - -- [ ] **Step 2: Update the response-envelope subsection heading (line 68)** - -Change the sentence: - -``` -`GET /api/v1/environments/{environmentId}/apps/{applicationId}/config` returns an envelope, not a bare `ApplicationConfig`: -``` - -to: - -``` -`GET /api/v1/agents/config` returns an envelope, not a bare `ApplicationConfig`: -``` - -- [ ] **Step 3: Update the envelope field explanation (line 94)** - -The `config` field explanation currently reads: - -``` -| `config` | `ApplicationConfig` | The per-application config document. Includes an `environment` field identifying which environment this config applies to; the server derives the value from the agent's JWT env claim at fetch time, so two agents with the same `applicationId` in different environments receive different configs. | -``` - -Change it to: - -``` -| `config` | `ApplicationConfig` | The per-agent config document. Includes an `environment` field identifying which environment this config applies to. The server resolves the target (application, environment) from the agent's JWT subject → registry entry (heartbeat-authoritative), with the JWT env claim as fallback, so two agents registered with different environments receive different configs even when their JWTs are otherwise similar. | -``` - -- [ ] **Step 4: Update the SSE reconnection bullet (line 127)** - -Change: - -``` -1. Fetch latest config via `GET /api/v1/environments/{environmentId}/apps/{applicationId}/config` -``` - -to: - -``` -1. Fetch latest config via `GET /api/v1/agents/config` -``` - -- [ ] **Step 5: Sanity-check that no stale URL remains in PROTOCOL.md** - -Run: - -```bash -grep -n "/environments/.*/apps/" cameleer-common/PROTOCOL.md -``` - -Expected: no matches. (If any line still references the env-scoped URL, update it to the flat URL before committing.) - -- [ ] **Step 6: Commit** - -```bash -git add cameleer-common/PROTOCOL.md -git commit -m "$(cat <<'EOF' -docs: update PROTOCOL.md agent config endpoint to flat URL - -Match the agent-side flip in fetchApplicationConfig. Endpoint table, -envelope heading, field description, and SSE reconnection bullet all -now reference /api/v1/agents/config with JWT-resolved (application, -environment). - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 3: Full verify - -**Files:** None modified. Verification only. - -### Step-by-step - -- [ ] **Step 1: Run the full multi-module verify** - -Run: - -```bash -mvn -q clean verify -``` - -Expected: BUILD SUCCESS across all 14 modules. No compile errors, no test failures. Specifically watch that `cameleer-core` (contains the code change), `cameleer-common` (depends on by core, unchanged model), `cameleer-agent`, and `cameleer-extension` (both depend on core) all build. - -- [ ] **Step 2: Confirm no uncommitted drift** - -Run: - -```bash -git status --short -``` - -Expected: clean except for the pre-existing working-tree entries that were already present when this plan started (AGENTS.md, CLAUDE.md, `docs/superpowers/plans/2026-04-16-bundle-log-appender-into-agent.md`, `org/`). No new staged/unstaged changes introduced by this task. - -If the `git status` output shows unexpected modifications introduced during execution (e.g. IDE rewrites, formatter drift), stop and report before proceeding. - -- [ ] **Step 3: No commit in this task — verification only** - -Nothing to add, nothing to commit. The branch is ready to push. - ---- - -## Self-Review - -**Spec coverage:** - -- Spec §"Endpoint" (flat URL): covered in Task 1 Step 5 — `path = "/api/v1/agents/config"`. -- Spec §"`ServerConnection.fetchApplicationConfig`" (drop parameter): covered in Task 1 Steps 2, 3, 5. -- Spec §"Post-response checks" (unchanged order — 401+403 retry, deserialize, merge, version-0 bypass, strict env validation): covered in Task 1 Step 5 (same method body as before, just URL + signature changed). -- Spec §"Fields and state" (`registeredEnvironmentId` retained; cache key retained; heartbeat env retained): no-op — no code touches those. -- Spec §"PROTOCOL.md" (endpoint table, envelope heading, config field explanation, SSE reconnection bullet): covered in Task 2 Steps 1-4. -- Spec §"Error handling" table: unchanged by this plan — the same method body preserves the behavior (401/403 refresh-retry, non-200 throw, version-0 null, env mismatch/null throw). -- Spec §"Testing" (URL shape, strict mismatch rejection, strict null rejection, happy path): URL-shape test updated in Task 1 Step 1; the other three retained unchanged except for the 0-arg call in Task 1 Step 2. -- Spec §"Non-goals" (no changes to /data/*, /agents/{id}/*, heartbeat body, protocol version, registeredEnvironmentId): plan makes no changes to those files — confirmed by Task 3 Step 2 `git status`. - -All spec items mapped to at least one step. No gaps. - -**Placeholder scan:** no "TBD", no "implement later", no "similar to", no handwavy "add appropriate error handling". Every code change is shown in full. Every command is exact. - -**Type consistency:** the method is `fetchApplicationConfig()` (0-arg, returns `ApplicationConfig`) throughout Task 1. The six test call sites (Step 1 new, Step 2 five existing) all call the 0-arg form. The one production call site in `ServerSetup` (Step 3) is the 0-arg form. The impl signature in Step 5 matches. URL string `/api/v1/agents/config` is identical in Step 1 assertion, Step 5 impl, and Task 2 Steps 1-4 docs. diff --git a/docs/superpowers/specs/2026-03-16-diagram-execution-overlay-correctness-design.md b/docs/superpowers/specs/2026-03-16-diagram-execution-overlay-correctness-design.md deleted file mode 100644 index 0ecd7b5..0000000 --- a/docs/superpowers/specs/2026-03-16-diagram-execution-overlay-correctness-design.md +++ /dev/null @@ -1,226 +0,0 @@ -# Diagram-Execution Overlay Correctness - -**Date:** 2026-03-16 -**Status:** Draft -**Scope:** Audit + fix design for the route diagram extraction, execution collection, and processor-to-diagram mapping system - -## Context - -Cameleer instruments Apache Camel routes to collect execution path information. Two artifacts are produced: - -1. **Static RouteGraph** — extracted at startup from Camel route definitions. Contains nodes (processors), edges (flow/branch/error/cross-route), tree hierarchy, and a `processorNodeMapping` (Camel processorId → diagram nodeId). -2. **Runtime RouteExecution** — collected per exchange. Contains a tree of `ProcessorExecution` nodes, each tagged with a `diagramNodeId` from the mapping. - -The visualization model is **full diagram with execution overlay**: the user sees the complete route design (all branches, loops, error handlers) and the overlay "paints" which processors and edges were activated. Unpainted nodes show what *didn't* happen. For loops and splits, users iterate through per-iteration execution data. - -### Cross-Route Behavior - -Each route is a separate `RouteGraph`. Cross-route calls (`to("direct:...")`) show as clickable nodes that expand inline to show the target route's diagram (collapsed by default, expandable on demand). Runtime correlation via `X-Cameleer-CorrelationId` links executions across routes. - -### Dynamic Routing - -Dynamic endpoints (`toD()`, `recipientList()`, `routingSlip()`, `dynamicRouter()`) show as static placeholder nodes in the diagram. The execution overlay renders ephemeral "discovered" sub-nodes with resolved URIs, visible only when viewing that execution. - -### Error Handlers - -Route-level error handlers (`onException`, `errorHandler`) appear as floating blocks in the diagram, not connected to the main flow. When an execution triggers one, the overlay draws a dynamic "diverted to error handler" indicator from the failing processor to the handler block. Inline `doTry/doCatch/doFinally` remains as nested nodes with ERROR edges. - -## Audit Findings - -### HIGH — Execution data lost or silently wrong - -#### H1: Loop Iteration Tracking Not Implemented - -**Location:** `ExecutionCollector.onProcessorStart()` (split detection block), `RouteModelExtractor.processDefinition()` (LoopDefinition handler) - -Split has full iteration support: `splitIteration` wrappers, `splitIndex`/`splitSize`/`splitDepth`, exchange-ID-keyed stacks. Loop has nothing. A `.loop(3)` executes inner processors 3 times but they appear as 3 flat, unrelated entries. The user cannot step through loop iterations. - -#### H2: onException Handler Processors May Miss Diagram Node Mapping - -**Location:** `ExecutionCollector.onProcessorStart()` (mapping lookup), `RouteModelExtractor.processDefinition()` (OnExceptionDefinition handler) - -`ExecutionCollector` uses `exchange.getFromRouteId()` to look up `processorNodeMapping`. When an onException handler fires, the exchange still reports the original route's ID. The onException processors are extracted into the route's graph — so their IDs *should* be in the route's mapping (via `processChildren()`). However, this is untested and the runtime behavior when the handler creates ProcessorExecutions needs verification: they should be tagged or wrapped to indicate they're error handler executions. - -#### H3: Null ProcessorId Passes Silently Through the System - -**Location:** `CameleerInterceptStrategy.wrapProcessorInInterceptors()` (`definition.getId()` call), `ExecutionCollector.onProcessorStart()` (ProcessorExecution creation) - -`definition.getId()` can be null for synthetic/internal Camel processors (Pipeline, Channel, UnitOfWork wrappers). This null flows into `ExecutionCollector.onProcessorStart()` → `ProcessorExecution` is created with `processorId=null`, `diagramNodeId=null`. No log, no skip. The execution tree contains ghost entries. - -### MEDIUM — Diagram structure incomplete or overlay inaccurate - -#### M4: Cross-Route Edge URI Mismatch with Query Parameters - -**Location:** `RouteModelExtractor.processTo()` - -`to("direct:foo?timeout=5000")` produces CROSS_ROUTE edge target `"direct:foo?timeout=5000"` but target route's `from()` is `"direct:foo"`. Server/UI matching fails. - -#### M5: Inline Multicast Body Execution Not Tracked as Branches - -**Location:** `ExecutionCollector.onProcessorStart()` (multicast deferral comment), `RouteModelExtractor.processMulticast()` - -Diagram extracts multicast children. Runtime doesn't detect multicast sub-exchanges (no `SPLIT_INDEX` equivalent checked). Execution tree shows multicast body processors flat. The common `multicast().to("direct:...")` pattern works via correlation, but inline multicast bodies break. - -#### M6: CircuitBreaker Fallback Processor Mapping Untested - -**Location:** `RouteModelExtractor.processCircuitBreaker()` - -`OnFallbackDefinition` is extracted via special handling (not in `getOutputs()`). `processChildren()` should add processor IDs to the mapping, but no test verifies this. Runtime fallback execution mapping is also unverified. - -#### M7: No Edge Activation Data in Execution Model - -**Status:** Deferred (not in scope for this work) - -The execution model captures which nodes executed but not which edges were traversed. The UI must infer edge activation from the execution tree. This is acceptable once the tree is correct (which this work delivers). Can be revisited if the server team finds inference insufficient. - -### LOW — Out of scope - -- **L8: Filter children shown unconditionally** — Correct behavior; overlay shows them as unexecuted when predicate is false. -- **L9: Dynamic endpoint URI mismatch** — By design; overlay uses `diagramNodeId` mapping, not URI matching. -- **L10: REST DSL routes not extracted** — Known; trivial bridge routes with `inlineRoutes(false)`. - -## Design - -### Fix 1: Null ProcessorId Handling (H3) - -**Goal:** Prevent null processorIds from creating ghost entries; make unmapped processors visible during development. - -**Changes:** - -1. `CameleerInterceptStrategy.wrapProcessorInInterceptors()`: If `definition.getId()` is null, compute a local synthetic ID: `processorType + "-synthetic-" + atomicCounter.incrementAndGet()` and pass it to `onProcessorStart()`. **Do not mutate the Camel definition object** — `NamedNode` is read-only and setting IDs on Camel's internal model could cause side effects with other Camel features that rely on null IDs for internal processors. - -2. `ExecutionCollector.onProcessorStart()`: After mapping lookup, if `diagramNodeId` resolves to null, log at DEBUG level: `"Unmapped processor: id={}, type={}, routeId={}"`. Makes unmapped processors visible without production noise. - -3. Processors with synthetic IDs (Camel internals like Pipeline, Channel) won't have diagram nodes — `diagramNodeId` will be null. The UI filters these by checking `diagramNodeId == null`. - -**Files:** `CameleerInterceptStrategy.java`, `ExecutionCollector.java` - -### Fix 2: onException Handler Mapping (H2) - -**Goal:** Verify onException processor IDs map correctly; tag error handler executions in the execution tree. - -**Changes:** - -1. Write an end-to-end test: define a route with `onException(Exception.class).log("handled").to("mock:error")`, start the context, extract the diagram, verify onException child processor IDs appear in `processorNodeMapping` under the parent route. - -2. If mapping is already correct (likely since `processChildren()` adds mappings), the fix is test coverage. - -3. In `ExecutionCollector`: when processors execute inside an onException handler context, tag the ProcessorExecution so the UI can distinguish error handler execution from normal flow and draw the dynamic divert indicator. **Concrete model change:** Add a nullable `String errorHandlerType` field to `ProcessorExecution` in `cameleer-common`. Set it to `"onException"` when the processor executes inside an error handler context. Serializes as `"errorHandlerType": "onException"` in JSON. - -4. **Detection mechanism:** When an onException handler is active, Camel sets `Exchange.FAILURE_HANDLED` (Boolean) on the exchange. In `onProcessorStart()`, check this property — if true and the current processorType matches an onException handler's child processor, set `errorHandlerType`. Additionally, the processorType from the InterceptStrategy's `definition.getShortName()` will be available to identify handler-internal processors. - -**Files:** `ProcessorExecution.java` (new field), `RouteModelExtractorTest.java`, `ExecutionCollector.java`, `ExecutionCollectorTest.java` - -### Fix 3: Loop Iteration Tracking (H1) - -**Goal:** Give loops the same iteration-stepping experience as splits. - -**Changes:** - -1. **Detection in ExecutionCollector.onProcessorStart():** Check for the `CamelLoopIndex` exchange property (Integer). Note: unlike `Exchange.SPLIT_INDEX`, the loop properties may not have public constants in Camel 4.x — define local string constants (`LOOP_INDEX_KEY = "CamelLoopIndex"`, `LOOP_SIZE_KEY = "CamelLoopSize"`) or use `ExchangePropertyKey.LOOP_INDEX.getName()` if available. Also check `CamelLoopSize` (Integer) — this is set for counted `loop(N)` but **not** for `loopDoWhile(predicate)` where the total is unknown. - -2. **Iteration wrapper:** When `CamelLoopIndex` is detected and the current iteration index differs from the last seen index for this exchange, create a synthetic `loopIteration` ProcessorExecution wrapper. Attach subsequent processors as children of this wrapper. - -3. **Key difference from split:** Loop reuses the same exchange (no sub-exchanges). Detect iteration boundaries by watching `CamelLoopIndex` change — when it increments, complete the current iteration wrapper and start a new one. - -4. **Stack depth awareness:** The `activeLoops` map's `loopState` must track the stack depth of the loop processor itself. When `onProcessorStart()` detects a changed `CamelLoopIndex`, it should only create a new iteration wrapper if the current stack depth matches the loop's depth (i.e., we are returning to the loop's direct child level, not still nested inside the previous iteration). This prevents deeply nested processors within a loop iteration from incorrectly triggering new iteration wrappers. - -5. **New fields on ProcessorExecution:** `loopIndex` (Integer) and `loopSize` (Integer, nullable — null for `doWhile` loops where total is unknown until completion). - -6. **Active loop tracking:** Add `activeLoops` map (`exchangeId → loopState`) in ExecutionCollector. `loopState` tracks: current iteration index, the loop's ProcessorExecution, the stack depth at loop entry, and the current iteration wrapper. When the loop processor completes, clean up iteration wrappers. - -7. **doWhile support:** For `loopDoWhile(predicate)`, `loopSize` is null during iteration. After the loop completes, the final iteration count can be derived from the number of `loopIteration` children. The UI shows iteration tabs without a total count indicator. - -8. **Diagram mapping:** The loop node has a `diagramNodeId`. `loopIteration` wrappers are synthetic (no diagram node) — UI renders them as iteration tabs within the loop node, same as split. - -**Files:** `ProcessorExecution.java`, `ExecutionCollector.java`, `ExecutionCollectorTest.java` - -### Fix 4: Cross-Route Edge URI Normalization (M4) - -**Goal:** CROSS_ROUTE edge targets match target route `from()` URIs. - -**Changes:** - -1. In `RouteModelExtractor.processTo()`: when creating a CROSS_ROUTE edge for `direct:` or `seda:` endpoints, strip query parameters from the edge target URI. Simple `uri.split("\\?")[0]` — no full URI parser needed. Note: this `split("\\?")` approach is safe only because it is guarded by the `direct:`/`seda:` prefix check. If extended to other endpoint types in the future (e.g., `sql:` where `?` appears in queries), use `java.net.URI` parsing instead. - -2. The node's `endpointUri` keeps the full URI (useful debugging context). Only the edge target is normalized. - -3. Precautionary: also normalize `from()` endpoint URIs when building the `routeId` → root node relationship, so both sides use the same stripped form. In practice, `from("direct:...")` rarely has query parameters, but this makes the matching robust. - -**Files:** `RouteModelExtractor.java`, `RouteModelExtractorTest.java` - -### Fix 5: Inline Multicast Branch Tracking (M5) - -**Goal:** Multicast sub-exchanges get branch wrappers analogous to split's iteration wrappers. - -**Changes:** - -1. **Research:** Verify that Camel 4.x sets `CamelMulticastIndex` property on multicast sub-exchanges. Based on existing codebase comments, Camel does NOT reliably set multicast-specific index properties for all cases. The property may only be set when using `parallelProcessing()`. This fix is scoped to `parallelProcessing()` multicasts where the property is available; sequential inline multicast bodies remain untracked (a known Camel limitation). - -2. **Detection in ExecutionCollector.onProcessorStart():** Check for `CamelMulticastIndex` (Integer) property. If present, this is a multicast sub-exchange with parallel processing. - -3. **Branch wrapper:** Create a synthetic `multicastBranch` ProcessorExecution wrapper. Set a new `multicastIndex` (Integer) field. - -4. **Parent lookup:** Same mechanism as split — `findParentExchangeId()` checks `CamelParentExchangeId`. - -5. **Active multicast tracking:** Add `activeMulticasts` map (exchangeId → ProcessorExecution) in ExecutionCollector. - -6. **Known limitation:** Sequential inline multicast bodies (without `parallelProcessing()`) are not tracked as branches. The common `multicast().to("direct:...")` pattern continues to work via cross-route correlation regardless. - -**Files:** `ProcessorExecution.java`, `ExecutionCollector.java`, `ExecutionCollectorTest.java` - -### Fix 6: CircuitBreaker Fallback Mapping Verification (M6) - -**Goal:** Verify fallback processor IDs are correctly mapped; fix if not. - -**Changes:** - -1. Write a test: define a route with `circuitBreaker().to("mock:service").onFallback().log("fallback").to("mock:fallback")`, start the context, extract the diagram, verify fallback child processor IDs appear in `processorNodeMapping`. - -2. Verify at runtime that when the fallback executes, `exchange.getFromRouteId()` returns the original route. If it does, the mapping lookup works. If not, ensure fallback processor IDs are registered under the parent route's mapping. - -3. If mapping is missing from extraction, fix `processCircuitBreaker()` to explicitly add mappings for fallback children. - -**Files:** `RouteModelExtractorTest.java`, possibly `RouteModelExtractor.java` - -## Implementation Order - -Fixes are ordered by dependency and risk: - -1. **Fix 1 (null processorId)** — foundational, unblocks debugging of all other issues -2. **Fix 2 (onException mapping)** — test-first, may already work -3. **Fix 6 (circuitBreaker fallback)** — test-first, same pattern as Fix 2 -4. **Fix 4 (cross-route URI)** — isolated string normalization, no model changes -5. **Fix 3 (loop iterations)** — model change + collector logic, largest scope -6. **Fix 5 (multicast branches)** — depends on Camel research, may be skipped - -## Test Strategy - -Each fix gets: -- **Unit tests** in the relevant test class (RouteModelExtractorTest or ExecutionCollectorTest) -- **Verification** that existing tests still pass (no regressions) - -**Fix 1 (null processorId):** Test that synthetic IDs are generated for processors with null `definition.getId()`. Test that DEBUG log fires for unmapped processors. - -**Fix 2 (onException mapping):** End-to-end test: route with `onException`, start context, extract diagram, verify onException child processor IDs in `processorNodeMapping`. Test that `errorHandlerType` is set on ProcessorExecution when exception is handled. - -**Fix 3 (loop iterations):** Mirror the existing split test patterns: -- Single counted loop (`loop(3)`) — verify 3 `loopIteration` wrappers with correct `loopIndex`/`loopSize` -- `loopDoWhile(predicate)` — verify `loopIteration` wrappers with `loopSize=null` -- Nested loop (loop inside loop) — verify correct depth tracking -- Loop with error in iteration N — verify partial iteration wrappers -- Loop with zero iterations — verify no iteration wrappers created -- Multiple processors per iteration — verify single wrapper (same as split test pattern) - -**Fix 4 (cross-route URI):** Test `to("direct:foo?timeout=5000")` produces edge target `"direct:foo"`. Test that `endpointUri` on the node retains full URI. - -**Fix 5 (multicast branches):** Test `multicast().parallelProcessing().process().log()` produces `multicastBranch` wrappers with correct `multicastIndex`. Test that sequential multicast (no `parallelProcessing()`) falls back gracefully. - -**Fix 6 (circuitBreaker fallback):** Test that fallback child processor IDs appear in `processorNodeMapping`. Test that runtime fallback execution maps to diagram nodes. - -## Out of Scope - -- Explicit edge activation tracking (M7) — deferred to future work -- Filter conditional semantics (L8) — cosmetic -- Dynamic endpoint URI display (L9) — by design -- REST DSL route extraction (L10) — known limitation diff --git a/docs/superpowers/specs/2026-03-26-camel-native-extracts-design.md b/docs/superpowers/specs/2026-03-26-camel-native-extracts-design.md deleted file mode 100644 index fabb1e6..0000000 --- a/docs/superpowers/specs/2026-03-26-camel-native-extracts-design.md +++ /dev/null @@ -1,57 +0,0 @@ -# Camel-Native Taps, Business Attributes & Enhanced Replay - -## Context - -Competitive analysis against nJAMS Client for BW identified two priority gaps: (1) runtime data extraction with business attributes, and (2) enhanced replay with server-provided payloads. Cameleer already leads in Camel-native instrumentation but lacked the ability to tap searchable business keys from exchange payloads at runtime. - -## Strategy - -Camel-first approach: leverage Camel's own expression language ecosystem (Simple, JsonPath, XPath, JQ, Groovy) via `CamelContext.resolveLanguage()` rather than copying nJAMS's TIBCO-centric XPath/Regex/JMESPath model. This gives Camel developers extraction using the same expressions they already know. - -## Design Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Expression engine | Camel's language resolver | Guaranteed compat with app's Camel version, all installed languages | -| Tap targeting | processorId only | Simple, precise; server UI enumerates from diagram data | -| Eval timing | Hot path with timeout (50ms) | Result immediately on ProcessorExecution; timeout kills slow expressions | -| Recording store | Server-side only | Agent doesn't store recorded data; server sends payload with replay | -| SLA detection | Server-side only | Agent provides clean data; server owns alerting logic | -| Replay targeting | Per agent instance | Commands address specific agent, not broadcast | -| Attribute merge | First-write-wins | Multiple processors tapping same key: first wins at route level | - -## Components - -### TapDefinition (cameleer-common) -Data model for tap rules pushed via SSE. Fields: tapId, processorId, target (INPUT/OUTPUT/BOTH), expression, language, attributeName, attributeType, enabled, version. - -### TapEvaluator (cameleer-agent) -Core evaluation engine. Holds active taps keyed by processorId. Uses CamelContext language resolver with expression caching. Enforces per-expression timeout and max results cap. Thread-safe via ConcurrentHashMap + volatile reference swap. - -### Business Attributes -`Map attributes` added to ProcessorExecution and RouteExecution. Route-level attributes aggregated via `mergeAttributes()` (first-write-wins via `putIfAbsent`). - -### set-taps SSE Command -Full replacement semantics. Persisted to config cache for startup recovery. ACK includes active definition count. - -### Enhanced Replay -`originalExchangeId` added to replay command for audit trail. Uses `ProducerTemplate.send()` to capture `replayExchangeId` in ACK response. Server provides payload (no local recording). - -### Per-Route Recording Toggle -Server pushes `routeRecording` map via `config-update`. Agent converts to `disabledRoutes` set. Check in `onExchangeCreated()` before RouteExecution creation — zero overhead for disabled routes. - -### Compress Successful Transactions -Strips `processors` array from successful RouteExecutions before export. Jackson `@JsonInclude(NON_NULL)` auto-omits. Failed executions always retain full tree. Config: `cameleer.execution.compressSuccess` (default false). - -## Modified Files - -- `cameleer-common/.../model/TapDefinition.java` (NEW) -- `cameleer-common/.../model/ProcessorExecution.java` (attributes field) -- `cameleer-common/.../model/RouteExecution.java` (attributes field + mergeAttributes) -- `cameleer-common/.../model/ApplicationConfig.java` (taps, tapVersion, routeRecording, compressSuccess) -- `cameleer-common/PROTOCOL.md` (sections 12-15) -- `cameleer-agent/.../tap/TapEvaluator.java` (NEW) -- `cameleer-agent/.../collector/ExecutionCollector.java` (tap eval + route recording check + compression) -- `cameleer-agent/.../command/DefaultCommandHandler.java` (set-taps + enhanced replay) -- `cameleer-agent/.../CameleerAgentConfig.java` (tap + recording + compression config) -- `cameleer-agent/.../instrumentation/CameleerHookInstaller.java` (evaluator init + cached taps) diff --git a/docs/superpowers/specs/2026-03-27-agent-feature-pack-design.md b/docs/superpowers/specs/2026-03-27-agent-feature-pack-design.md deleted file mode 100644 index 84c4287..0000000 --- a/docs/superpowers/specs/2026-03-27-agent-feature-pack-design.md +++ /dev/null @@ -1,661 +0,0 @@ -# Agent Feature Pack — Design Spec - -Six agent-side features that extend Cameleer's observability capabilities beyond nJAMS parity, plus a server handoff spec for dependency mapping. - -## Feature Summary - -| # | Feature | Complexity | Dependencies | -|---|---------|-----------|--------------| -| 1 | Error Classification | Small | None | -| 2 | Sampling | Small | None | -| 3 | Circuit Breaker State Detection | Small | None | -| 4 | Startup Self-Test + Health + Events | Medium | None | -| 5 | ~~Multi-CamelContext Support~~ | ~~Large~~ | **DEFERRED** — see [#55](https://gitea.siegeln.net/cameleer/cameleer/issues/55) | -| 6 | OTel Hybrid | Large | None | -| — | Dependency Mapping (server handoff) | N/A | Server-side only | - -Features 1–3 are independent and can be parallelized. Feature 4 is independent but touches HookInstaller. Feature 5 (multi-context) is deferred — see [#55](https://gitea.siegeln.net/cameleer/cameleer/issues/55). Feature 6 (OTel) works with the current single-context architecture. - ---- - -## 1. Error Classification - -### Problem - -`RouteExecution.fail()` and `ProcessorExecution.fail()` capture `errorMessage` (string) and `errorStackTrace` (first 10 frames). No exception class name, no categorization, no root cause chain. The server cannot group or classify errors without parsing stack traces. - -### Design - -Add 4 fields to both `RouteExecution` and `ProcessorExecution`: - -| Field | Type | Description | -|-------|------|-------------| -| `errorType` | `String` | Fully qualified exception class name (e.g. `java.net.SocketTimeoutException`) | -| `errorCategory` | `String` | Auto-classified: `TIMEOUT`, `CONNECTION`, `VALIDATION`, `SECURITY`, `RESOURCE`, `UNKNOWN` | -| `rootCauseType` | `String` | Class name of the deepest cause in the chain | -| `rootCauseMessage` | `String` | Message of the deepest cause | - -New `ErrorClassifier` utility in `cameleer-common/src/main/java/com/cameleer/common/model/ErrorClassifier.java`: - -```java -public class ErrorClassifier { - - public enum Category { - TIMEOUT, CONNECTION, VALIDATION, SECURITY, RESOURCE, UNKNOWN - } - - public static Category classify(Throwable t) { ... } - public static Throwable getRootCause(Throwable t) { ... } -} -``` - -Classification rules (checked against each cause in the chain, deepest match wins): - -| Category | Exception patterns | -|----------|-------------------| -| `TIMEOUT` | `*TimeoutException`, `*TimedOutException` | -| `CONNECTION` | `ConnectException`, `UnknownHostException`, `NoRouteToHostException`, `*ConnectionException`, `*ConnectionRefused*` | -| `VALIDATION` | `IllegalArgumentException`, `*ValidationException`, `*ParseException`, `NumberFormatException` | -| `SECURITY` | `*AccessDenied*`, `SecurityException`, `*AuthenticationException`, `*AuthorizationException`, `*Unauthorized*` | -| `RESOURCE` | `OutOfMemoryError`, `StackOverflowError`, `FileNotFoundException`, `NoSuchFileException`, `DiskQuotaExceededException`, `SQLException` | -| `UNKNOWN` | Everything else | - -Classification walks the cause chain. Pattern matching uses class name substring checks (not `instanceof`, to handle exceptions from app classloaders the agent can't load). The first matching category wins (deepest cause checked first). If no pattern matches any cause in the chain, the category is `UNKNOWN`. - -### Changes to `fail()` methods - -Both `RouteExecution.fail()` and `ProcessorExecution.fail()` gain: - -```java -this.errorType = exception.getClass().getName(); -this.errorCategory = ErrorClassifier.classify(exception).name(); -Throwable root = ErrorClassifier.getRootCause(exception); -if (root != exception) { - this.rootCauseType = root.getClass().getName(); - this.rootCauseMessage = root.getMessage(); -} -``` - -### Files to modify - -| File | Change | -|------|--------| -| `common/model/RouteExecution.java` | Add 4 fields + update `fail()` | -| `common/model/ProcessorExecution.java` | Add 4 fields + update `fail()` | -| `common/model/ErrorClassifier.java` | **NEW** — classification logic + root cause extraction | - ---- - -## 2. Sampling - -### Problem - -`ApplicationConfig.samplingRate` exists (default 1.0) but is never consumed by the agent. Every exchange is recorded regardless of volume. High-throughput routes can overwhelm the server. - -### Design - -**Config model:** - -| Config | Location | Default | Description | -|--------|----------|---------|-------------| -| `cameleer.sampling.rate` | System property | `1.0` | Global sampling rate (0.0–1.0) | -| `samplingRate` | `ApplicationConfig` | `1.0` | Server-controlled global rate (existing field) | -| `routeSamplingRates` | `ApplicationConfig` | `null` | Server-controlled per-route overrides: `Map` | - -`CameleerAgentConfig` additions: -- `volatile double samplingRate = 1.0` -- `volatile Map routeSamplingRates = Map.of()` -- `double getRouteSamplingRate(String routeId)` — checks per-route map first, falls back to global - -**Sampling decision** in `ExecutionCollector.onExchangeCreated()`, after the route-recording check and before creating the RouteExecution: - -```java -double rate = config.getRouteSamplingRate(routeId); -if (rate < 1.0 && ThreadLocalRandom.current().nextDouble() >= rate) { - LOG.trace("Cameleer: Sampled out exchange [{}] route={} (rate={})", - exchangeId, routeId, rate); - return; -} -``` - -**Key behaviors:** -- `1.0` = record everything (default, backward compatible) -- `0.0` = record nothing -- Per-route overrides take precedence over global -- `ThreadLocalRandom` — no contention, no allocation -- Sampled-out exchanges are fully skipped (no RouteExecution, zero overhead) -- Sampling is independent of the recording toggle (disabled routes never reach the sampling check) - -**Diff tracking** in `applyServerConfigWithDiff()`: -- `samplingRate` changes reported as `ConfigChange` -- `routeSamplingRates` changes reported as `ConfigChange` - -### Files to modify - -| File | Change | -|------|--------| -| `common/model/ApplicationConfig.java` | Add `routeSamplingRates` field + getter/setter | -| `agent/CameleerAgentConfig.java` | Add sampling fields, `getRouteSamplingRate()`, diff tracking | -| `agent/collector/ExecutionCollector.java` | Add sampling check in `onExchangeCreated()` | -| `agent/command/DefaultCommandHandler.java` | Wire sampling rate changes from config-update | - ---- - -## 3. Circuit Breaker State Detection - -### Problem - -`RouteModelExtractor` creates diagram nodes for circuit breakers and their fallback branches, but no runtime state (OPEN/CLOSED/HALF_OPEN) or fallback execution is captured in execution data. - -### Design - -Add 2 fields to `ProcessorExecution`: - -| Field | Type | Description | -|-------|------|-------------| -| `circuitBreakerState` | `String` | `CLOSED`, `OPEN`, `HALF_OPEN` — null if not a CB processor | -| `fallbackTriggered` | `Boolean` | true when fallback path executed — null if not applicable | - -**Detection approach** — execution-time only, no polling: - -Camel 4.x exposes circuit breaker outcome via 5 boolean exchange properties (not a single state string): -- `CamelCircuitBreakerResponseSuccessfulExecution` — normal execution succeeded -- `CamelCircuitBreakerResponseFromFallback` — fallback was invoked -- `CamelCircuitBreakerResponseShortCircuited` — CB was open, request rejected -- `CamelCircuitBreakerResponseTimedOut` — execution timed out -- `CamelCircuitBreakerResponseRejected` — rejected (bulkhead/rate limit) - -The agent reads these in `onProcessorComplete()` when the processor type is `circuitBreaker` and derives the state: - -```java -Boolean shortCircuited = exchange.getProperty("CamelCircuitBreakerResponseShortCircuited", Boolean.class); -Boolean fromFallback = exchange.getProperty("CamelCircuitBreakerResponseFromFallback", Boolean.class); -Boolean successful = exchange.getProperty("CamelCircuitBreakerResponseSuccessfulExecution", Boolean.class); - -if (Boolean.TRUE.equals(shortCircuited)) { - processorExec.setCircuitBreakerState("OPEN"); -} else if (Boolean.TRUE.equals(successful)) { - processorExec.setCircuitBreakerState("CLOSED"); -} -// HALF_OPEN is indistinguishable from CLOSED at the exchange level - -if (Boolean.TRUE.equals(fromFallback)) { - processorExec.setFallbackTriggered(true); -} -``` - -If none of these properties are set (no Resilience4j, older Camel), fields stay null — graceful degradation. - -Note: `HALF_OPEN` cannot be reliably detected from exchange properties alone (a successful probe execution in HALF_OPEN looks identical to CLOSED). The `circuitBreakerState` field captures what the exchange experienced, not the CB's internal state machine. - -### Files to modify - -| File | Change | -|------|--------| -| `common/model/ProcessorExecution.java` | Add `circuitBreakerState` + `fallbackTriggered` fields | -| `agent/collector/ExecutionCollector.java` | Read CB state in processor lifecycle methods | - ---- - -## 4. Startup Self-Test + Health Endpoint + Server Events - -### Problem - -Startup has many try-catch blocks that swallow failures. No health endpoint. Route add/remove events are logged locally but not reported to the server. Operators have no way to monitor agent health or know when topology changes. - -### Design — Three Components - -### 4a. Startup Report - -New `StartupReport` class in `agent/health/` that collects check results and produces a structured log: - -``` -=== Cameleer Startup Report === - Agent ID: sample-app-12345 - Engine Level: REGULAR - Routes: 6 detected, 6 instrumented [PASS] - Diagrams: 6 extracted [PASS] - Server: Connected (HTTP exporter active) [PASS] - Log Forwarding: Active (Logback, level=INFO) [PASS] - Metrics: JMX bridge active [PASS] - Prometheus: :9464/metrics [PASS] - Taps: 3 active definitions [PASS] - Config: Server config v12 applied [PASS] -=============================== -``` - -Each check is a `HealthCheck` record: `(name, status, detail)` where status is `PASS`/`WARN`/`FAIL`. - -Checks: -- **Routes**: count > 0, all have processor IDs assigned → PASS; 0 routes → WARN -- **Intercept**: wrapped processor count matches route model processor count → PASS; mismatch → WARN -- **Server**: HTTP exporter active → PASS; LOG-only when HTTP was configured → WARN; connection failed → FAIL -- **Log forwarding**: installed → PASS; failed → WARN; disabled → info only -- **Metrics**: JMX bridge available → PASS; unavailable → WARN -- **Diagrams**: all routes extracted → PASS; partial → WARN; disabled → info only -- **Config**: server config applied with version → PASS; using cache → WARN; using defaults → info only - -Logged at INFO. Individual WARN/FAIL items get separate WARN log lines. - -### 4b. Health Endpoint - -New `HealthEndpoint` in `agent/health/`: - -- Path: `/cameleer/health` (configurable via `cameleer.health.path`) -- JSON response: - ```json - { - "status": "UP", - "agentId": "sample-app-12345", - "uptime": "PT2H30M", - "contexts": ["camel-1"], - "checks": { - "routes": { "status": "PASS", "detail": "6 detected, 6 instrumented" }, - "server": { "status": "PASS", "detail": "Connected (HTTP exporter active)" } - } - } - ``` -- Aggregate status: `UP` (all PASS), `DEGRADED` (any WARN), `DOWN` (any FAIL) -- Reuses `StartupReport` check logic, re-evaluated per request -- Config: `cameleer.health.enabled` (default `true`) - -**HTTP server strategy:** If `PrometheusEndpoint` is already running, add a handler to its HTTP server. Otherwise, start a lightweight `com.sun.net.httpserver.HttpServer` on the configured port (default 9464, same as Prometheus default). If Prometheus is on a different port, health gets its own server on the health port. - -### 4c. Agent Events - -New model in `cameleer-common`: - -```java -public class AgentEvent { - private String eventType; // enum-like string - private Instant timestamp; - private Map details; -} -``` - -Event types: - -| Event | Trigger | Details | -|-------|---------|---------| -| `AGENT_STARTED` | After postInstall + startup report | `routeCount`, `engineLevel`, startup report summary | -| `ROUTE_ADDED` | Runtime route addition | `routeId`, `contextName` | -| `ROUTE_REMOVED` | Runtime route removal | `routeId`, `contextName` | -| `ROUTE_STARTED` | Route started | `routeId` | -| `ROUTE_STOPPED` | Route stopped | `routeId` | -| `CONFIG_APPLIED` | After config-update SSE | `configVersion`, `changeCount`, change descriptions | - -New method on `Exporter` interface: -```java -default void exportEvent(AgentEvent event) {} -``` - -Default no-op so `LogExporter` doesn't need changes (events logged inline). `HttpExporter` sends events via `POST /api/agents/{agentId}/events`. - -### Files to create/modify - -| File | Change | -|------|--------| -| `common/model/AgentEvent.java` | **NEW** — event model | -| `agent/health/StartupReport.java` | **NEW** — startup checks + structured log | -| `agent/health/HealthEndpoint.java` | **NEW** — HTTP health endpoint | -| `agent/export/Exporter.java` | Add `exportEvent()` default method | -| `agent/export/HttpExporter.java` | Implement `exportEvent()` | -| `agent/notifier/CameleerEventNotifier.java` | Send events on route lifecycle changes | -| `agent/instrumentation/CameleerHookInstaller.java` | Run startup report, send AGENT_STARTED | -| `agent/command/DefaultCommandHandler.java` | Send CONFIG_APPLIED event | -| `agent/CameleerAgentConfig.java` | Add health endpoint config properties | - ---- - -## 5. Multi-CamelContext Support - -### Problem - -`CameleerHookInstaller` uses 7 static fields. A second `CamelContext.start()` silently overwrites everything — the first context's executions are lost, routes vanish from diagrams, and the server sees only the last context. - -### Design - -Replace static fields with a **per-context registry** plus a shared singleton for server-facing resources. - -### CameleerContextRegistry - -New class in `agent/instrumentation/`: - -```java -public class CameleerContextRegistry { - private static final CameleerContextRegistry INSTANCE = new CameleerContextRegistry(); - private final Map contexts = new ConcurrentHashMap<>(); - - // Shared resources (one per JVM, initialized by first context) - private volatile SharedState shared; - - public record ContextState( - String contextName, - CamelContext camelContext, - ExecutionCollector collector, - CameleerEventNotifier eventNotifier, - CameleerInterceptStrategy interceptStrategy, - CamelMetricsBridge metricsBridge, - TapEvaluator tapEvaluator, - RouteModelExtractor extractor - ) {} - - public record SharedState( - ServerConnection serverConnection, - SseClient sseClient, - HeartbeatManager heartbeatManager, - Exporter exporter, - LogForwarder logForwarder, - PrometheusEndpoint prometheusEndpoint, - HealthEndpoint healthEndpoint - ) {} -} -``` - -### Resource classification - -| Resource | Scope | Reason | -|----------|-------|--------| -| `ServerConnection` | Shared | Single agent-to-server connection | -| `SseClient` | Shared | One SSE stream per agent | -| `HeartbeatManager` | Shared | One heartbeat per JVM | -| `Exporter` | Shared | Single export pipeline | -| `LogForwarder` | Shared | One log bridge per JVM | -| `PrometheusEndpoint` | Shared | One metrics endpoint | -| `HealthEndpoint` | Shared | One health endpoint | -| `ExecutionCollector` | Per-context | Exchange tracking is context-specific | -| `CameleerEventNotifier` | Per-context | Event dispatch per context | -| `CameleerInterceptStrategy` | Per-context | Processor wrapping per context | -| `CamelMetricsBridge` | Per-context | JMX beans are per-context | -| `TapEvaluator` | Per-context | Expression resolution needs CamelContext | -| `RouteModelExtractor` | Per-context | Route definitions are per-context | - -### Route ID prefixing - -All route IDs exposed externally are prefixed with the context name: `contextName:routeId`. - -- Applied in `ExecutionCollector.onExchangeCreated()` when creating the `RouteExecution` -- Applied in `RouteModelExtractor` when building `RouteGraph` -- Sampling rate map keys, recording toggle keys, and tap processor IDs all use prefixed form -- `CameleerContextRegistry.prefixRouteId(contextName, routeId)` utility method -- **Always applied**, even for single-context apps — ensures consistent behavior and avoids a breaking migration if a second context is added later. The default Camel context name (e.g. `camel-1`) is used. -- Server-side config keys (`routeSamplingRates`, `routeRecording`, `tracedProcessors`, tap `processorId`) must use the prefixed form. The server learns context names from the agent registration. - -### Registration flow - -**`preInstall(context)`:** -1. Create per-context `ExecutionCollector` (using shared exporter if available, or temporary `LogExporter`) -2. Create + install `CameleerInterceptStrategy` -3. Register `ContextState` in registry - -**`postInstall(context)`:** -1. Create per-context `EventNotifier`, `MetricsBridge`, `TapEvaluator`, `RouteModelExtractor` -2. If first context (no `SharedState` yet): - - Establish server connection, SSE, heartbeat, log forwarding - - Initialize `SharedState` - - Swap all per-context exporters from `LogExporter` to `HttpExporter` -3. If subsequent context: - - Reuse `SharedState` exporter - - Send route graphs to server - - Send `ROUTE_ADDED` events for new routes - -### Shutdown - -- `CamelContextStoppingEvent` for a specific context: - - Clean up that context's `ContextState` (stop metrics bridge, etc.) - - Remove from registry -- Last context stopping: - - Tear down `SharedState` (SSE → heartbeat → flush → metrics → deregister) - -### Config updates - -`DefaultCommandHandler` iterates all registered `ContextState` instances when applying config changes (e.g. tap definitions go to all `TapEvaluator` instances, engine level changes apply globally). - -### Files to create/modify - -| File | Change | -|------|--------| -| `agent/instrumentation/CameleerContextRegistry.java` | **NEW** — per-context + shared state registry | -| `agent/instrumentation/CameleerHookInstaller.java` | Major refactor: replace static fields with registry | -| `agent/command/DefaultCommandHandler.java` | Iterate all contexts for config updates | -| `agent/collector/ExecutionCollector.java` | Accept context name, prefix route IDs | -| `agent/diagram/RouteModelExtractor.java` | Prefix route IDs in RouteGraph | - ---- - -## 6. OTel Hybrid (AUTO/CREATE/CORRELATE/OFF) - -### Problem - -Users with existing OTel setups (Jaeger, Tempo, Zipkin) cannot correlate Cameleer execution data with their distributed traces. Users without OTel get no tracing at all. - -### Design - -Cameleer either creates OTel spans or reads existing trace context, depending on configuration and auto-detection. - -### Config - -| Property | Default | Description | -|----------|---------|-------------| -| `cameleer.otel.mode` | `OFF` | `OFF`, `AUTO`, `CREATE`, `CORRELATE` | -| `cameleer.otel.endpoint` | `http://localhost:4318` | OTLP exporter endpoint (CREATE mode only) | -| `cameleer.otel.serviceName` | `${cameleer.agent.application}` | OTel service name | - -### Modes - -| Mode | Behavior | -|------|----------| -| `OFF` | No OTel interaction. Default. Zero overhead. | -| `AUTO` | At startup, check if OTel SDK classes exist on app classpath (via reflection through app classloader). If present → `CORRELATE`. If absent → `CREATE`. | -| `CREATE` | Initialize OTel SDK (shaded in agent JAR). Create spans from Cameleer execution data. Export via OTLP. | -| `CORRELATE` | Read W3C `traceparent` header from exchange headers. Extract traceId + spanId. Store on RouteExecution. No span creation. | - -### New fields on RouteExecution - -| Field | Type | Description | -|-------|------|-------------| -| `traceId` | `String` | W3C trace ID (32 hex chars), null if OTel OFF | -| `spanId` | `String` | W3C span ID (16 hex chars), null if OTel OFF | - -### OtelBridge - -New class in `agent/otel/OtelBridge.java`: - -```java -public class OtelBridge { - public enum Mode { OFF, AUTO, CREATE, CORRELATE } - - // Called once at startup - public void initialize(CameleerAgentConfig config, ClassLoader appClassLoader) { ... } - - // Called from ExecutionCollector lifecycle methods - public void onExchangeCreated(Exchange exchange, RouteExecution routeExec) { ... } - public void onProcessorStart(Exchange exchange, ProcessorExecution procExec) { ... } - public void onProcessorComplete(Exchange exchange, ProcessorExecution procExec) { ... } - public void onProcessorFailed(Exchange exchange, ProcessorExecution procExec, Throwable cause) { ... } - public void onExchangeCompleted(Exchange exchange, RouteExecution routeExec) { ... } - public void onExchangeFailed(Exchange exchange, RouteExecution routeExec) { ... } -} -``` - -**CREATE mode lifecycle:** -1. `onExchangeCreated()`: Start root span (name = `routeId`). Set span attributes: `cameleer.exchangeId`, `cameleer.correlationId`, `cameleer.routeId`. Store traceId/spanId on RouteExecution. -2. `onProcessorStart()`: Start child span (name = `processorId`). Set attributes: `cameleer.processorType`, `cameleer.splitIndex` (if set), tap attributes. -3. `onProcessorComplete()`: End child span with OK status. -4. `onProcessorFailed()`: End child span with ERROR status, record exception. -5. `onExchangeCompleted()`: End root span with OK status. -6. `onExchangeFailed()`: End root span with ERROR status, record exception. - -**CORRELATE mode lifecycle:** -1. `onExchangeCreated()`: Read `traceparent` header (format: `00-{traceId}-{spanId}-{flags}`). Parse and store traceId/spanId on RouteExecution. -2. All other methods: no-op. - -### Auto-detection - -```java -private Mode resolveAutoMode(ClassLoader appClassLoader) { - try { - appClassLoader.loadClass("io.opentelemetry.api.trace.Span"); - return Mode.CORRELATE; - } catch (ClassNotFoundException e) { - return Mode.CREATE; - } -} -``` - -### Shading - -OTel SDK shaded into the agent JAR with relocation: -- `io.opentelemetry` → `com.cameleer.shaded.otel` -- Dependencies: `opentelemetry-api`, `opentelemetry-sdk`, `opentelemetry-exporter-otlp` -- Adds ~2-3MB to the agent JAR -- In `OFF` and `CORRELATE` modes, the SDK is never initialized (no threads, no connections) - -### Files to create/modify - -| File | Change | -|------|--------| -| `common/model/RouteExecution.java` | Add `traceId`, `spanId` fields | -| `agent/otel/OtelBridge.java` | **NEW** — mode detection, span creation/correlation | -| `agent/CameleerAgentConfig.java` | Add otel config properties | -| `agent/instrumentation/CameleerHookInstaller.java` | Initialize OtelBridge | -| `agent/collector/ExecutionCollector.java` | Call OtelBridge hooks | -| `agent/pom.xml` | Add OTel SDK dependencies + shade relocation | - ---- - -## Server Handoff: Dependency Mapping - -This section is for the server implementation team. No agent changes required. - -### Available Data - -The agent already exports all data needed to build a service dependency graph: - -**Static dependencies (from RouteGraph):** - -Every `RouteNode` has: -- `endpointUri` — the full URI (e.g. `http://backend:8080/api/products`, `jms:queue:orders`, `file:/data/incoming`) -- `type` — `NodeType` enum: `TO`, `TO_DYNAMIC`, `ENRICH`, `POLL_ENRICH`, `WIRE_TAP`, `DIRECT`, `SEDA`, `ENDPOINT` -- `dynamic` — boolean flag for dynamic endpoints (`toD`, `recipientList`) - -Cross-route links are already edges: -- `RouteEdge` with `edgeType = CROSS_ROUTE` for `direct:` and `seda:` links between routes - -**Runtime dependencies (from ProcessorExecution):** - -- `endpointUri` — static endpoint URI from route definition -- `resolvedEndpointUri` — actual runtime URI for dynamic endpoints (resolved from `Exchange.TO_ENDPOINT` property) - -**Cross-service correlation:** - -- `X-Cameleer-CorrelationId` header propagated across HTTP calls -- `RouteExecution.correlationId` links related executions across services - -### How to Build the Dependency Graph - -**Step 1: Parse static topology from RouteGraph data** - -For each route's graph, iterate nodes where `type` is `ENDPOINT` (from), `TO`, `TO_DYNAMIC`, `ENRICH`, `POLL_ENRICH`, `WIRE_TAP`: - -``` -Protocol = URI scheme (http, https, jms, file, sql, direct, seda, kafka, amqp, ...) -Host = URI authority (for http/https/jms/kafka) -Direction = FROM nodes are inbound; TO/ENRICH/WIRE_TAP are outbound -``` - -Build edges: `(agentApplication, routeId) → (protocol, host, path)` for outbound; `(protocol, host, path) → (agentApplication, routeId)` for inbound. - -**Step 2: Resolve cross-agent links** - -When two agents share the same `agentApplication` prefix or when correlation IDs match across agents, the server can link: `Agent A route X → HTTP → Agent B route Y`. - -**Step 3: Track dynamic endpoints** - -For routes with `dynamic = true` nodes, use `resolvedEndpointUri` from ProcessorExecution records to build the actual runtime dependency set. This may change over time (e.g. content-based routing), so store as a set of observed targets. - -**Step 4: Visualization** - -Render as a directed graph: -- Nodes = applications (agents) and external systems (databases, queues, APIs) -- Edges = protocol + direction -- Edge weight = execution count (from ProcessorExecution frequency) -- Color-code by protocol (HTTP green, JMS blue, FILE orange, etc.) - -No agent changes needed for any of this. All data is already in the existing export format. - ---- - -## Implementation Order - -1. **Error Classification** — Pure model change, no config/protocol additions -2. **Sampling** — Config model + collector decision point -3. **Circuit Breaker Detection** — Small enrichment to collector -4. **Startup Self-Test + Health + Events** — New components, new event type -5. **OTel Hybrid** — New dependency, new bridge - -~~Multi-CamelContext~~ — Deferred. See [#55](https://gitea.siegeln.net/cameleer/cameleer/issues/55). - -Features 1–3 are independent and can be parallelized. - -## Protocol Changes - -New fields in execution JSON (all nullable, backward compatible): - -```json -{ - "errorType": "java.net.SocketTimeoutException", - "errorCategory": "TIMEOUT", - "rootCauseType": "java.net.SocketTimeoutException", - "rootCauseMessage": "Read timed out", - "traceId": "0af7651916cd43dd8448eb211c80319c", - "spanId": "b7ad6b7169203331", - "circuitBreakerState": "OPEN", - "fallbackTriggered": true -} -``` - -New config fields in `ApplicationConfig`: - -```json -{ - "routeSamplingRates": { "orders-route": 0.1, "health-check": 0.0 } -} -``` - -New event export endpoint: `POST /api/agents/{agentId}/events` - -```json -{ - "eventType": "ROUTE_ADDED", - "timestamp": "2026-03-27T10:00:00Z", - "details": { "routeId": "camel-1:new-route", "contextName": "camel-1" } -} -``` - -New health endpoint: `GET /cameleer/health` - -```json -{ - "status": "UP", - "agentId": "sample-app-12345", - "uptime": "PT2H30M", - "contexts": ["camel-1"], - "checks": { - "routes": { "status": "PASS", "detail": "6 detected" }, - "server": { "status": "PASS", "detail": "Connected" } - } -} -``` - -## Verification - -1. `mvn clean verify` — all existing + new tests pass -2. Error classification: throw various exception types, verify errorType/errorCategory/rootCause fields in exported JSON -3. Sampling: set `samplingRate=0.5`, verify ~50% of exchanges are recorded over 100+ exchanges -4. CB detection: trigger circuit breaker open state, verify `circuitBreakerState` and `fallbackTriggered` in execution data -5. Startup report: start agent, verify structured log output and health endpoint JSON -6. Agent events: add/remove route at runtime, verify events exported to server -7. Multi-context: deploy app with 2 CamelContexts, verify both contexts' routes and executions are tracked independently -8. OTel CREATE: set `cameleer.otel.mode=CREATE`, verify spans appear in Jaeger/OTLP collector -9. OTel CORRELATE: run alongside camel-opentelemetry, verify traceId/spanId stored on RouteExecution diff --git a/docs/superpowers/specs/2026-03-30-execution-tracking-integration-tests-design.md b/docs/superpowers/specs/2026-03-30-execution-tracking-integration-tests-design.md deleted file mode 100644 index d2aba37..0000000 --- a/docs/superpowers/specs/2026-03-30-execution-tracking-integration-tests-design.md +++ /dev/null @@ -1,121 +0,0 @@ -# Execution Tracking Integration Test Infrastructure - -**Date:** 2026-03-30 -**Status:** Draft -**Scope:** Spec 1 of 2 — test infrastructure first, capture refactoring second - -## Problem - -In recent iterations, we broke the agent's execution tracking twice. Both times, all unit tests passed because they use mocked exchanges with manually set properties that don't match what Camel 4.10 actually sets. - -### The breakage pattern - -1. `onSplitStart()` was changed to use `CamelParentExchangeId` directly (instead of `findParentExchangeId()` with CORRELATION_ID fallback) -2. All 41 unit tests passed — because mocks manually set `CamelParentExchangeId` -3. In production, Camel 4.10 does NOT set that property on split sub-exchanges -4. Split iteration tracking broke completely — split1 had `children: []` -5. Required a second fix (ExchangeCreatedEvent registration gate) - -### Root cause - -No integration tests verify execution data against real Camel exchanges. The unit tests create a "false sense of correctness" — mocks match the test author's mental model, not Camel's actual behavior. - -### What we need - -1. **Property snapshot tests** — canonical reference for what Camel 4.10 actually sets on sub-exchanges -2. **Execution tree integration tests** — verify the full agent pipeline produces correct processor trees -3. **Sample app smoke tests** — lightweight execution assertions on existing real-route tests - -## Design - -### TestExporter - -An `Exporter` implementation for tests that collects `RouteExecution` objects in memory. - -```java -public class TestExporter implements Exporter { - private final CopyOnWriteArrayList executions = new CopyOnWriteArrayList<>(); - private final CountDownLatch latch; - - // getExecutions(), awaitExecution(timeout), clear() -} -``` - -Lives in `cameleer-agent/src/test/java/com/cameleer/agent/test/TestExporter.java`. Duplicated into sample app test sources for the enhanced sample tests. - -### Property Snapshot Tests - -**File:** `CamelExchangePropertySnapshotTest.java` - -Starts a real CamelContext, runs routes with split/multicast/CB/loop, captures the actual exchange properties via a recording `Processor` inserted into the route. Asserts as a canonical reference for Camel 4.10. - -| Test | Asserts | -|------|---------| -| `splitSubExchange_properties` | SPLIT_INDEX, SPLIT_SIZE, CORRELATION_ID present; CamelParentExchangeId absent | -| `multicastSubExchange_properties` | CamelMulticastIndex present, CORRELATION_ID present | -| `circuitBreakerInternal_properties` | Which props CB inherits vs sets | -| `loopIteration_properties` | CamelLoopIndex on same exchange (not a sub-exchange) | -| `nestedSplitSubExchange_properties` | Inner split sub-exchange property chain | - -These are Camel version upgrade canaries. If Camel 4.11 changes property behavior, these tests flag it immediately. - -### Execution Tree Integration Tests - -**File:** `ExecutionTreeIntegrationTest.java` - -Runs real Camel routes through the full agent pipeline: InterceptStrategy + EventNotifier + ExecutionCollector + TestExporter. Asserts on the resulting RouteExecution processor tree. - -| Test | Route Pattern | Key Assertion | -|------|---------------|---------------| -| `splitWithAggregation_correctTree` | split(body(), agg) > log > .end() > log("after") | Post-split log is sibling of split, not child | -| `splitWithCircuitBreaker_cbProcessorsNested` | split > CB > toD > .end() > .end() | CB processors are children of circuitBreaker, not phantom wrappers | -| `splitWithFilter_rejectedOrderSkipsChildren` | split > filter(pred) > log > .end() > .end() | Filtered iteration: filterMatched=false, empty children | -| `splitWithIdempotent_duplicateDetected` | split > idempotentConsumer > log > .end() > .end() | Duplicate: duplicateMessage=true | -| `multicastExecution_branchWrappersCreated` | multicast > direct:a, direct:b | 2 multicastBranch wrappers with children | -| `recipientListExecution_resolvedUris` | recipientList(header) | recipientListBranch wrappers with resolvedEndpointUri | -| `loopExecution_iterationWrappers` | loop(3) > log | 3 loopIteration wrappers | -| `nestedSplitLoop_correctNesting` | split > loop(2) > log | Each split iteration contains loop with 2 wrappers | - -These would have caught both breakages: -- The `CamelParentExchangeId` assumption error: `splitWithAggregation_correctTree` would fail because no iteration wrappers are created -- The phantom CB wrapper bug: `splitWithCircuitBreaker_cbProcessorsNested` would fail because CB processors appear in phantom wrappers - -### Sample App Test Enhancements - -Add lightweight execution assertions to existing sample app tests. These already start real CamelContexts with the agent attached. - -| Test File | Enhancement | -|-----------|-------------| -| `NestedSplitRouteTest` | Assert RouteExecution has split with iteration wrappers | -| `ContentBasedRoutingRouteTest` | Assert choice processor has children | -| `ErrorHandlingRouteTest` | Assert error fields (errorType, errorCategory) populated | - -Requires a `TestExporter` wired via test profile that captures executions in-memory, accessible via `@Autowired` in test classes. - -## Files - -### Create -- `cameleer-agent/src/test/java/com/cameleer/agent/test/TestExporter.java` -- `cameleer-agent/src/test/java/com/cameleer/agent/collector/CamelExchangePropertySnapshotTest.java` -- `cameleer-agent/src/test/java/com/cameleer/agent/collector/ExecutionTreeIntegrationTest.java` - -### Modify -- `cameleer-sample-app/src/test/.../NestedSplitRouteTest.java` -- `cameleer-sample-app/src/test/.../ContentBasedRoutingRouteTest.java` -- `cameleer-sample-app/src/test/.../ErrorHandlingRouteTest.java` -- Sample app test config (for TestExporter wiring) - -## Verification - -```bash -mvn test -pl cameleer-agent # existing + new integration tests pass -mvn test -pl cameleer-sample-app # enhanced sample tests pass -mvn clean verify # full build green -``` - -## Success Criteria - -- Property snapshot tests document actual Camel 4.10 behavior (no guessing) -- Execution tree integration tests catch split/multicast/loop/CB tracking regressions -- A simulated replay of the `CamelParentExchangeId` breakage (reverting the fix) causes integration test failure -- Sample app tests fail if the agent's execution tree is fundamentally wrong diff --git a/docs/superpowers/specs/2026-03-31-chunked-transport-clickhouse-design.md b/docs/superpowers/specs/2026-03-31-chunked-transport-clickhouse-design.md deleted file mode 100644 index bc1082b..0000000 --- a/docs/superpowers/specs/2026-03-31-chunked-transport-clickhouse-design.md +++ /dev/null @@ -1,468 +0,0 @@ -# Chunked Execution Transport with ClickHouse-Native Storage - -Supersedes: `2026-03-28-chunked-transport-polyglot-storage-design.md` - -## Problem - -The current execution data model sends a complete `RouteExecution` as one atomic JSON blob — the full processor tree or nothing. This creates three scaling bottlenecks: - -1. **Agent memory**: A split with 1000 items and 10 processors each builds a ~40MB tree in memory before it can be exported -2. **Network/server**: A single 40MB JSON payload overwhelms HTTP connections and server ingestion -3. **Latency**: Long-running exchanges show no data in the UI until complete - -Additionally, captured payloads (`inputBody`/`outputBody`) on processors can be large and must be held in memory for the entire exchange lifetime. - -## Design Principles - -- **Append-only**: No updates to stored data — ClickHouse-native pattern -- **Lean agent state**: Minimal in-memory tracking, flush and release early -- **No information loss**: Every field needed for full tree reconstruction is preserved -- **nJAMS-proven pattern**: Flat records with parent references, size-based chunking, partial message semantics — adapted from the production nJAMS BW client - -## Prior Art: nJAMS BW Client - -The nJAMS client for TIBCO BusinessWorks (`com.faizsiegeln.njams`) solves the same problem for BW process monitoring. Key patterns adopted: - -- **Flat activity list with parent references**: Each `Activity` carries `instanceId` (sequence), `parentInstanceId` (parent's sequence), and `iteration`. No nested `children` arrays. Server reconstructs the tree by walking parent chains. -- **Iteration is local, not a full path**: Each record only knows its immediate container's iteration index. The full iteration path is reconstructed by walking `parentInstanceId` upward. -- **Size-based flushing**: Flush when estimated message size exceeds threshold. Only completed activities are flushed — running ones stay in buffer. `messageNo` increments on each flush. -- **Model ID links to static definition**: `modelId` connects runtime activity to process definition. Equivalent to our `processorId` → route graph linkage. -- **Lightweight runtime state**: `activityStackMap` tracks open containers — not the full tree. - -## 1. Flat Processor Record - -Each processor execution becomes one flat record. No `children` array, no synthetic wrapper nodes (`splitIteration`, `loopIteration`, `multicastBranch`). - -```json -{ - "seq": 3, - "parentSeq": 2, - "parentProcessorId": "split1", - "processorId": "log2", - "processorType": "log", - "iteration": 0, - "iterationSize": null, - "status": "COMPLETED", - "startTime": "2026-03-28T14:30:00.124Z", - "durationMs": 1, - "resolvedEndpointUri": null, - "inputBody": null, - "outputBody": null, - "inputHeaders": null, - "outputHeaders": null, - "errorMessage": null, - "errorStackTrace": null, - "errorType": null, - "errorCategory": null, - "rootCauseType": null, - "rootCauseMessage": null, - "attributes": null, - "circuitBreakerState": null, - "fallbackTriggered": null, - "filterMatched": null, - "duplicateMessage": null -} -``` - -### Fields for Tree Reconstruction - -| Field | Purpose | Set by | -|---|---|---| -| `seq` | Unique per exchange, monotonic | `AtomicInteger.incrementAndGet()` | -| `parentSeq` | Immediate parent's seq (null for root-level) | Stack peek, or `defaultParentSeq` for sub-exchanges | -| `parentProcessorId` | Parent's processor ID (redundant but query-friendly) | Resolved alongside `parentSeq` | -| `processorId` | Links to route graph node | Camel processor ID (unique in CamelContext) | -| `iteration` | Which iteration of parent container (0-based) | `SPLIT_INDEX`, `CamelMulticastIndex`, or guarded `CamelLoopIndex` | -| `iterationSize` | Total iterations (only on container processors, set at completion) | `SPLIT_SIZE`, loop/multicast count at completion | - -### What's Eliminated vs Current Model - -- `children` array — replaced by `parentSeq` + `parentProcessorId` -- `splitIndex`/`splitSize`/`loopIndex`/`loopSize`/`multicastIndex` as separate fields — unified into `iteration`/`iterationSize` -- `splitDepth`/`loopDepth` — unnecessary, tree depth derived from `parentSeq` chain -- `endTime` — derivable from `startTime + durationMs` -- `startNanos` — internal timing, never serialized -- Synthetic wrapper nodes (`splitIteration`, `loopIteration`, `multicastBranch`, `recipientListBranch`) — eliminated entirely - -### Container Processors - -Container processors (split, loop, multicast, recipientList) carry `iterationSize` when they complete. Individual child processors carry `iteration` (their index within the container). The server walks `parentSeq` upward to build the full iteration path for any processor. - -## 2. Chunk Document (Transport Format) - -The agent batches processor records into chunks, each wrapped with the exchange envelope. This is the wire format sent via `POST /api/v1/data/executions`. - -```json -{ - "exchangeId": "A2A3C980AC95F62-0000000000001234", - "applicationName": "order-service", - "agentId": "order-service-pod-7f8d9", - "routeId": "order-processing", - "correlationId": "A2A3C980AC95F62-0000000000001234", - "status": "RUNNING", - "startTime": "2026-03-28T14:30:00.123Z", - "endTime": null, - "durationMs": null, - "engineLevel": "REGULAR", - "errorMessage": null, - "errorStackTrace": null, - "errorType": null, - "errorCategory": null, - "rootCauseType": null, - "rootCauseMessage": null, - "attributes": {}, - "traceId": null, - "spanId": null, - "originalExchangeId": null, - "replayExchangeId": null, - - "chunkSeq": 0, - "final": false, - - "processors": [ - {"seq": 1, "parentSeq": null, "parentProcessorId": null, "processorId": "log1", "processorType": "log", "iteration": null, "status": "COMPLETED", "startTime": "2026-03-28T14:30:00.124Z", "durationMs": 1}, - {"seq": 2, "parentSeq": null, "parentProcessorId": null, "processorId": "split1", "processorType": "split", "iteration": null, "status": "COMPLETED", "startTime": "2026-03-28T14:30:00.125Z", "durationMs": 500, "iterationSize": 100}, - {"seq": 3, "parentSeq": 2, "parentProcessorId": "split1", "processorId": "log2", "processorType": "log", "iteration": 0, "status": "COMPLETED", "startTime": "2026-03-28T14:30:00.126Z", "durationMs": 1} - ] -} -``` - -### Chunk Lifecycle - -| Event | Behavior | -|---|---| -| Buffer reaches 50 processors | Emit chunk with `status: RUNNING`, `final: false`, increment `chunkSeq` | -| Estimated buffer size exceeds 1MB | Same — size-based flush for large payloads | -| Time interval elapses (1 second) | Same — prevents long-running exchanges from going dark | -| Exchange completes successfully | Emit final chunk with `status: COMPLETED`, `final: true`, `endTime`, `durationMs` | -| Exchange fails | Emit final chunk with `status: FAILED`, `final: true`, error fields populated | - -Flush triggers are independent — whichever fires first. Count-based handles "many small processors", size-based handles "few processors with large captured bodies". - -### Chunk Semantics - -- **`chunkSeq`**: Starts at 0, increments on each flush per `exchangeId`. Server uses highest `chunkSeq` as authoritative for envelope state. -- **Idempotent upsert key**: `{exchangeId}_{chunkSeq}` — safe for retries. -- **`attributes` accumulation**: Each chunk carries all attributes extracted so far. Server takes latest chunk's attributes as authoritative. -- **`inputSnapshot`/`outputSnapshot`**: These former `RouteExecution` fields become processor-level `inputBody`/`outputBody` on the first and last processor respectively, controlled by engine level. - -## 3. Agent Runtime State - -Minimal in-memory state to produce correct flat records. No tree, no synthetic wrappers. - -### Per Exchange - -``` -ExchangeState { - exchangeId: String - seqCounter: AtomicInteger // next seq number - processorSeqStack: Deque // stack of active processor seqs (depth-proportional) - seqToProcessorId: Map // bounded by stack depth, cleaned on pop - loopStates: Deque // one entry per active loop nesting level - envelope: ExchangeEnvelope // routeId, correlationId, status, startTime, attributes, etc. - buffer: ConcurrentLinkedQueue // records awaiting flush (lock-free for parallel splits) - bufferCount: AtomicInteger // flush threshold check without O(n) queue size() - estimatedBufferSize: AtomicInteger // running byte estimate for size-based flush -} -``` - -### Per Sub-Exchange (Split/Multicast) - -``` -SubExchangeContext { - parentContainerSeq: int // the split/multicast processor's seq - parentContainerProcessorId: String // the split/multicast processor's processorId - iteration: int // from SPLIT_INDEX or CamelMulticastIndex -} -``` - -### Loop Tracking (Per Active Loop Level) - -``` -LoopTrackingState { - loopSeq: int // the loop processor's seq - loopProcessorId: String // the loop processor's processorId - stackDepthAtEntry: int // stack size when loop started - currentIndex: int // last captured iteration index (-1 initially) - iterationCount: int // total iterations seen (for iterationSize on completion) -} -``` - -### Algorithm: onProcessorStart - -1. `seq = seqCounter.incrementAndGet()` -2. Determine `parentSeq` and `parentProcessorId`: - - Stack not empty → `parentSeq = stack.peek()`, `parentProcessorId = seqToProcessorId.get(parentSeq)` - - Stack empty + sub-exchange → from `SubExchangeContext` - - Stack empty + root exchange → both `null` -3. Determine `iteration`: - - Sub-exchange (split/multicast) → `subExchangeContext.iteration` - - Inside active loop + stack depth matches expected → read `CamelLoopIndex`, update `LoopTrackingState` - - Otherwise → `null` -4. Push `seq` onto `processorSeqStack` -5. Store `seqToProcessorId.put(seq, processorId)` - -### Algorithm: onProcessorComplete - -1. Pop `seq` from stack -2. Build `FlatProcessorRecord` with timing, payloads, errors, status -3. If container processor (split/loop/multicast) → set `iterationSize` from tracking state, clean up state -4. Append record to `buffer`, add to `estimatedBufferSize` -5. Remove `seq` from `seqToProcessorId` (unless active container with sub-exchanges) -6. If `buffer.size() >= 50` or `estimatedBufferSize >= 1MB` → flush chunk - -### Memory Comparison - -| Component | Current model (v1) | New model (v2) | -|---|---|---| -| Processor objects in memory | All (full tree, proportional to exchange size) | ~50 max (buffer bound) | -| Stack | Full `ProcessorExecution` objects | Integers only | -| Loop tracking | `LoopState` with `ProcessorExecution` refs | `LoopTrackingState` with seq integers | -| Container tracking | `SubExchangeTracker` maps with wrappers | One `SubExchangeContext` per active sub-exchange | -| `seqToProcessorId` map | N/A | Bounded by stack depth | - -For a split with 10,000 iterations × 10 processors: current model holds ~100,000 `ProcessorExecution` objects in memory. New model holds ~50 `FlatProcessorRecord` objects max. - -### Exchange Property Reliability - -| Property | Reliable? | Mechanism | -|---|---|---| -| `SPLIT_INDEX` | Yes | Separate sub-exchanges per iteration | -| `CamelMulticastIndex` | Yes | Separate sub-exchanges per branch | -| `CamelLoopIndex` | No — inner loops overwrite | Stack depth guard: only read when `stack.size() == expectedDepth` | - -The loop depth guard is the same proven algorithm from the current `SubExchangeTracker`, using `stackDepthAtEntry` to prevent stale `CamelLoopIndex` values from inner loops from being misattributed to outer loops. - -## 4. ClickHouse Schema - -Two tables. The server decomposes each incoming chunk into an exchange upsert and processor row inserts. - -### Exchange Table - -One row per exchange, updated on each chunk via `ReplacingMergeTree`. - -```sql -CREATE TABLE exchanges -( - exchange_id String, - application_name String, - agent_id String, - route_id String, - correlation_id String, - status Enum8('RUNNING' = 0, 'COMPLETED' = 1, 'FAILED' = 2, 'ABANDONED' = 3), - start_time DateTime64(3, 'UTC'), - end_time Nullable(DateTime64(3, 'UTC')), - duration_ms Nullable(Int64), - engine_level LowCardinality(String), - error_message Nullable(String), - error_stack_trace Nullable(String), - error_type Nullable(String), - error_category Nullable(LowCardinality(String)), - root_cause_type Nullable(String), - root_cause_message Nullable(String), - attributes Map(String, String), - trace_id Nullable(String), - span_id Nullable(String), - original_exchange_id Nullable(String), - replay_exchange_id Nullable(String), - chunk_seq UInt16, - final Bool, - updated_at DateTime64(3, 'UTC') DEFAULT now64(3) -) -ENGINE = ReplacingMergeTree(updated_at) -ORDER BY (application_name, route_id, exchange_id) -PARTITION BY toYYYYMMDD(start_time) -TTL start_time + INTERVAL 30 DAY; -``` - -`ReplacingMergeTree` keeps only the latest version (by `updated_at`) after background merges. Queries use `FINAL` or `argMax` to get the latest state before merges complete. No actual UPDATE statements — append-only. - -### Processor Table - -One row per processor execution, append-only, never updated. - -```sql -CREATE TABLE processors -( - exchange_id String, - application_name LowCardinality(String), - agent_id LowCardinality(String), - route_id LowCardinality(String), - seq UInt32, - parent_seq Nullable(UInt32), - parent_processor_id Nullable(LowCardinality(String)), - processor_id LowCardinality(String), - processor_type LowCardinality(String), - iteration Nullable(UInt32), - iteration_size Nullable(UInt32), - status Enum8('RUNNING' = 0, 'COMPLETED' = 1, 'FAILED' = 2), - start_time DateTime64(3, 'UTC'), - duration_ms Int64, - resolved_endpoint_uri Nullable(String), - input_body Nullable(String), - output_body Nullable(String), - input_headers Nullable(Map(String, String)), - output_headers Nullable(Map(String, String)), - error_message Nullable(String), - error_stack_trace Nullable(String), - error_type Nullable(String), - error_category Nullable(LowCardinality(String)), - root_cause_type Nullable(String), - root_cause_message Nullable(String), - attributes Map(String, String), - circuit_breaker_state Nullable(LowCardinality(String)), - fallback_triggered Nullable(Bool), - filter_matched Nullable(Bool), - duplicate_message Nullable(Bool) -) -ENGINE = MergeTree() -ORDER BY (application_name, route_id, exchange_id, seq) -PARTITION BY toYYYYMMDD(start_time) -TTL start_time + INTERVAL 30 DAY; -``` - -Plain `MergeTree` — append-only, no deduplication needed. Each `(exchange_id, seq)` is unique. - -### ClickHouse Optimizations - -- **`LowCardinality`** on `application_name`, `agent_id`, `route_id`, `processor_id`, `processor_type`, `error_category`: dictionary encoding for low-distinct-value columns -- **`ORDER BY (application_name, route_id, exchange_id, seq)`**: supports exchange listing (`WHERE application_name AND route_id`) and tree reconstruction (`WHERE exchange_id ORDER BY seq`) -- **Denormalized envelope fields** on processor rows: `application_name`, `agent_id`, `route_id` copied from chunk envelope during server decomposition — avoids JOINs, columnar compression makes it nearly free - -### Common Query Patterns - -```sql --- Exchange listing (latest state) -SELECT * FROM exchanges FINAL -WHERE application_name = 'order-service' AND route_id = 'order-processing' -ORDER BY start_time DESC LIMIT 50; - --- Tree reconstruction -SELECT * FROM processors -WHERE exchange_id = 'A2A3C980AC95F62-0000000000001234' -ORDER BY seq; - --- Failed processors across all exchanges -SELECT * FROM processors -WHERE application_name = 'order-service' AND status = 'FAILED' -ORDER BY start_time DESC LIMIT 100; - --- Processor duration statistics -SELECT processor_id, processor_type, - count() AS total, - avg(duration_ms) AS avg_ms, - quantile(0.95)(duration_ms) AS p95_ms -FROM processors -WHERE application_name = 'order-service' AND route_id = 'order-processing' - AND start_time >= now() - INTERVAL 1 HOUR -GROUP BY processor_id, processor_type; -``` - -## 5. Server Decomposition - -When the server receives a chunk via `POST /api/v1/data/executions`: - -### Step 1: Extract Envelope → Upsert Exchange Row - -Take all top-level fields (everything except `processors` array). Insert into `exchanges` table. `ReplacingMergeTree` handles "latest chunk wins" semantics. - -### Step 2: Extract Processors → Batch Insert - -Iterate the `processors` array. Denormalize envelope fields (`application_name`, `agent_id`, `route_id`) onto each processor record. Batch insert into `processors` table via ClickHouse native batch insert. - -### Step 3: Detect Completion - -If `final: true`, the exchange is complete. Trigger post-completion logic (alerting, aggregation). - -### Abandoned Exchange Detection - -When an agent transitions to STALE/DEAD (heartbeat timeout), the server queries: - -```sql -SELECT exchange_id FROM exchanges FINAL -WHERE agent_id = 'dead-agent' AND status = 'RUNNING'; -``` - -These exchanges transition to `ABANDONED` by inserting a new row with higher `updated_at`. - -### Idempotency - -If the agent retries a chunk, the server receives a duplicate. For the exchange table, `ReplacingMergeTree` deduplicates naturally. For the processor table, duplicate `(exchange_id, seq)` rows are harmless — the server deduplicates by `seq` during tree reconstruction. - -### Tree Reconstruction Algorithm - -On-demand when the UI requests exchange detail: - -``` -1. SELECT * FROM processors WHERE exchange_id = ? ORDER BY seq -2. Index by seq: Map -3. For each record: - - parentSeq is null → root-level processor - - parentSeq is not null → add as child of record[parentSeq] -4. Children sorted by seq (preserved from ORDER BY) -5. Return tree -``` - -O(n) where n = total processors in the exchange. Single ClickHouse query, no joins. - -### Handling Missing Chunks (Orphaned Processors) - -If chunks were lost, some processors may reference a `parentSeq` that doesn't exist in the result set. The reconstruction algorithm attaches orphans to a synthetic "data lost" root node and flags the exchange as having incomplete data. - -## 6. Agent-Side Changes - -### New Classes - -| Class | Purpose | -|---|---| -| `FlatProcessorRecord` | POJO for the flat processor record | -| `ExecutionChunk` | POJO for the chunk document (envelope + processors array) | -| `ChunkedExporter` | New `Exporter` implementation — buffers records, flushes chunks | - -### Modified Classes - -| Class | Change | -|---|---| -| `ExecutionCollector` | Replace tree building with flat record emission. `processorStacks` holds integers, not `ProcessorExecution` objects. Add `seqCounter`, `ExchangeState`. | -| `SubExchangeTracker` | Remove all wrapper creation. Keep sub-exchange parent resolution and loop state stack with depth guards. Possibly merge into `ExecutionCollector`. | -| `HttpExporter` | Deprecated — replaced by `ChunkedExporter` | -| `Exporter` interface | `exportExecution(RouteExecution)` deprecated, add `exportChunk(ExecutionChunk)` | - -### Deprecated (marked `@Deprecated(since = "2.0", forRemoval = true)`) - -- `ProcessorExecution.children` and `addChild()` -- `ProcessorExecution.splitIndex`, `splitSize`, `loopIndex`, `loopSize`, `multicastIndex` -- `SubExchangeTracker` wrapper creation methods -- `Exporter.exportExecution(RouteExecution)` -- `HttpExporter` class - -### Kept As-Is - -- `RouteExecution` model in `cameleer-common` (retained for other uses, deprecated for transport) -- `CameleerInterceptStrategy` — still wraps every processor -- `CameleerEventNotifier` — exchange created/completed events -- Payload capture, tap evaluation, error classification logic — same, writes to `FlatProcessorRecord` -- Loop state depth guards — same algorithm, lighter data structure - -## 7. Failure Handling & Back-pressure - -### Chunk Retry - -When a chunk fails to send: - -- Buffer in a bounded retry queue (separate from main flush buffer) -- Retry with exponential backoff: 1s, 2s, 4s, max 30s -- Retry queue cap: configurable (e.g. 10MB estimated size) -- When full, drop oldest chunks (least valuable for real-time monitoring) - -### Back-pressure (HTTP 503) - -Same as current behavior: `pauseUntil = now + 10s`, pause all exports. - -### Agent Crash - -Chunks already sent are preserved in ClickHouse. Exchange stays `RUNNING`. Server-side abandoned exchange detection (heartbeat timeout → `ABANDONED`) handles cleanup. - -### Partial Data in ClickHouse - -Missing chunks create seq gaps. Tree reconstruction attaches orphaned processors (unresolvable `parentSeq`) to a synthetic "data lost" node. Exchange flagged as incomplete. diff --git a/docs/superpowers/specs/2026-04-01-cameleer-extension-graalvm-design.md b/docs/superpowers/specs/2026-04-01-cameleer-extension-graalvm-design.md deleted file mode 100644 index 391307a..0000000 --- a/docs/superpowers/specs/2026-04-01-cameleer-extension-graalvm-design.md +++ /dev/null @@ -1,391 +0,0 @@ -# Design Spec: Cameleer Extension for GraalVM Native Support - -## Context - -The Cameleer Java agent uses ByteBuddy to instrument Apache Camel applications at runtime. This works in JVM mode but is incompatible with GraalVM native images (no runtime class transformation). Investigation revealed that 90% of the agent code is pure Camel SPI — ByteBuddy is only the entry mechanism. This spec defines a Quarkus extension that provides the same observability without ByteBuddy, enabling GraalVM native image support. - -The work involves: -1. Extracting shared observability code into `cameleer-core` -2. Building a Quarkus extension (`cameleer-extension`) using CDI lifecycle hooks -3. Creating a native-capable example app (`cameleer-quarkus-native-app`) -4. Updating all pipelines, Dockerfiles, and existing apps for the new module structure - -## Module Architecture (after refactoring) - -``` -cameleer-common ← Models (unchanged) -cameleer-core ← NEW: observability logic extracted from agent -cameleer-agent ← REFACTORED: thin ByteBuddy wrapper → depends on core -cameleer-extension/ ← NEW: Quarkus extension (CDI hooks → core) - ├── runtime/ - └── deployment/ -cameleer-sample-app ← UPDATE: Dockerfiles for new module -cameleer-backend-app ← UPDATE: Dockerfiles for new module -cameleer-caller-app ← UPDATE: Dockerfiles for new module -cameleer-quarkus-app ← UPDATE: Dockerfiles for new module -cameleer-quarkus-native-app ← NEW: native-capable example app -``` - -Dependency graph: -``` -common ← core ← agent (ByteBuddy + core) - ← extension/runtime (CDI + core) - ← extension/deployment (build-time processing) -``` - -## 1. `cameleer-core` — Shared Observability Logic - -### What moves from `cameleer-agent` to `cameleer-core` - -**Moves to core (no ByteBuddy dependency):** -- `CameleerAgentConfig.java` (config POJO, only has a ByteBuddy comment) -- `collector/` — `ExecutionCollector`, `FlatExecutionCollector`, `ExchangeState`, `LoopTrackingState`, `PayloadCapture`, `SubExchangeContext` -- `notifier/` — `CameleerInterceptStrategy`, `CameleerEventNotifier` (+ `InterceptCallback` inner class) -- `export/` — `Exporter`, `ExporterFactory`, `LogExporter`, `ChunkedExporter` -- `connection/` — `ServerConnection`, `SseClient`, `HeartbeatManager`, `ConfigVerifier`, `TokenRefreshFailedException` -- `command/` — `CommandHandler`, `CommandResult`, `DefaultCommandHandler` -- `config/` — `ConfigCacheManager` -- `diagram/` — `RouteModelExtractor`, `DebugSvgRenderer`, `RouteGraphVersionManager` -- `metrics/` — `CamelMetricsBridge`, `PrometheusEndpoint`, `PrometheusFormatter` -- `tap/` — `TapEvaluator` -- `otel/` — `OtelBridge` -- `health/` — `HealthEndpoint`, `StartupReport` -- `logging/` — `LogForwarder`, `LogEventBridge`, `LogEventConverter`, `LogLevelSetter`, `CameleerJulHandler` - -**Stays in `cameleer-agent` (ByteBuddy-dependent):** -- `CameleerAgent.java` — premain entry point -- `instrumentation/AgentClassLoader.java` — classloader bridge -- `instrumentation/CamelContextAdvice.java` — ByteBuddy advice -- `instrumentation/CamelContextTransformer.java` — ByteBuddy transformer -- `instrumentation/CameleerHookInstaller.java` — orchestrator (calls core classes) -- `instrumentation/SendDynamicAwareTransformer.java` — ByteBuddy transformer -- `instrumentation/SendDynamicUriAdvice.java` — ByteBuddy advice -- `instrumentation/ServerSetup.java` — server connection setup -- `logging/LogForwardingInstaller.java` — ByteBuddy ClassInjector -- `logging/CameleerLogbackAppender.java` — ByteBuddy ClassInjector target -- `logging/CameleerLog4j2Appender.java` — ByteBuddy ClassInjector target - -### Package rename - -Classes move from `com.cameleer.agent.*` to `com.cameleer.core.*`. The agent's remaining classes keep `com.cameleer.agent.*` and import from `com.cameleer.core.*`. - -### `cameleer-core/pom.xml` - -- Parent: `cameleer-parent` -- Dependencies: `cameleer-common`, Apache Camel (`provided`), Jackson, SLF4J, OTel SDK, Prometheus client -- No ByteBuddy dependency -- No shade plugin — this is a regular JAR - -### `cameleer-agent` refactored - -- POM adds `cameleer-core` -- `CameleerHookInstaller` imports from `com.cameleer.core.*` instead of `com.cameleer.agent.*` -- Shade plugin relocations unchanged (ByteBuddy, SLF4J, OTel) -- Shade plugin must now include `cameleer-core` classes in the shaded JAR (add `com.cameleer:cameleer-core` to shade config) -- Tests: unit tests for core classes (collector, notifier, exporter) move to `cameleer-core`. Integration tests that use `CamelContext` and route builders stay in `cameleer-agent` since they test the full agent instrumentation path. The agent tests will import from `com.cameleer.core.*` instead of `com.cameleer.agent.*`. - -## 2. `cameleer-extension` — Quarkus Extension - -### `cameleer-extension/runtime` - -**`CameleerLifecycle.java`** — replaces `CameleerHookInstaller`: -```java -@ApplicationScoped -@Unremovable -public class CameleerLifecycle { - @Inject CamelContext camelContext; - - // BEFORE routes start — register InterceptStrategy - void onBeforeStart(@Observes @Priority(Interceptor.Priority.PLATFORM_BEFORE) - StartupEvent event) { - camelContext.setUseMDCLogging(true); - camelContext.getCamelContextExtension() - .addInterceptStrategy(new CameleerInterceptStrategy(collector, config)); - } - - // AFTER CamelContext started — register EventNotifier, extract diagrams - void onStarted(@Observes CamelEvent.CamelContextStartedEvent event) { - camelContext.getManagementStrategy() - .addEventNotifier(new CameleerEventNotifier(collector, config)); - RouteModelExtractor.extractAndExport(camelContext, exporter); - if (!isNativeImage()) { - startMetricsBridge(); - } - } - - void onShutdown(@Observes ShutdownEvent event) { - // graceful shutdown: flush, deregister, close - } - - private static boolean isNativeImage() { - return System.getProperty("org.graalvm.nativeimage.imagecode") != null; - } -} -``` - -**`CameleerConfigMapping.java`** — CDI config mapping: -```java -@ConfigMapping(prefix = "cameleer") -public interface CameleerConfigMapping { - @WithDefault("LOG") Export export(); - @WithDefault("") Agent agent(); - @WithDefault("REGULAR") String engineLevel(); - // ... mirrors CameleerAgentConfig properties -} -``` - -Adapts to `CameleerAgentConfig` internally so all core classes work unchanged. - -**SendDynamicUri capture** — replaces ByteBuddy `SendDynamicUriAdvice`: -The `CameleerEventNotifier` already receives `ExchangeSendingEvent` which contains the resolved endpoint URI. In the extension, use this event to capture dynamic URIs instead of the ByteBuddy advice. The `ExchangeSendingEvent.getEndpoint().getEndpointUri()` provides the full resolved URI. This replaces the `SendDynamicUriAdvice.CAPTURED_URIS` map — the EventNotifier passes captured URIs directly to the collector. - -**Log forwarding** — in the extension, log appenders are CDI beans registered directly (no ClassInjector needed). The Quarkus app's logging framework is accessible on the same classloader. - -Dependencies: `cameleer-core`, `io.quarkus:quarkus-arc`, `org.apache.camel.quarkus:camel-quarkus-core` - -### `cameleer-extension/deployment` - -**`CameleerExtensionProcessor.java`**: -```java -public class CameleerExtensionProcessor { - - @BuildStep - FeatureBuildItem feature() { - return new FeatureBuildItem("cameleer"); - } - - @BuildStep - AdditionalBeanBuildItem registerBeans() { - return AdditionalBeanBuildItem.builder() - .addBeanClasses(CameleerLifecycle.class, CameleerConfigMapping.class) - .setUnremovable() - .build(); - } - - @BuildStep - ReflectiveClassBuildItem registerReflection() { - return new ReflectiveClassBuildItem(true, true, - ExecutionChunk.class, FlatProcessorRecord.class, - RouteGraph.class, RouteNode.class, RouteEdge.class, - AgentEvent.class, MetricsSnapshot.class, ExchangeSnapshot.class, - ErrorInfo.class, LogEntry.class, LogBatch.class, - ApplicationConfig.class, TapDefinition.class); - } -} -``` - -Dependencies: `cameleer-extension` (runtime), `io.quarkus:quarkus-arc-deployment`, `org.apache.camel.quarkus:camel-quarkus-core-deployment` - -### User experience - -```xml - - com.cameleer - cameleer-extension - 1.0-SNAPSHOT - -``` -```properties -cameleer.export.type=HTTP -cameleer.export.endpoint=http://cameleer-server:8081 -cameleer.agent.name=${HOSTNAME} -cameleer.engine.level=REGULAR -``` - -No `-javaagent`. Works in `mvn quarkus:dev`, `java -jar`, and native binary. - -## 3. `cameleer-quarkus-native-app` — Example Application - -Proves GraalVM native compilation works with the extension. - -### Routes (3 route classes, compact) - -| Class | Route IDs | Patterns | -|-------|-----------|----------| -| `NativeRestRoute` | `process-order`, `get-order` | REST DSL (platform-http), direct, processor | -| `NativeSplitTimerRoute` | `batch-split`, `timer-heartbeat` | timer, split, choice, log | -| `NativeErrorRoute` | `error-test`, `try-catch-test` | errorHandler (DLC), onException, doTry/doCatch | - -Reuses `Order` model (copy in own package, same as `cameleer-quarkus-app` pattern). - -### POM - -- Parent: `cameleer-parent` -- Dependencies: `cameleer-extension`, `camel-quarkus-*` extensions, `quarkus-junit5`, `rest-assured` -- Build profiles: - - Default: JVM mode (`quarkus-maven-plugin` with `build` goal) - - `native`: adds `-Dquarkus.native.enabled=true` - -### Dockerfile (`Dockerfile.quarkus-native`) - -Multi-stage build with cross-compilation for ARM64 builder → amd64 native binary: - -```dockerfile -# Stage 1: Build native binary -# Use Mandrel builder (GraalVM distribution for Quarkus) -FROM --platform=$BUILDPLATFORM quay.io/quarkus/ubi-quarkus-mandrel-builder-image:jdk-21 AS build -WORKDIR /build -COPY . . -# Container-based native build produces a linux-amd64 binary regardless of host arch -RUN mvn clean package -Pnative -DskipTests -B \ - -Dquarkus.native.container-build=false \ - -pl cameleer-common,cameleer-core,cameleer-extension/runtime,cameleer-quarkus-native-app - -# Stage 2: Minimal runtime -FROM quay.io/quarkus/quarkus-micro-image:2.0 -WORKDIR /app -COPY --from=build /build/cameleer-quarkus-native-app/target/*-runner /app/application -EXPOSE 8080 -ENTRYPOINT ["/app/application", \ - "-Dcameleer.export.type=${CAMELEER_EXPORT_TYPE}", \ - "-Dcameleer.export.endpoint=${CAMELEER_EXPORT_ENDPOINT}", \ - "-Dcameleer.agent.name=${HOSTNAME:-${CAMELEER_DISPLAY_NAME}}"] -``` - -**ARM64/AMD64 strategy:** Native compilation produces a platform-specific binary. Since our Gitea runner is ARM64 but deployment target is amd64, CI uses `docker buildx build --platform linux/amd64` which runs the entire build stage emulated on amd64 — this produces a correct amd64 native binary. This is slower than native ARM64 compilation (~5min vs ~2min) but architecturally simple and consistent with how our other Dockerfiles work. The Mandrel builder image is multi-arch and works under emulation. - -### K8s manifest (`deploy/quarkus-native-app.yaml`) - -- Deployment `cameleer-quarkus-native`, 1 replica -- NodePort 30085 -- Same env pattern as other apps - -### CI integration - -Native builds are slow (~3-5 minutes). Options: -- Add to main CI pipeline (increases build time significantly) -- Run as separate nightly/weekly job -- Run only on tag push (releases) - -Recommend: separate nightly job or manual trigger, not on every push. - -## 4. Pipeline & Infrastructure Updates - -### Root POM (`pom.xml`) - -New modules added: -```xml - - cameleer-common - cameleer-core - cameleer-agent - cameleer-extension - cameleer-sample-app - cameleer-backend-app - cameleer-caller-app - cameleer-quarkus-app - cameleer-quarkus-native-app - -``` - -Module order matters: `core` before `agent` and `extension`; `extension` before native app. - -### All Dockerfiles (5 existing + 1 new) - -Every Dockerfile copies all module POMs in the build stage. Add: -```dockerfile -COPY cameleer-core/pom.xml cameleer-core/ -COPY cameleer-extension/pom.xml cameleer-extension/ -COPY cameleer-extension/runtime/pom.xml cameleer-extension/runtime/ -COPY cameleer-extension/deployment/pom.xml cameleer-extension/deployment/ -COPY cameleer-quarkus-native-app/pom.xml cameleer-quarkus-native-app/ -``` - -Files to update: -- `Dockerfile` (sample app) -- `Dockerfile.backend` -- `Dockerfile.caller` -- `Dockerfile.quarkus` -- `Dockerfile.quarkus-native` (new) - -### CI Workflow (`.gitea/workflows/ci.yml`) - -- **build job**: `mvn clean verify` already builds all modules (no change needed for build command) -- **build job**: add artifact upload for quarkus-native-app (if included) -- **docker job**: add build+push step for `Dockerfile.quarkus-native` -- **docker cleanup**: add `cameleer-quarkus-native` to package cleanup loop -- **deploy job**: add kubectl apply + rollout for `cameleer-quarkus-native` - -### Compatibility Workflow (`.gitea/workflows/camel-compat.yml`) - -- Agent compat test: update `-pl` to include `cameleer-core`: - ``` - mvn test "-Dcamel.version=$VERSION" -pl cameleer-common,cameleer-core,cameleer-agent - ``` -- Quarkus compat test: no change needed (quarkus-app already tested) - -### Other Workflows - -- `release.yml`: publish `cameleer-core` and `cameleer-extension` to Maven registry alongside `cameleer-common` -- `sonarqube.yml`: no change (`mvn clean verify` covers all modules) -- `benchmark.yml`: no change (tests agent, not extension) -- `soak.yml`: no change - -### CLAUDE.md - -Update module list, add extension docs, update run commands. - -## 5. Native Image Constraints - -| Feature | JVM Mode | Native Mode | Approach | -|---------|:---:|:---:|---------| -| InterceptStrategy | Works | Works | Pure Camel SPI, no reflection | -| EventNotifier | Works | Works | Pure Camel SPI, no reflection | -| Route diagrams | Works | Works | `ReflectiveClassBuildItem` in deployment | -| Exporters (HTTP/LOG) | Works | Works | Standard HTTP client | -| JMX Metrics | Works | **Skip** | Detect `org.graalvm.nativeimage.imagecode`, log warning | -| OTel spans | Works | Works | OTel SDK supports native | -| Taps (expression eval) | Works | Partial | simple/jsonpath work; groovy needs registration | -| Log forwarding | Works | Works | CDI-based appender registration (no ClassInjector) | -| Prometheus endpoint | Works | Works | Custom HTTP server, no JMX | - -## 6. Implementation Order - -The work must be sequenced to keep the build green at each step: - -1. **Create `cameleer-core`** — new module, move classes, update packages -2. **Refactor `cameleer-agent`** — depend on core, update imports, update shade config -3. **Verify**: `mvn clean verify` — all existing tests must pass -4. **Create `cameleer-extension`** — runtime + deployment modules -5. **Create `cameleer-quarkus-native-app`** — example app with native profile -6. **Update all Dockerfiles** — add new POM COPY lines -7. **Create `Dockerfile.quarkus-native`** — native build Dockerfile -8. **Create `deploy/quarkus-native-app.yaml`** — K8s manifest -9. **Update CI/CD workflows** — ci.yml, camel-compat.yml, release.yml -10. **Update CLAUDE.md** — documentation -11. **Final verify**: `mvn clean verify` — all modules pass - -## 7. Verification - -1. `mvn clean verify` — all modules build and test (including agent regression) -2. Agent still works: `java -javaagent:...shaded.jar -jar ...sample-app.jar` -3. Extension JVM mode: run `cameleer-quarkus-native-app` with `java -jar` (no agent flag), verify execution chunks exported -4. Extension native mode: `mvn package -Pnative -pl ...quarkus-native-app`, run binary, verify execution chunks exported -5. REST endpoint test: `curl -X POST http://localhost:8080/api/orders ...` -6. Verify no agent hooks fire (no `-javaagent`), only extension CDI hooks -7. Docker builds: all 6 Dockerfiles build successfully - -## 8. Files Summary - -### New files -- `cameleer-core/pom.xml` -- `cameleer-core/src/main/java/com/cameleer/core/**` (moved from agent) -- `cameleer-extension/pom.xml` (parent) -- `cameleer-extension/runtime/pom.xml` -- `cameleer-extension/runtime/src/main/java/com/cameleer/extension/**` -- `cameleer-extension/deployment/pom.xml` -- `cameleer-extension/deployment/src/main/java/com/cameleer/extension/deployment/**` -- `cameleer-quarkus-native-app/pom.xml` -- `cameleer-quarkus-native-app/src/**` -- `Dockerfile.quarkus-native` -- `deploy/quarkus-native-app.yaml` - -### Modified files -- `pom.xml` (root — add modules) -- `cameleer-agent/pom.xml` (add core dependency, update shade config) -- `cameleer-agent/src/**` (update imports from `agent.*` to `core.*`) -- `Dockerfile`, `Dockerfile.backend`, `Dockerfile.caller`, `Dockerfile.quarkus` (add POM COPY lines) -- `.gitea/workflows/ci.yml` (docker build + deploy for native app) -- `.gitea/workflows/camel-compat.yml` (add core to `-pl`) -- `.gitea/workflows/release.yml` (publish core + extension) -- `CLAUDE.md` diff --git a/docs/superpowers/specs/2026-04-11-log-forwarding-v2-design.md b/docs/superpowers/specs/2026-04-11-log-forwarding-v2-design.md deleted file mode 100644 index 27c2717..0000000 --- a/docs/superpowers/specs/2026-04-11-log-forwarding-v2-design.md +++ /dev/null @@ -1,249 +0,0 @@ -# Log Forwarding v2 — Appender JAR + Server Pipeline - -**Date:** 2026-04-11 -**Issue:** [#77](https://gitea.siegeln.net/cameleer/cameleer/issues/77) -**Status:** Draft - -## Goal - -Replace the current ByteBuddy-injected log appender architecture (~12 files) with a two-JAR approach: a small appender JAR on the app classpath and the existing agent JAR. Forward all logs (application + agent) to the server via `ChunkedExporter`, where they are stored in ClickHouse for dashboard filtering by source, application, instance, exchange, route, and correlation ID. - -### Requirements - -1. **Remote log level control** — keep `LogLevelSetter` for dynamic `applicationLogLevel` and `agentLogLevel` changes via SSE `config-update`. -2. **MDC enrichment** — inject `cameleer.applicationId`, `cameleer.instanceId`, and `cameleer.correlationId` into the MDC alongside Camel's built-in MDC fields (`camel.exchangeId`, `camel.routeId`, `camel.correlationId`). -3. **Forward all logs to ClickHouse** — application logs and agent logs flow through the server pipeline (`ChunkedExporter` → Server HTTP → ClickHouse). -4. **Non-interference** — the app developer's existing logging pipeline (Datadog, ELK, stdout, etc.) must remain untouched. The Cameleer appender is additive. -5. **Agent log visibility** — agent operational logs (`com.cameleer.*`) are available on stderr for the customer to see bootstrap, connection, and error information locally. - -### Non-Goals - -- Log format control (no runtime pattern switching) -- External log collectors (Vector, Fluentd) — out of scope -- Direct agent-to-ClickHouse writes — logs go via the server -- JUL support — Spring Boot and Quarkus both use Logback or Log4j2 - -## Architecture - -``` -App Logs (Logback/Log4j2) - | - v -CameleerAppender (app CL, additive) - | App's own appenders (Datadog, stdout, etc.) - | still fire normally — untouched - v -LogEventBridge (system CL, AtomicReference) - | - v -LogForwarder - | - v -ChunkedExporter --> Server HTTP /api/v1/data/logs --> ClickHouse - - -Agent Logs (com.cameleer.*) - | - v -Custom SLF4J Backend (relocated) - | - +--> LogForwarder (same as above, tagged source: "agent") - | - +--> stderr (toggleable via cameleer.agent.logs.stderr) -``` - -## Components - -### 1. `cameleer-log-appender` Module (New) - -A new Maven module producing a small JAR (`cameleer-log-appender.jar`) placed on the application classpath. Contains framework-specific appenders that capture log events and forward them to the agent via `LogEventBridge`. - -**Files:** - -| File | Purpose | -|------|---------| -| `CameleerLogbackAppender` | Extends `AppenderBase`. Captures all log events. Reads MDC from the event. Converts to `LogEntry`. Calls `LogEventBridge` on system CL via reflection. Filters out `com.cameleer.*` loggers (agent logs have their own path). | -| `CameleerLog4j2Appender` | Extends `AbstractAppender`. Same pattern as Logback. Reads context data via `getContextData()`. | -| `LogAppenderRegistrar` | Detects the active logging framework (Logback or Log4j2). Programmatically adds the Cameleer appender to the root logger. Called by the agent via reflection. | - -**Dependencies (all `provided` scope):** -- `ch.qos.logback:logback-classic` -- `org.apache.logging.log4j:log4j-core` - -**Key behavior:** -- The appender is *additive* — it does not replace or interfere with existing appenders. -- Each log event is converted to a `LogEntry` with `source: "app"`. -- MDC map is copied from the framework event (includes both Camel and Cameleer MDC keys). -- The appender accesses `LogEventBridge` on the system classloader via `ClassLoader.getSystemClassLoader().loadClass("com.cameleer.core.logging.LogEventBridge")` and reflection on the static `handler` field. - -### 2. MDC Enrichment - -Inject Cameleer-specific MDC values during exchange processing, alongside Camel's built-in MDC (enabled via `setUseMDCLogging(true)`). - -**MDC keys added by Cameleer:** - -| Key | Value | Source | -|-----|-------|--------| -| `cameleer.applicationId` | Agent's application ID | `AgentConfig.applicationId` | -| `cameleer.instanceId` | Agent's instance ID | `AgentConfig.instanceId` | -| `cameleer.correlationId` | Cross-service correlation ID | `X-Cameleer-CorrelationId` header | - -**Lifecycle:** -- **Set** in `EventNotifier.onExchangeCreated()` (or earliest exchange hook) via `MDC.put()`. -- **Cleared** in `EventNotifier.onExchangeCompleted()` / `onExchangeFailed()` via `MDC.remove()`. -- Thread-safe: MDC is inherently thread-local, matching Camel's threading model. - -**Note:** `cameleer.applicationId` and `cameleer.instanceId` are static for the agent's lifetime. They could be set once at startup on the main thread, but MDC is thread-local so they must be set per-exchange to be present on the processing thread. - -### 3. Agent Log Capture - -Replace the relocated `slf4j-simple` backend with a custom relocated SLF4J backend that dual-writes to `LogForwarder` and stderr. - -**Behavior:** -- All `com.cameleer.*` log output is captured as `LogEntry` with `source: "agent"`. -- Stderr output controlled by `cameleer.agent.logs.stderr` (default `true`). -- Stderr format: timestamp, level, logger (short name), message — simple text, not JSON. -- Before `LogForwarder` is initialized (early bootstrap), logs only go to stderr. - -**Implementation:** A single `CameleerSLF4JServiceProvider` (SLF4J 2.x SPI) or `StaticLoggerBinder` (SLF4J 1.x) in the relocated SLF4J namespace. The provider creates logger instances that write to a static log queue. Once `LogForwarder` is initialized, it drains the queue and subsequent logs flow directly. - -### 4. `LogForwarder` (Simplified) - -Replaces the current `LogForwarder` implementation. No longer sends logs directly via HTTP — delegates to `ChunkedExporter`. - -**Responsibilities:** -- Receives `LogEntry` objects from both the appender bridge and the agent SLF4J backend. -- Buffers in a `ConcurrentLinkedQueue` (max 1,000 entries, same as today). -- Flushes batches to `ChunkedExporter` which handles HTTP transport, auth, reconnection, and backpressure. -- No direct HTTP client code — reuses existing `ChunkedExporter` infrastructure. - -### 5. `LogEntry` Model (Updated) - -**Existing fields (unchanged):** -- `timestamp` (Instant) -- `level` (String: TRACE, DEBUG, INFO, WARN, ERROR) -- `loggerName` (String) -- `message` (String, already interpolated) -- `threadName` (String) -- `stackTrace` (String, nullable) -- `mdc` (Map, nullable) - -**New field:** -- `source` (String: `"app"` or `"agent"`) — enables dashboard filtering by log origin. - -**Removed model:** -- `LogBatch` — no longer needed, `ChunkedExporter` handles batching. - -### 6. `LogLevelSetter` (Unchanged) - -Kept as-is. Remote log level control via SSE `config-update` command: -- `applicationLogLevel` → sets root logger level -- `agentLogLevel` → sets `com.cameleer` logger level - -No changes needed. - -### 7. `LogEventBridge` (Simplified) - -Kept but simplified. Single static `AtomicReference>` on the system classloader. The appender (app CL) accesses it via reflection. The agent sets the consumer to `LogForwarder::accept`. - -**Classloader constraint:** The bridge cannot pass typed `LogEntry` objects — `LogEntry` loaded by app CL is a different class than `LogEntry` loaded by system CL. Instead, the appender packs log data into an `Object[]` (timestamp, level, loggerName, message, threadName, stackTrace, mdcMap), and `LogForwarder` on the system CL reconstructs the `LogEntry`. This is the same pattern as the current `LogEventBridge` but with a defined field order contract instead of raw `Object`. - -## Files Removed - -| File | Reason | -|------|--------| -| `LogForwardingInstaller` | ByteBuddy class injection replaced by classpath-based appender | -| `LogEventConverter` | Conversion logic moves into appenders themselves | -| `CameleerJulHandler` | JUL not supported (Spring Boot/Quarkus use Logback or Log4j2) | -| `CameleerLogbackAppender` (agent) | Moves to `cameleer-log-appender` module | -| `CameleerLog4j2Appender` (agent) | Moves to `cameleer-log-appender` module | -| `LogBatch` | Batching handled by `ChunkedExporter` | -| ByteBuddy log-related injection code | No longer needed | - -Net: ~12 files of complex cross-classloader ByteBuddy code → ~5 files of straightforward appender + bridge code. - -## Files Added/Modified - -| File | Module | Action | -|------|--------|--------| -| `CameleerLogbackAppender` | log-appender | New (simpler, on app CL) | -| `CameleerLog4j2Appender` | log-appender | New (simpler, on app CL) | -| `LogAppenderRegistrar` | log-appender | New | -| `CameleerSLF4JServiceProvider` | agent | New (replaces slf4j-simple) | -| `LogForwarder` | core | Modified (delegates to ChunkedExporter) | -| `LogEventBridge` | core | Simplified (typed LogEntry) | -| `LogEntry` | common | Modified (add `source` field) | -| `ChunkedExporter` | core | Modified (add log batch support) | -| `EventNotifier` | core | Modified (MDC enrichment) | -| `PostStartSetup` | core | Modified (appender registration) | - -## Classpath Setup - -The appender JAR must be on the application's classpath so the logging framework can see the appender classes. - -| Platform | Setup | -|----------|-------| -| Spring Boot | `-Dloader.path=/path/to/cameleer-log-appender.jar` | -| Quarkus JVM (agent mode) | Copy `cameleer-log-appender.jar` to `quarkus-app/lib/main/` | -| Quarkus extension (no agent) | Maven dependency of `cameleer-extension-runtime` — on app CL automatically | -| Docker | `COPY` JAR into image, configure in entrypoint script | -| K8s | Include in container image or mount via ConfigMap/volume | - -Example Spring Boot command: -```bash -java -javaagent:cameleer-agent-1.0-SNAPSHOT-shaded.jar \ - -Dloader.path=cameleer-log-appender-1.0-SNAPSHOT.jar \ - -jar myapp.jar -``` - -## Configuration - -| Property | Default | Description | -|----------|---------|-------------| -| `cameleer.agent.logs.enabled` | `true` | Enable/disable log forwarding entirely | -| `cameleer.agent.logs.stderr` | `true` | Agent logs also written to stderr for local visibility | -| `cameleer.agent.applicationLogLevel` | — | Remote-controlled root logger level (existing) | -| `cameleer.agent.agentLogLevel` | — | Remote-controlled agent logger level (existing) | - -## Server-Side Impact (cameleer-server) - -- `/api/v1/data/logs` endpoint receives `List` batches (same as today, model updated with `source` field) -- Server writes to ClickHouse `logs` table -- Dashboard filters: source (app/agent/all), applicationId, instanceId, level, routeId, exchangeId, correlationId, time range -- No breaking protocol changes — `LogEntry` gains one additive field - -## ClickHouse Schema - -```sql -CREATE TABLE logs ( - timestamp DateTime64(3), - application_id LowCardinality(String), - instance_id LowCardinality(String), - level LowCardinality(String), - logger_name String, - message String, - thread_name Nullable(String), - stack_trace Nullable(String), - mdc Map(String, String), - source LowCardinality(String) DEFAULT 'app' -) ENGINE = MergeTree() -ORDER BY (application_id, timestamp) -TTL timestamp + INTERVAL 7 DAY; -``` - -MDC is stored as a ClickHouse `Map(String, String)` — allows querying any MDC key without schema changes when new keys are added. Dashboard filters for `exchangeId`, `routeId`, `correlationId` query into this map: - -```sql -SELECT * FROM logs -WHERE application_id = 'my-app' - AND mdc['camel.exchangeId'] = 'ABC-123' -ORDER BY timestamp; -``` - -## Testing Strategy - -- **Unit tests:** Appender captures log events and produces correct `LogEntry` (Logback + Log4j2). -- **Integration test:** End-to-end: log statement → appender → bridge → forwarder → mock server receives `LogEntry` with correct MDC and source. -- **Agent log test:** `com.cameleer.*` logs appear with `source: "agent"` and on stderr. -- **Non-interference test:** App's existing appenders still fire after Cameleer appender registration. -- **MDC test:** `cameleer.applicationId`, `cameleer.instanceId`, `cameleer.correlationId` present in captured log MDC during exchange processing. diff --git a/docs/superpowers/specs/2026-04-12-early-log-capture-design.md b/docs/superpowers/specs/2026-04-12-early-log-capture-design.md deleted file mode 100644 index 138990b..0000000 --- a/docs/superpowers/specs/2026-04-12-early-log-capture-design.md +++ /dev/null @@ -1,153 +0,0 @@ -# Early Log Capture Design - -## Problem - -Log forwarding currently registers the appender in `ServerSetup.installLogForwarding()` (post-CamelContext-start, after server connection). This means: - -1. **All startup logs are missed** — Spring Boot init, bean creation, Tomcat startup, CamelContext route creation — none of these reach the server. -2. **Quarkus native app has no log forwarding** — the extension's runtime module doesn't depend on `cameleer-log-appender`, so `LogAppenderRegistrar` can't find it. -3. **Classloader bridge was broken** — fixed in separate commits (system CL vs AgentCL mismatch, dual-CL BridgeAccess). - -## Goal - -Capture application logs as early as possible — ideally from Spring's `ApplicationContext.refresh()` (step 3 in the boot sequence) — and buffer them until the server connection is established. - -## Boot Sequence Timeline - -``` -1. JVM starts, premain() installs ByteBuddy transformers -2. Spring Boot initializes Logback (context.reset + XML config) -3. AbstractApplicationContext.refresh() ← REGISTER APPENDER HERE - - Bean scanning, autoconfiguration, Tomcat init -4. CamelContext bean created -5. preInstall() — InterceptStrategy installed -6. CamelContext.start() — routes created and started -7. postInstall() — EventNotifier, TapEvaluator, PostStartSetup -8. ServerSetup.connect() — server registration, config download - ← CURRENTLY registered here, SWAP EXPORTER here -``` - -## Design - -### 1. BridgeAccess Early Buffer - -`BridgeAccess` (in `cameleer-log-appender`) gains a bounded buffer for entries received before the bridge handler is set: - -- `forward(Object[] data)`: if handler is set, forward directly; if null, buffer the entry -- Buffer: `ConcurrentLinkedQueue`, capped at 5000 entries (oldest dropped on overflow) -- When the handler is first set (via `setBridgeHandler`), drain the buffer through the handler before switching to live mode -- Thread-safe drain: `synchronized` one-time drain with `volatile boolean bufferDrained` guard - -All three appenders call `BridgeAccess.forward(data)` instead of `getHandler()` + `accept()`. - -### 2. LogForwarder Deferred Exporter - -`LogForwarder` currently takes `Exporter` as a final constructor argument. Change to support deferred exporter: - -- Constructor accepts no exporter (or null) — starts scheduler but `flush()` skips drain when exporter is null -- `setExporter(Exporter)`: sets the exporter via `AtomicReference`, enabling flush -- Existing constructor `LogForwarder(Exporter)` remains for backwards compatibility -- `flush()`: checks `exporter.get() != null` before draining queue - -### 3. Early Appender Registration — Agent Mode (Spring Boot) - -Add a third ByteBuddy transformer in `premain()`: - -**`SpringContextTransformer`**: matches `org.springframework.context.support.AbstractApplicationContext`, instruments `refresh()` method. - -**`SpringContextAdvice`**: `@Advice.OnMethodEnter` — on entry to `refresh()`: -1. Get app classloader from the context object (`context.getClass().getClassLoader()`) -2. Call `LogAppenderRegistrar.install(appCL)` via reflection -3. Create `LogForwarder` (no exporter) and store in a static field -4. Call `setBridgeHandler(logForwarder::forward)` — entries start buffering in LogForwarder's queue (exporter is null, flush skips) - -Guard: `initialized` flag prevents re-registration on context refresh/restart. - -If the app is NOT Spring Boot (plain Java), this transformer never matches — preInstall handles registration instead. - -**preInstall changes**: Check if the SpringContextAdvice already registered the appender (via the static flag). If yes, skip re-registration. If no (plain Java app), register the appender here using the same logic. - -**postInstall changes**: When `ServerSetup.connect()` creates the `ChunkedExporter`, call `logForwarder.setExporter(chunkedExporter)` on the existing LogForwarder (don't create a new one). Next scheduler tick drains all buffered entries. - -### 4. Early Appender Registration — Extension Mode (Quarkus) - -**`CameleerConfigAdapter`** already has `@PostConstruct init()` which runs during CDI container initialization, before `CameleerLifecycle.configure()`. Add appender registration here: - -1. Call `LogAppenderRegistrar.install(Thread.currentThread().getContextClassLoader())` -2. Create `LogForwarder` (no exporter) -3. Set bridge handler via `setBridgeHandler(logForwarder::forward)` -4. Store LogForwarder reference for later exporter swap - -**`CameleerLifecycle.onCamelContextStarted()`**: When `ServerSetup.connect()` returns, call `logForwarder.setExporter(chunkedExporter)` to start flushing buffered entries. - -### 5. Extension Runtime Dependency - -Add `cameleer-log-appender` as a compile dependency of `cameleer-extension/runtime/pom.xml`: - -```xml - - com.cameleer - cameleer-log-appender - ${project.version} - -``` - -Any app using the Quarkus extension automatically gets log forwarding support. - -### 6. ServerSetup.installLogForwarding() Refactor - -This method currently creates LogForwarder + registers appender + sets bridge handler. Refactor to: - -- Accept an existing `LogForwarder` (created earlier) or null -- If LogForwarder already exists (early registration happened): just call `logForwarder.setExporter(exporter)` to activate flushing -- If null (fallback, e.g., direct HTTP mode without early hook): create LogForwarder, register appender, set bridge handler (current behavior) -- The `capabilities.put("logForwarding", true)` stays here since it requires server connection context - -### 7. Cleanup - -Remove diagnostic `System.err.println` lines from: -- `CameleerLogbackAppender` (forwardedCount, droppedNullHandler) -- `LogForwarder` (receivedCount, flushedCount) -- `ChunkedExporter` (logsSentCount) - -These were temporary diagnostics for the classloader bridge debugging. - -## What This Captures - -| Log source | Before (step 8) | After (step 3) | -|---|---|---| -| Spring Boot init (Logback config, env) | missed | missed | -| Bean creation, autoconfiguration | missed | **captured** | -| Tomcat/Undertow init | missed | **captured** | -| CamelContext creation | missed | **captured** | -| Route creation and startup | missed | **captured** | -| Agent hook installation | missed | **captured** | -| Server connection, config download | missed | **captured** | -| Runtime exchange processing | captured | captured | - -The only gap is step 1-2 (JVM bootstrap + logging framework init). If something fails there, the app never reaches `refresh()` and the error is on stdout/stderr. - -## Files Changed - -| File | Change | -|---|---| -| `cameleer-log-appender/.../BridgeAccess.java` | Add early buffer + drain logic, forward() method | -| `cameleer-log-appender/.../CameleerLogbackAppender.java` | Call `BridgeAccess.forward()`, remove diag lines | -| `cameleer-log-appender/.../CameleerLog4j2Appender.java` | Call `BridgeAccess.forward()` | -| `cameleer-log-appender/.../CameleerJulHandler.java` | Call `BridgeAccess.forward()` | -| `cameleer-core/.../LogForwarder.java` | AtomicReference, no-arg constructor, setExporter(), remove diag lines | -| `cameleer-core/.../ServerSetup.java` | Refactor installLogForwarding() to accept existing LogForwarder | -| `cameleer-agent/.../CameleerAgent.java` | Register SpringContextTransformer in premain() | -| `cameleer-agent/.../SpringContextTransformer.java` | NEW — ByteBuddy transformer for AbstractApplicationContext | -| `cameleer-agent/.../SpringContextAdvice.java` | NEW — registers appender on refresh() entry | -| `cameleer-agent/.../CameleerHookInstaller.java` | Check if early registration happened, wire existing LogForwarder | -| `cameleer-extension/runtime/pom.xml` | Add cameleer-log-appender dependency | -| `cameleer-extension/.../CameleerConfigAdapter.java` | Register appender in @PostConstruct | -| `cameleer-extension/.../CameleerLifecycle.java` | Wire existing LogForwarder's exporter on server connect | -| `cameleer-core/.../ChunkedExporter.java` | Remove diag lines | - -## Non-Goals - -- Capturing logs before logging framework init (step 1-2) — not feasible without instrumenting the logging framework itself -- Auto-detecting non-Spring, non-Quarkus frameworks (e.g., Micronaut) — preInstall fallback covers these -- Changing the log forwarding protocol or LogEntry model diff --git a/docs/superpowers/specs/2026-04-12-micrometer-metrics-integration-design.md b/docs/superpowers/specs/2026-04-12-micrometer-metrics-integration-design.md deleted file mode 100644 index 579e761..0000000 --- a/docs/superpowers/specs/2026-04-12-micrometer-metrics-integration-design.md +++ /dev/null @@ -1,345 +0,0 @@ -# Micrometer Metrics Integration Design - -**Date**: 2026-04-12 -**Status**: Draft -**Scope**: Agent core metrics, extension CDI integration, sample app updates, deployment manifests - -## Problem - -The current metrics implementation (`CamelMetricsBridge`) polls JMX MBeans on a 60-second interval and formats output via a hand-rolled `PrometheusFormatter`. This has several shortcomings: - -- JMX `MeanProcessingTime` is a cumulative average — no histogram/percentile data (p50, p95, p99) -- 60-second polling creates data gaps vs continuous Micrometer instrumentation -- Custom `PrometheusFormatter` is fragile and may not be compatible with newer Prometheus versions -- Metric names (`route.exchangesCompleted`) don't follow Camel's standard Micrometer naming conventions (`camel.exchanges.succeeded`) -- No agent-specific operational metrics (chunk export rates, heartbeat latency, SSE reconnects, etc.) -- The custom `PrometheusEndpoint` duplicates what the app framework already provides when Micrometer is present - -## Design - -### Strategy: Detect and Piggyback - -The agent detects whether Micrometer is on the target application's classpath. If present, it piggybacks on the app's existing `MeterRegistry` and registers agent-specific metrics. If absent, it falls back to the current JMX-based approach. - -This preserves the "zero-code instrumentation" philosophy — the agent never requires Micrometer — but recommends it for maximum observability. - -### 1. MetricsBridge Interface - -New interface in `cameleer-core` replacing the direct `CamelMetricsBridge` dependency: - -```java -public interface MetricsBridge { - void start(); - void stop(); - List getLatestSnapshots(); - boolean isAvailable(); -} -``` - -Both implementations satisfy this contract. `PostStartSetup` and `PrometheusEndpoint` depend on `MetricsBridge`, not a concrete class. - -### 2. MicrometerMetricsBridge (new) - -Created when Micrometer is detected on the classpath. Lives in `cameleer-core`. - -**Registry discovery** (via reflection, since core has no compile-time Micrometer dependency): - -1. `CamelContext.getRegistry().findByType(MeterRegistry.class)` — works for Spring Boot (bean) and Quarkus (CDI) -2. Fallback: `Metrics.globalRegistry` (Micrometer's static composite registry) - -**Behavior when active**: - -- Does NOT re-register JVM binders — Spring Boot and Quarkus auto-register `JvmMemoryMetrics`, `JvmGcMetrics`, `JvmThreadMetrics`, `ProcessorMetrics`, etc. -- Does NOT re-register Camel Micrometer metrics — if `camel-micrometer` is present, `MicrometerRoutePolicyFactory` and friends are already wired -- Registers `cameleer.*` agent metrics (see section 3) -- Does NOT start the custom `PrometheusEndpoint` — the app already exposes `/actuator/prometheus` (Spring Boot) or `/q/metrics` (Quarkus) -- For server push: periodically reads meters from the `MeterRegistry`, converts to `MetricsSnapshot` DTOs, exports via the existing `Exporter` interface - -**Reflection boundary**: Since `cameleer-core` has no compile-time Micrometer dependency, all Micrometer interaction (registry lookup, meter registration, periodic meter reading for server push) goes through reflection. This is intentional — the agent JAR must work on applications that don't have Micrometer. The reflection calls are confined to `MicrometerMetricsBridge` and its helper methods. The `CameleerMeterBinder` in the extension does NOT use reflection — it has a direct CDI dependency on `MeterRegistry`. - -**Performance**: All reflected `Method` and `MethodHandle` objects are resolved and cached at startup. The periodic meter reading (~20-50 cached method invocations every 60 seconds) has negligible overhead — the JVM JIT-optimizes cached reflection calls. If the reflection surface grows in future, this can be extracted to a thin optional SPI module (`cameleer-micrometer`) discovered via `ServiceLoader`. - -**Verification**: On startup, checks `registry.find("jvm.memory.used").gauge() != null` (via reflection) to confirm JVM binders are active. Logs a warning if not (framework may have disabled them). - -### 3. JmxMetricsBridge (rename of CamelMetricsBridge) - -The current `CamelMetricsBridge` is renamed to `JmxMetricsBridge` and refactored to implement `MetricsBridge`. No behavioral changes — same JMX polling, same `PrometheusEndpoint` support, same `PrometheusFormatter`. - -Used when Micrometer is not on the classpath (plain-app scenario). - -### 4. Agent Metrics (cameleer.* prefix) - -Registered on the app's `MeterRegistry` when Micrometer is present. In JMX fallback mode, tracked as additional `MetricsSnapshot` entries. - -| Metric | Type | Tags | Description | -|--------|------|------|-------------| -| `cameleer.chunks.exported` | Counter | `instanceId` | Execution chunks sent to server | -| `cameleer.chunks.dropped` | Counter | `instanceId`, `reason` | Chunks dropped (queue_full, server_error, backpressure) | -| `cameleer.heartbeat.latency` | Timer | `instanceId` | Heartbeat round-trip time | -| `cameleer.sse.reconnects` | Counter | `instanceId` | SSE reconnection count | -| `cameleer.taps.evaluated` | Counter | `instanceId` | Tap expression evaluations | -| `cameleer.taps.errors` | Counter | `instanceId` | Failed tap evaluations (no per-tap tag — avoids unbounded cardinality) | -| `cameleer.metrics.exported` | Counter | `instanceId` | Metric batches pushed to server | - -The `instanceId` tag uses the same value as the agent's registration identity (`{hostname}-{pid}`). - -### 5. Extension CDI Integration - -For `cameleer-extension` (Quarkus, no agent): - -- New `CameleerMeterBinder` class in extension runtime, `@ApplicationScoped`, implements `MeterBinder` -- Registers the same `cameleer.*` agent metrics listed above -- Quarkus auto-discovers `MeterBinder` CDI beans and calls `bindTo(registry)` -- If `quarkus-micrometer` is not on the app's classpath, the bean won't activate (unsatisfied `MeterRegistry` dependency) -- No reflection needed — direct CDI injection - -### 6. PostStartSetup Changes - -The orchestration logic becomes: - -``` -PostStartSetup.setupMetrics(ctx): - 1. Is io.micrometer.core.instrument.MeterRegistry on classpath? (Class.forName check) - YES: - a. Locate MeterRegistry via CamelContext registry - b. Create MicrometerMetricsBridge(registry, config, instanceId) - c. Register cameleer.* agent metrics - d. Skip PrometheusEndpoint setup - NO: - a. Create JmxMetricsBridge(config, camelContext, exporter, instanceId) - b. Start PrometheusEndpoint if config.isPrometheusEnabled() - 2. Return MetricsBridge instance -``` - -### 7. Sample App Dependency Changes - -**Spring Boot apps** (sample-app, backend-app, caller-app) — add to POM: - -```xml - - org.apache.camel.springboot - camel-micrometer-starter - - - io.micrometer - micrometer-registry-prometheus - -``` - -`spring-boot-starter-actuator` is already present in all three. - -**Spring Boot application.properties** additions: - -```properties -management.server.port=8081 -management.endpoints.web.exposure.include=health,info,camelroutes,metrics,prometheus -``` - -**caller-app**: change `server.port` from 8081 to 8080 (was conflicting with the management port convention). - -**Quarkus apps** (quarkus-app, quarkus-native-app) — add to POM: - -```xml - - org.apache.camel.quarkus - camel-quarkus-micrometer - - - io.quarkus - quarkus-micrometer-registry-prometheus - -``` - -**Quarkus application.properties** additions: - -```properties -quarkus.management.enabled=true -# Quarkus management interface defaults to port 9000 -``` - -**plain-app**: No changes. Stays as the JMX fallback test case. - -### 8. Dockerfiles - -Add Prometheus discovery labels and expose management ports. - -**Spring Boot Dockerfiles** (Dockerfile, Dockerfile.backend, Dockerfile.caller, Dockerfile.perf): - -```dockerfile -LABEL prometheus.scrape="true" -LABEL prometheus.path="/actuator/prometheus" -LABEL prometheus.port="8081" -EXPOSE 8080 8081 -``` - -**Quarkus Dockerfiles** (Dockerfile.quarkus, Dockerfile.quarkus-native): - -```dockerfile -LABEL prometheus.scrape="true" -LABEL prometheus.path="/q/metrics" -LABEL prometheus.port="9000" -EXPOSE 8080 9000 -``` - -**Plain Dockerfile** (Dockerfile.plain): - -```dockerfile -LABEL prometheus.scrape="true" -LABEL prometheus.path="/metrics" -LABEL prometheus.port="9464" -``` - -Uses the agent's custom `PrometheusEndpoint` as the scrape target (JMX fallback mode). - -### 9. K8s Manifests - -Add pod annotations and named container ports to all Deployments in `deploy/`. - -**Spring Boot apps** (sample-app, backend-app, caller-app, perf-app): - -```yaml -template: - metadata: - labels: - app: cameleer- - annotations: - prometheus.io/scrape: "true" - prometheus.io/path: "/actuator/prometheus" - prometheus.io/port: "8081" - spec: - containers: - - ports: - - name: http - containerPort: 8080 - - name: management - containerPort: 8081 -``` - -**Quarkus apps** (quarkus-app, quarkus-native-app): - -```yaml -annotations: - prometheus.io/scrape: "true" - prometheus.io/path: "/q/metrics" - prometheus.io/port: "9000" -ports: - - name: http - containerPort: 8080 - - name: management - containerPort: 9000 -``` - -**Plain app**: - -```yaml -annotations: - prometheus.io/scrape: "true" - prometheus.io/path: "/metrics" - prometheus.io/port: "9464" -ports: - - name: metrics - containerPort: 9464 -``` - -**caller-app**: change `containerPort` and Service `targetPort` from 8081 to 8080. - -### 10. Deployment & Monitoring Guide - -Different deployment scenarios have different Prometheus scraping patterns: - -#### Customer Self-Hosted (Docker Compose) - -Customer runs Prometheus/Grafana alongside their Camel apps. Prometheus scrapes agent metrics endpoints directly. The customer connects their app containers to a shared monitoring network: - -```yaml -services: - my-camel-app: - image: customer-app - networks: - - app-network - - monitoring # shared with Prometheus - - prometheus: - image: prom/prometheus - networks: - - monitoring -``` - -Docker labels on the container enable Prometheus `docker_sd_configs` auto-discovery. - -#### Customer Self-Hosted (Kubernetes) - -Prometheus uses `kubernetes_sd_configs` with pod-level service discovery. The `prometheus.io/*` pod annotations (added in section 9) enable auto-discovery. If `NetworkPolicy` resources restrict cross-namespace traffic, the customer adds an ingress rule allowing Prometheus to reach management ports. - -#### Cameleer SaaS (Docker Compose, Multi-Tenant) - -Per-tenant network isolation means Prometheus cannot scrape individual agents directly. Instead: - -- Agents push `MetricsSnapshot` data to the Cameleer server over the tenant network -- The server aggregates and exposes metrics on its own Prometheus endpoint -- Prometheus scrapes only the server (which is on the shared/Traefik network) - -#### Cameleer SaaS (Kubernetes, Multi-Tenant) - -Same pattern as Docker Compose SaaS — per-namespace isolation, agents push to server, Prometheus scrapes server only. - -## Files Changed - -### New Files - -| File | Description | -|------|-------------| -| `cameleer-core/.../metrics/MetricsBridge.java` | Interface for metrics bridge implementations | -| `cameleer-core/.../metrics/MicrometerMetricsBridge.java` | Micrometer-based implementation (reflection API) | -| `cameleer-extension/runtime/.../CameleerMeterBinder.java` | CDI MeterBinder for extension mode | - -### Modified Files - -| File | Change | -|------|--------| -| `cameleer-core/.../metrics/CamelMetricsBridge.java` | Rename to `JmxMetricsBridge`, implement `MetricsBridge` | -| `cameleer-core/.../PostStartSetup.java` | Detection logic: Micrometer vs JMX, skip PrometheusEndpoint when Micrometer present | -| `cameleer-core/.../export/ChunkedExporter.java` | Instrument with `cameleer.chunks.exported/dropped` counters | -| `cameleer-core/.../export/Exporter.java` | No interface change; implementations gain counter instrumentation | -| `cameleer-sample-app/pom.xml` | Add camel-micrometer-starter, micrometer-registry-prometheus | -| `cameleer-sample-app/.../application.properties` | Add management.server.port=8081, expose prometheus | -| `cameleer-backend-app/pom.xml` | Add camel-micrometer-starter, micrometer-registry-prometheus | -| `cameleer-backend-app/.../application.properties` | Add management.server.port=8081, expose prometheus | -| `cameleer-caller-app/pom.xml` | Add camel-micrometer-starter, micrometer-registry-prometheus | -| `cameleer-caller-app/.../application.properties` | Change server.port to 8080, add management.server.port=8081 | -| `cameleer-quarkus-app/pom.xml` | Add camel-quarkus-micrometer, quarkus-micrometer-registry-prometheus | -| `cameleer-quarkus-app/.../application.properties` | Add quarkus.management.enabled=true | -| `cameleer-quarkus-native-app/pom.xml` | Add camel-quarkus-micrometer, quarkus-micrometer-registry-prometheus | -| `cameleer-quarkus-native-app/.../application.properties` | Add quarkus.management.enabled=true | -| `Dockerfile` | Add LABEL prometheus.*, EXPOSE 8081 | -| `Dockerfile.backend` | Add LABEL prometheus.*, EXPOSE 8081 | -| `Dockerfile.caller` | Add LABEL prometheus.*, EXPOSE 8081, fix port | -| `Dockerfile.quarkus` | Add LABEL prometheus.*, EXPOSE 9000 | -| `Dockerfile.quarkus-native` | Add LABEL prometheus.*, EXPOSE 9000 | -| `Dockerfile.perf` | Add LABEL prometheus.*, EXPOSE 8081 | -| `Dockerfile.plain` | Add LABEL prometheus.* | -| `deploy/sample-app.yaml` | Add prometheus annotations, management port | -| `deploy/backend-app.yaml` | Add prometheus annotations, management port | -| `deploy/caller-app.yaml` | Add prometheus annotations, fix ports | -| `deploy/quarkus-app.yaml` | Add prometheus annotations, management port | -| `deploy/quarkus-native-app.yaml` | Add prometheus annotations, management port | -| `deploy/perf-app.yaml` | Add prometheus annotations, management port | -| `deploy/plain-app.yaml` | Add prometheus annotations, metrics port | - -### Unchanged - -| File | Reason | -|------|--------| -| `cameleer-plain-app/*` | JMX fallback test — no Micrometer added | -| `PrometheusEndpoint.java` | Kept for JMX fallback mode | -| `PrometheusFormatter.java` | Kept for JMX fallback mode | -| `MetricsSnapshot.java` | No changes — still used for server push in both modes | -| `cameleer-agent/` | No agent-module changes — core handles everything | - -## Testing Strategy - -- **Unit test**: `MicrometerMetricsBridgeTest` — mock `MeterRegistry`, verify `cameleer.*` meters registered, verify snapshot conversion -- **Unit test**: `JmxMetricsBridgeTest` — rename of existing `CamelMetricsBridgeTest`, verify unchanged behavior -- **Integration test**: Spring Boot sample app starts with Micrometer, `/actuator/prometheus` returns `cameleer_chunks_exported` metric -- **Integration test**: plain-app starts without Micrometer, custom `PrometheusEndpoint` on 9464 returns JMX-based metrics -- **Extension test**: Quarkus native app with extension, `/q/metrics` returns `cameleer_*` metrics via CDI MeterBinder diff --git a/docs/superpowers/specs/2026-04-14-sensitive-keys-unification-design.md b/docs/superpowers/specs/2026-04-14-sensitive-keys-unification-design.md deleted file mode 100644 index 4ce8de0..0000000 --- a/docs/superpowers/specs/2026-04-14-sensitive-keys-unification-design.md +++ /dev/null @@ -1,149 +0,0 @@ -# Sensitive Keys Unification + SSE Support + Pattern Matching - -**Date:** 2026-04-14 -**Status:** Approved - -## Problem - -The agent has two separate configuration fields for masking sensitive data in captured payloads: - -- `sensitiveHeaders` (default: `Authorization,Cookie,Set-Cookie,X-API-Key,X-Auth-Token,Proxy-Authorization`) -- `sensitiveProperties` (default: empty) - -Issues: - -1. **Unnecessary split** — a key like `secretToken` should be masked whether it appears as a Camel header or exchange property. Two lists means double configuration and easy omissions. -2. **Case-sensitive matching** — `sensitive.contains(key)` is case-sensitive. HTTP headers are case-insensitive by spec, and Camel normalizes inconsistently. A header arriving as `authorization` (lowercase) bypasses masking. This is a bug. -3. **No pattern support** — only exact key names match. No way to mask `X-Internal-*` or anything containing `password`. -4. **No SSE support** — sensitive keys can only be set via system properties at startup. The server cannot push updated masking rules at runtime. - -## Design - -### 1. SensitiveKeyMatcher (new class) - -Immutable matcher that splits configured keys into two tiers at construction time: - -- **Exact keys** (no `*` or `?`) — stored in `TreeSet(String.CASE_INSENSITIVE_ORDER)` for O(log n) case-insensitive lookup. -- **Glob patterns** (contain `*` or `?`) — compiled to `java.util.regex.Pattern` with `CASE_INSENSITIVE` flag. Compiled once, reused. - -**Match algorithm:** - -``` -matches(key): - if exactKeys.contains(key) -> true // fast path, no iteration - for each compiled pattern -> match // only reached on exact miss - false -``` - -**Glob-to-regex conversion:** - -| Glob | Regex | Use case | -|------|-------|----------| -| `*` | `.*` | Wildcard | -| `?` | `.` | Single char | -| Other regex metacharacters | Escaped | Safety | - -Pattern wrapped with `^...$` and compiled with `Pattern.CASE_INSENSITIVE`. - -**Examples:** - -| Key config | Matches | Does not match | -|------------|---------|----------------| -| `Authorization` | `Authorization`, `authorization`, `AUTHORIZATION` | `MyAuthorization` | -| `X-Internal-*` | `X-Internal-Token`, `x-internal-secret` | `X-Public-Token` | -| `*password*` | `db-password-hash`, `PASSWORD`, `userPassword` | `passwd` | -| `*-Secret` | `API-Secret`, `app-secret` | `SecretKey` | - -**Thread safety:** Immutable after construction. The `AtomicReference` swap in `CameleerAgentConfig` handles thread-safe replacement when SSE pushes new keys. - -**Location:** `cameleer-core/src/main/java/com/cameleer/core/collector/SensitiveKeyMatcher.java` - -### 2. ApplicationConfig change - -Add to `ApplicationConfig` in `cameleer-common`: - -```java -private List sensitiveKeys; -// + getter/setter -``` - -Sent by server in `config-update` SSE payloads. Supports both exact names and glob patterns. `@JsonInclude(NON_NULL)` means omitting the field leaves the agent's current config unchanged (same semantics as other nullable fields). - -### 3. CameleerAgentConfig change - -Replace: - -```java -private Set sensitiveHeaders; -private Set sensitiveProperties; -``` - -With: - -```java -private volatile SensitiveKeyMatcher sensitiveKeyMatcher; -``` - -- **System property:** `cameleer.agent.payload.sensitivekeys` (comma-separated, supports globs) -- **Default:** `Authorization,Cookie,Set-Cookie,X-API-Key,X-Auth-Token,Proxy-Authorization` -- **Old properties removed:** `sensitiveheaders` and `sensitiveproperties` are deleted, no backward compatibility. -- **Getter:** `getSensitiveKeyMatcher()` returns the current matcher. -- Handled in both `applyServerConfig()` (startup) and `applyServerConfigWithDiff()` (runtime SSE). - -### 4. PayloadCapture change - -Both `captureHeaders()` and `captureProperties()` use the same matcher: - -```java -SensitiveKeyMatcher matcher = config.getSensitiveKeyMatcher(); -// ... -if (matcher.matches(key)) { - result.put(key, "***MASKED***"); -} -``` - -### 5. No new SSE command - -This piggybacks on the existing `config-update` command. The server includes `sensitiveKeys` in the `ApplicationConfig` payload — same pattern as `tracedProcessors`, `compressSuccess`, `routeRecording`, etc. - -## Server Team Contract - -The server needs to: - -1. **Store** `sensitiveKeys: List` on the application config document. -2. **Include** the field in `config-update` SSE payloads when set. -3. **Accept** both exact key names and glob patterns (`*`, `?`) in the list. -4. **Omit** the field (or send `null`) to leave the agent's current keys unchanged. -5. **Send** an empty list `[]` to clear all masking (agent will mask nothing). - -Example config-update payload fragment: - -```json -{ - "sensitiveKeys": [ - "Authorization", - "Cookie", - "Set-Cookie", - "X-API-Key", - "*password*", - "*secret*", - "X-Internal-*" - ], - "version": 42, - ... -} -``` - -Agent ACK will include `sensitiveKeys` in the change summary if the list differs from current. - -## Files Changed - -| File | Change | -|------|--------| -| `cameleer-core/.../collector/SensitiveKeyMatcher.java` | **New** — immutable glob matcher | -| `cameleer-core/.../CameleerAgentConfig.java` | Replace two fields with one `SensitiveKeyMatcher`, add to `applyServerConfig`/`applyServerConfigWithDiff` | -| `cameleer-core/.../collector/PayloadCapture.java` | Use `getSensitiveKeyMatcher().matches()` for both headers and properties | -| `cameleer-common/.../model/ApplicationConfig.java` | Add `sensitiveKeys` field | -| `cameleer-agent/.../collector/PayloadCaptureTest.java` | Update mocks, add pattern tests | -| `cameleer-agent/.../collector/SensitiveKeyMatcherTest.java` | **New** — unit tests for matcher | -| `cameleer-agent/.../perf/*` | Update mock stubs | diff --git a/docs/superpowers/specs/2026-04-14-simplified-log-forwarding-design.md b/docs/superpowers/specs/2026-04-14-simplified-log-forwarding-design.md deleted file mode 100644 index 28a372d..0000000 --- a/docs/superpowers/specs/2026-04-14-simplified-log-forwarding-design.md +++ /dev/null @@ -1,168 +0,0 @@ -# Simplified Log Forwarding Architecture - -**Date:** 2026-04-14 -**Status:** Approved (conversation design review) - -## Problem - -The current log forwarding has three buffering layers (BridgeAccess early buffer → LogForwarder deferred exporter → ChunkedExporter) to cover the timing gap between appender registration and server connection. This creates: - -- Framework-specific early hooks: `SpringContextAdvice` hooks `AbstractApplicationContext.refresh()` for Spring apps; `CameleerHookInstaller.preInstall()` has a fallback path for non-Spring apps -- `BridgeAccess` early buffer (5000 entries) with synchronized drain logic and classloader reflection -- `LogForwarder` deferred exporter pattern (no-arg constructor, `setExporter()` called later) -- `LogForwardingConsumer` wrapper class to avoid ByteBuddy lambda inlining issues -- Cross-classloader complexity in `SpringContextAdvice` (loads LogForwarder on system CL via reflection) - -Despite this complexity, **bootstrap failures before CamelContext.start() are still not captured** — the server connection only happens in `postInstall`, so if the app dies during Spring context initialization, buffered logs die with the process. - -## Decision - -1. **Move server connection to `preInstall`** — connect before routes start, so the exporter is live when the appender is registered. No deferred pattern needed. -2. **External infrastructure handles pre-agent logs** — Docker/K8s logging drivers, DaemonSet shippers, or kubelet events capture container lifecycle before the JVM starts. The server team will implement this. -3. **Remove all early buffering machinery** — BridgeAccess buffer, deferred exporter, SpringContextAdvice log code, LogForwardingConsumer. -4. **Unified appender registration path** — same code for Spring Boot, Quarkus, and Plain Java, executed in preInstall/configure. - -### Why preInstall instead of premain? - -The original conversation explored moving the server connection to `premain()`. However, the agent uses an `AgentClassLoader` (child-first) that creates a separate class namespace from the system classloader. Objects created in `premain()` (system CL) cannot be passed to `CameleerHookInstaller` (AgentClassLoader) without complex cross-classloader reflection using only primitives. Since `preInstall` already fires before any route processes exchanges (it runs at the start of `CamelContext.start()`), it meets the requirement of "fully initialized before the first route executes" without the classloader complexity. - -## Architecture - -### Three-Phase Log Coverage Model - -| Phase | Coverage | Mechanism | -|-------|----------|-----------| -| Pre-JVM (container fails before Java runs) | Server team / infra | kubelet events, Docker logging drivers, DaemonSet shipper | -| JVM start → preInstall (framework bootstrap) | Not captured by agent | Infra-level capture; acceptable gap | -| preInstall onward (routes executing) | Agent | In-agent appender with structured log forwarding | - -### New Startup Sequence (Agent Mode) - -``` -premain() - → install CamelContext transformer (existing) - → install SendDynamicAware transformer (existing) - → [REMOVED: Spring AbstractApplicationContext transformer] - -CamelContext.start() intercepted → preInstall() - → install InterceptStrategy (existing) - → earlyConnect: ServerConnection + register (minimal) + ChunkedExporter - → register log appender on root logger (unified, all frameworks) - → create LogForwarder WITH live exporter - → set bridge handler on LogEventBridge - → ✅ log forwarding LIVE before any route processes an exchange - -CamelContext.start() completes → postInstall() - → create EventNotifier (existing) - → PostStartSetup: diagrams, metrics, re-register with full info, heartbeat, SSE -``` - -### New Startup Sequence (Extension Mode) - -``` -@PostConstruct (CameleerConfigAdapter) - → set system properties from Quarkus config - → [REMOVED: appender registration and earlyLogForwarder] - -configure() (CameleerLifecycle - before CamelContext.start()) - → earlyConnect: ServerConnection + register (minimal) + ChunkedExporter - → create collector + InterceptStrategy + EventNotifier (existing) - → register log appender on root logger - → create LogForwarder WITH live exporter - → set bridge handler - → ✅ log forwarding LIVE - -CamelContextStartedEvent - → PostStartSetup: diagrams, metrics, re-register, heartbeat, SSE -``` - -### Server-Side Cutover - -The `logForwarding` capability in the registration/heartbeat capabilities map signals when agent log forwarding is active. The server team can use this to stop ingesting infra-level logs for that instance, avoiding duplicates. - -## Changes - -### New: `ServerSetup.earlyConnect(CameleerAgentConfig)` - -Minimal server registration before routes start. Returns `EarlyConnection(ServerConnection, ChunkedExporter)` or null on failure. - -- Creates `ServerConnection` with endpoint + auth token -- Registers with: instanceId, applicationId, environmentId, version, empty routeIds, minimal capabilities -- Creates `ChunkedExporter` wired to the connection -- On failure: logs warning, returns null (agent falls back to `LogExporter`) - -### New: `ServerSetup.setupLogForwarding(config, exporter, appClassLoader)` - -Shared method for both agent and extension to register the appender and create a LogForwarder in one call. - -### Modified: `ServerSetup.connect(ConnectionContext)` - -- `ConnectionContext` gains an `earlyConnection` field (replaces `earlyLogForwarder`) -- If `earlyConnection` is present: reuses `ServerConnection` and `ChunkedExporter`, re-registers with full route IDs and capabilities -- If absent: full connection as before (backward compat for edge cases) -- `installLogForwarding()` removed (log forwarding already set up in preInstall) - -### Modified: `PostStartSetup.Context` - -- `earlyLogForwarder` field replaced by `earlyConnection` (ServerSetup.EarlyConnection) -- `logForwarder` passed separately (already created in preInstall) - -### Modified: `LogForwarder` - -- No-arg constructor **removed** -- Constructor always requires a non-null `Exporter` -- `setExporter()` **kept** for one case: early connect failed (LogExporter), late connect succeeded (swap to ChunkedExporter) -- Internal queue and flush scheduler unchanged - -### Simplified: `BridgeAccess` - -- `earlyBuffer` (ConcurrentLinkedQueue) **removed** -- `drainBuffer()` **removed** -- `MAX_BUFFER_SIZE` **removed** -- `forward()` calls handler directly; if no handler, entry is silently dropped -- `setHandler()`, `getHandler()`, `resolveField()` unchanged -- Test utility methods updated - -### Removed: `SpringContextAdvice` - -- Entire class deleted (only purpose was early log registration) - -### Removed: `LogForwardingConsumer` - -- Entire class deleted (was only needed by SpringContextAdvice) - -### Removed: `SpringContextTransformer` - -- Entire class deleted (ByteBuddy transformer for SpringContextAdvice) - -### Modified: `CameleerAgent.premain()` - -- Remove `AbstractApplicationContext` transformer installation (lines 67-72) - -### Modified: `CameleerHookInstaller` - -- `preInstall()`: calls `earlyConnect()`, registers appender, creates `LogForwarder` with live exporter, stores `EarlyConnection` for postInstall -- `postInstall()`: no longer picks up `SpringContextAdvice.earlyLogForwarder`; passes `EarlyConnection` + `logForwarder` to `PostStartSetup` - -### Modified: `CameleerConfigAdapter` (Extension) - -- Remove `earlyLogForwarder` field and `getEarlyLogForwarder()` -- Remove appender registration from `@PostConstruct` - -### Modified: `CameleerLifecycle` (Extension) - -- `configure()`: calls `earlyConnect()`, registers appender, creates `LogForwarder` -- `onCamelContextStarted()`: passes `EarlyConnection` + `logForwarder` to `PostStartSetup` - -## Test Changes - -- `LogForwarderTest`: remove `deferredExporter_buffersUntilExporterSet` test (deferred pattern no longer exists) -- `BridgeAccessTest`: remove buffer tests (`forward_buffersWhenNoHandler`, `forward_drainsBufferWhenHandlerSet`, `forward_bufferCapped`); add test for silent drop when no handler -- Remaining tests unchanged (they already pass exporter to constructor) - -## Constraints - -- Must not steal the customer's log stream — appender adds alongside existing appenders, never replaces -- Must work with Datadog, Elastic, and other APM agents that also add appenders -- Extension path (Quarkus native, no agent) remains separate but follows the same simplified pattern -- If server is unreachable in preInstall, graceful fallback to LogExporter; postInstall retries connection diff --git a/docs/superpowers/specs/2026-04-16-env-scoped-config-url-agent-design.md b/docs/superpowers/specs/2026-04-16-env-scoped-config-url-agent-design.md deleted file mode 100644 index 590ef84..0000000 --- a/docs/superpowers/specs/2026-04-16-env-scoped-config-url-agent-design.md +++ /dev/null @@ -1,117 +0,0 @@ -# Env-Scoped Config URL (Agent Side) - -**Date:** 2026-04-16 -**Status:** Approved -**Scope:** `cameleer-agent`, `cameleer-core`, `cameleer-extension`, `cameleer-common` - -## Problem - -The agent fetches its per-application config from `GET /api/v1/config/{applicationId}`. The URL is not env-scoped, so the same URL returns the same config for every environment of the same application. Agents in `dev` and `prod` pointing at the same server and application receive identical config, which defeats per-env engine levels, tap sets, sensitive-key lists, and log levels. - -Server-side review concluded with a broader URL-taxonomy change: user-facing, env-scoped endpoints move under `/api/v1/environments/{envSlug}/...`, while agent ingestion (`/api/v1/data/*`) and agent-instance paths (`/api/v1/agents/{instanceId}/...`) stay flat and JWT-authoritative. This spec covers only what the agent modules must change to align with that server-side move. - -## Agent-side surface affected - -Only one HTTP call changes. Everything else in the agent's REST surface — registration, heartbeat, deregister, refresh, command ack, SSE, and all five data-ingestion endpoints — is unchanged. - -| Call | Today | Under this design | -|---|---|---| -| Config fetch | `GET /api/v1/config/{applicationId}` | `GET /api/v1/environments/{environmentId}/apps/{applicationId}/config` | - -`ApplicationConfig.environment` already exists in `cameleer-common` from a prior landing, so no model change is needed. - -## Design - -### Decisions - -| # | Question | Decision | -|---|---|---| -| 1 | `envSlug` vs `environmentId` | Same value. The agent uses its existing `environmentId` (system property `cameleer.agent.environment`, default `"default"`) directly in the URL. No new concept on the wire. | -| 2 | Backward compatibility | Hard cut. Agent only calls the new URL. No fallback, no capability negotiation. Agent and server ship together (pre-release project). | -| 3 | Response env validation | Strict. After deserializing `ApplicationConfig`, the agent throws if `config.environment` does not equal its own `registeredEnvironmentId`. `null` also throws — no tolerance. | -| 4 | Protocol version header | Unchanged at `1`. Pre-release, hard-cut; bumping adds ceremony without function. | - -### `ServerConnection.fetchApplicationConfig` - -Signature stays `(String application)`. Environment is read from the existing `registeredEnvironmentId` field, which `register()` already populates. Callers do not change. - -New behavior: - -``` -url = baseUrl + "/api/v1/environments/" + registeredEnvironmentId - + "/apps/" + application + "/config" -``` - -401/403 retry path (existing) targets the same new URL. - -Order of post-response checks: - -1. HTTP status / 401+403 refresh path (existing). -2. Deserialize envelope → `ApplicationConfig`. -3. Merge `mergedSensitiveKeys` into `ApplicationConfig.sensitiveKeys` (existing). -4. **Version check first.** If `config.getVersion() == 0`, return `null` (existing semantics: "no config stored"). Do not validate env — a default/placeholder response has nothing meaningful to validate and the caller treats `null` as "keep current state." -5. **Env validation.** Only reached when `version > 0`: - -``` -if (config.getEnvironment() == null - || !config.getEnvironment().equals(registeredEnvironmentId)) { - throw new RuntimeException( - "Config fetch returned env '" + config.getEnvironment() - + "' but agent is registered for '" + registeredEnvironmentId + "'"); -} -``` - -The order matters: putting version first preserves the current "no config yet" signalling path (which agents handle gracefully by keeping their prior state) while still rejecting any real config that comes back mis-scoped. - -### Cache key - -`ConfigCacheManager` keeps its per-application key. An agent instance is bound to one environment for its whole lifetime; there is no scenario in which it would cache configs for multiple envs. No change. - -### Heartbeat body - -`HeartbeatRequest.environmentId` stays. It becomes redundant with the JWT's env claim but is harmless and useful for server-side sanity checks during rollout. No change. - -### PROTOCOL.md - -Section 3 endpoint table: the `GET /api/v1/config/{applicationId}` row is replaced with `GET /api/v1/environments/{environmentId}/apps/{applicationId}/config`. The "Config Endpoint Response Envelope" subsection updates its heading to the new URL. The envelope shape (`{config, globalSensitiveKeys, mergedSensitiveKeys}`) is unchanged. - -## Error handling - -| Case | Behavior | -|---|---| -| 401 / 403 | Refresh token, retry the new URL once (existing path). | -| Non-200 after refresh | Throw `RuntimeException("Config fetch failed: HTTP nnn")` (existing). | -| 200 + `version == 0` | Return `null` (existing "no config stored" signal). Env is not validated in this case. | -| 200 + `version > 0` + `environment == null` | Throw — strict validation. | -| 200 + `version > 0` + `environment != registeredEnvironmentId` | Throw — strict validation. | -| 200 + `version > 0` + env matches | Return config. | - -All thrown exceptions propagate to the caller, which retains its prior in-memory config. No regression vs today — today's `fetchApplicationConfig` already throws on non-200. - -## Testing - -All new tests live in `cameleer-core/src/test/java/com/cameleer/core/connection/ServerConnectionTest.java`. - -- **URL shape** — `ArgumentCaptor` asserts the request URI is `/api/v1/environments/{env}/apps/{app}/config` for a connection registered with env `dev` and app `orders`. -- **Strict mismatch rejection** — mock returns `{config: {application:"orders", environment:"prod", version:1, ...}}` for a connection registered as `dev`; assert `fetchApplicationConfig` throws and the caller receives no config. -- **Strict null rejection** — mock returns a config without `environment` populated; assert `fetchApplicationConfig` throws. -- **Happy path env match** — mock returns `{config: {environment:"dev", version:1, ...}}`; assert the config is returned with the expected fields. - -No changes to `ChunkedExporterTest` (data endpoints are not in scope), `SseClientTest`, or registration tests. - -## Non-goals - -- No changes to `/api/v1/data/*` endpoints. They stay flat and JWT-authoritative. -- No changes to `/api/v1/agents/{instanceId}/*` endpoints (register, heartbeat, deregister, refresh, ack, SSE). -- No change to the registration or heartbeat payloads. -- No deprecation period or dual-URL support in the agent. -- No `ConfigCacheManager` restructuring. -- No new `X-Cameleer-Protocol-Version` value. - -## Rollout - -Agent and server are upgraded together. The server logs old-shape usage for its own migration purposes (out of scope here). Once the agent lands on the new URL, there is no further coordination required. - -## Closes - -- Memory: `project_env_scoped_config.md` — all three open questions (URL shape, response validation, cache key) are resolved by this design. The memory is retired once this spec is implemented. diff --git a/docs/superpowers/specs/2026-04-17-agent-config-endpoint-flat.md b/docs/superpowers/specs/2026-04-17-agent-config-endpoint-flat.md deleted file mode 100644 index 3354c1f..0000000 --- a/docs/superpowers/specs/2026-04-17-agent-config-endpoint-flat.md +++ /dev/null @@ -1,89 +0,0 @@ -# Agent Config Endpoint — Flat, JWT-Resolved - -**Date:** 2026-04-17 -**Status:** Approved -**Scope:** `cameleer-core`, `cameleer-common` -**Supersedes:** The agent-side URL from `2026-04-16-env-scoped-config-url-agent-design.md` only. The env-scoped route family (`/api/v1/environments/{envSlug}/...`) remains for user-facing endpoints on the server; it simply is not how the agent fetches its own config. - -## Problem - -The 2026-04-16 landing moved the agent's config fetch to `GET /api/v1/environments/{envId}/apps/{app}/config`. In practice that route is 403 for the agent because the server's only `hasRole("AGENT")` config-read endpoint is flat and JWT-scoped (`SecurityConfig.java:127`). The env-scoped route is reserved for user-facing calls. - -## Design - -### Endpoint - -``` -GET {baseUrl}/api/v1/agents/config -``` - -Zero URL parameters. The server resolves `(application, environment)` from the agent's JWT subject → registry entry (heartbeat-authoritative), with the JWT env claim as fallback. - -### `ServerConnection.fetchApplicationConfig` - -Signature changes from `(String application)` to `()` — the parameter was only used to build the URL and is now redundant. Callers drop the argument. - -New behavior: - -``` -path = "/api/v1/agents/config" -``` - -401/403 refresh-retry path (existing) targets the same URL. - -Post-response checks are unchanged from the 2026-04-16 design: - -1. HTTP status / 401+403 refresh path (existing). -2. Deserialize envelope → `ApplicationConfig`. -3. Merge `mergedSensitiveKeys` into `ApplicationConfig.sensitiveKeys` (existing). -4. Version check: if `config.getVersion() == 0`, return `null` (no config stored). -5. Env validation (version > 0 only): throw if `config.getEnvironment() == null` or does not equal `registeredEnvironmentId`. - -The response envelope keeps `environment` populated when `version > 0`, so strict validation remains a cheap cross-check that the server's JWT→env resolution agrees with what the agent registered as. - -### Fields and state - -- `registeredEnvironmentId` is still populated by `register()` and still used for response validation and the heartbeat body. No change. -- `ConfigCacheManager` keeps its per-application key. No change. -- Heartbeat body keeps `environmentId`. No change. - -### PROTOCOL.md - -- Section 3 endpoint table: replace the `GET /api/v1/environments/{environmentId}/apps/{applicationId}/config` row with `GET /api/v1/agents/config` — "Fetch per-agent config (server resolves application and environment from the agent's JWT)". -- "Config Endpoint Response Envelope" heading: update to the new URL. -- The paragraph on strict env validation and the `version == 0` bypass stays as-is — the response envelope and validation behavior are unchanged. -- SSE reconnection bullet that references the config URL: update to the flat URL. - -## Error handling - -Unchanged from 2026-04-16. Table applies verbatim: - -| Case | Behavior | -|---|---| -| 401 / 403 | Refresh token, retry once. | -| Non-200 after refresh | `RuntimeException("Config fetch failed: HTTP nnn")`. | -| 200 + `version == 0` | Return `null`. Env not validated. | -| 200 + `version > 0` + `environment == null` | Throw. | -| 200 + `version > 0` + `environment != registeredEnvironmentId` | Throw. | -| 200 + `version > 0` + env matches | Return config. | - -## Testing - -`cameleer-core/src/test/java/com/cameleer/core/connection/ServerConnectionTest.java`: - -- **URL shape** — `ArgumentCaptor` asserts the request URI path is `/api/v1/agents/config` (no env/app segments). -- **Strict mismatch rejection** — retained; response body still carries `environment`, and the agent still rejects a mismatch. -- **Strict null rejection** — retained. -- **Happy path env match** — retained. -- All `fetchApplicationConfig(...)` call sites in tests drop the argument. - -## Non-goals - -- No changes to `/api/v1/data/*` (unchanged, JWT-authoritative). -- No changes to `/api/v1/agents/{instanceId}/*` (register, heartbeat, deregister, refresh, ack, SSE). -- No removal of `registeredEnvironmentId` field or heartbeat env field. -- No protocol-version bump. - -## Rollout - -Hard cut on main. Server's agent-config route is already live (prior env-scoped URL is 403), so this fixes the agent immediately on deploy.