docs: delete superpowers plans and specs for completed work
All 11 plans and the matching design specs have shipped — implementations
are in the tree and git history. Keeping three reference docs in specs/:
- 2026-03-26-extracts-replay-usage-guide.md (server UI design input)
- 2026-03-28-chunked-transport-polyglot-storage-design.md (superseded, kept for historical context per commit 0266203)
- 2026-03-28-expert-review-conclusion.md (informed ClickHouse design, kept for historical context)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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<RouteExecution> 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<RouteExecution> 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.
|
||||
* <p>
|
||||
* 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> capturedProperties = new CopyOnWriteArrayList<>();
|
||||
List<String> 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<Map<String, Object>> mainFlowProperties = new CopyOnWriteArrayList<>();
|
||||
List<String> 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<Map<String, Object>> splitSubProperties = new CopyOnWriteArrayList<>();
|
||||
List<Map<String, Object>> cbInternalProperties = new CopyOnWriteArrayList<>();
|
||||
List<String> splitSubExchangeIds = new CopyOnWriteArrayList<>();
|
||||
List<String> 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.
|
||||
* <p>
|
||||
* 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<ProcessorExecution> 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<ProcessorExecution> 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<ProcessorExecution> 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"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
<modules>
|
||||
<module>cameleer-common</module>
|
||||
<module>cameleer-core</module>
|
||||
<module>cameleer-agent</module>
|
||||
...
|
||||
</modules>
|
||||
```
|
||||
|
||||
- [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
|
||||
<dependency>
|
||||
<groupId>com.cameleer</groupId>
|
||||
<artifactId>cameleer-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
- [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
|
||||
<module>cameleer-extension</module>
|
||||
```
|
||||
|
||||
- [x] **Step 2: Create extension parent POM**
|
||||
|
||||
`cameleer-extension/pom.xml`:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.cameleer</groupId>
|
||||
<artifactId>cameleer-parent</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<artifactId>cameleer-extension-parent</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
<name>Cameleer Extension Parent</name>
|
||||
<modules>
|
||||
<module>runtime</module>
|
||||
<module>deployment</module>
|
||||
</modules>
|
||||
</project>
|
||||
```
|
||||
|
||||
- [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 `<module>cameleer-quarkus-native-app</module>` 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
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>native</id>
|
||||
<properties>
|
||||
<quarkus.native.enabled>true</quarkus.native.enabled>
|
||||
</properties>
|
||||
</profile>
|
||||
</profiles>
|
||||
```
|
||||
|
||||
- [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
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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<String> registerResp = mock(HttpResponse.class);
|
||||
when(registerResp.statusCode()).thenReturn(200);
|
||||
when(registerResp.body()).thenReturn(registerJson);
|
||||
|
||||
HttpResponse<String> 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<String> registerResp = mock(HttpResponse.class);
|
||||
when(registerResp.statusCode()).thenReturn(200);
|
||||
when(registerResp.body()).thenReturn(registerJson);
|
||||
|
||||
HttpResponse<String> 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<String> registerResp = mock(HttpResponse.class);
|
||||
when(registerResp.statusCode()).thenReturn(200);
|
||||
when(registerResp.body()).thenReturn(registerJson);
|
||||
|
||||
HttpResponse<String> 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}).
|
||||
*
|
||||
* <p>Response envelope (see PROTOCOL.md §3):
|
||||
* <pre>
|
||||
* { "config": { ...ApplicationConfig... },
|
||||
* "globalSensitiveKeys": [...],
|
||||
* "mergedSensitiveKeys": [...] }
|
||||
* </pre>
|
||||
* 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<String> 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<String> 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) <noreply@anthropic.com>
|
||||
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 == <its registered env>` 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) <noreply@anthropic.com>
|
||||
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.
|
||||
@@ -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<String> registerResp = mock(HttpResponse.class);
|
||||
when(registerResp.statusCode()).thenReturn(200);
|
||||
when(registerResp.body()).thenReturn(registerJson);
|
||||
|
||||
HttpResponse<String> 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}).
|
||||
*
|
||||
* <p>Response envelope (see PROTOCOL.md §3):
|
||||
* <pre>
|
||||
* { "config": { ...ApplicationConfig... },
|
||||
* "globalSensitiveKeys": [...],
|
||||
* "mergedSensitiveKeys": [...] }
|
||||
* </pre>
|
||||
* 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<String> 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<String> 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) <noreply@anthropic.com>
|
||||
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) <noreply@anthropic.com>
|
||||
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.
|
||||
@@ -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
|
||||
@@ -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<String, String> 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)
|
||||
@@ -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<String, Double>` |
|
||||
|
||||
`CameleerAgentConfig` additions:
|
||||
- `volatile double samplingRate = 1.0`
|
||||
- `volatile Map<String, Double> 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<String, String> 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<String, ContextState> 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
|
||||
@@ -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<RouteExecution> 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
|
||||
@@ -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<Integer> // stack of active processor seqs (depth-proportional)
|
||||
seqToProcessorId: Map<Integer, String> // bounded by stack depth, cleaned on pop
|
||||
loopStates: Deque<LoopTrackingState> // one entry per active loop nesting level
|
||||
envelope: ExchangeEnvelope // routeId, correlationId, status, startTime, attributes, etc.
|
||||
buffer: ConcurrentLinkedQueue<FlatProcessorRecord> // 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<Integer, ProcessorRecord>
|
||||
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.
|
||||
@@ -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 `<dependency>cameleer-core</dependency>`
|
||||
- `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 `<include>com.cameleer:cameleer-core</include>` 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
|
||||
<dependency>
|
||||
<groupId>com.cameleer</groupId>
|
||||
<artifactId>cameleer-extension</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
```
|
||||
```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
|
||||
<modules>
|
||||
<module>cameleer-common</module>
|
||||
<module>cameleer-core</module> <!-- NEW -->
|
||||
<module>cameleer-agent</module>
|
||||
<module>cameleer-extension</module> <!-- NEW (parent POM for runtime + deployment) -->
|
||||
<module>cameleer-sample-app</module>
|
||||
<module>cameleer-backend-app</module>
|
||||
<module>cameleer-caller-app</module>
|
||||
<module>cameleer-quarkus-app</module>
|
||||
<module>cameleer-quarkus-native-app</module> <!-- NEW -->
|
||||
</modules>
|
||||
```
|
||||
|
||||
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`
|
||||
@@ -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<ILoggingEvent>`. 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<String, String>, 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<Consumer<Object[]>>` 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<LogEntry>` 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.
|
||||
@@ -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<Object[]>`, 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
|
||||
<dependency>
|
||||
<groupId>com.cameleer</groupId>
|
||||
<artifactId>cameleer-log-appender</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
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<Exporter>, 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
|
||||
@@ -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<MetricsSnapshot> 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
|
||||
<dependency>
|
||||
<groupId>org.apache.camel.springboot</groupId>
|
||||
<artifactId>camel-micrometer-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
`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
|
||||
<dependency>
|
||||
<groupId>org.apache.camel.quarkus</groupId>
|
||||
<artifactId>camel-quarkus-micrometer</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-micrometer-registry-prometheus</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
**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-<name>
|
||||
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
|
||||
@@ -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<String> 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<String> sensitiveHeaders;
|
||||
private Set<String> 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<String>` 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 |
|
||||
@@ -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
|
||||
@@ -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<HttpRequest>` 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.
|
||||
@@ -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<HttpRequest>` 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.
|
||||
Reference in New Issue
Block a user