Skip to content

Commit 5c46803

Browse files
davsclausclaude
andauthored
CAMEL-23865: Use EWMA smoothing for throughput and fix TUI sparkline scaling
Switch the throughput metric in LoadThroughput from raw instantaneous rate to EWMA smoothing with a 1-minute decay window. Also fixes TUI sparkline charts to properly display sub-1.0 msg/s rates by scaling throughput values by 100x. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d802332 commit 5c46803

6 files changed

Lines changed: 214 additions & 34 deletions

File tree

core/camel-management/src/main/java/org/apache/camel/management/mbean/LoadThroughput.java

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,25 @@
1919
import org.apache.camel.util.StopWatch;
2020

2121
/**
22-
* Holds the load throughput messages/second
22+
* Holds the throughput (messages/second) using EWMA (exponentially weighted moving average) smoothing, modeled after
23+
* Unix load averages (same approach as {@link LoadTriplet}).
24+
*
25+
* The instantaneous rate from each 1-second sampling interval is smoothed with a 1-minute decay window so that the
26+
* reported value converges to the true average rate instead of oscillating between 0 and spike values.
2327
*/
2428
public final class LoadThroughput {
2529

30+
// EWMA exponent for a 1-minute decay window, sampled every 1 second
31+
private static final double EXP_1 = Math.exp(-1.0 / 60.0);
32+
2633
private final StopWatch watch = new StopWatch(false);
2734
private long last;
2835
private double thp;
2936

3037
/**
31-
* Update the load statistics
38+
* Update the throughput statistics
3239
*
33-
* @param currentReading the current reading
40+
* @param currentReading the current cumulative exchange count
3441
*/
3542
public void update(long currentReading) {
3643
if (!watch.isStarted()) {
@@ -40,14 +47,10 @@ public void update(long currentReading) {
4047
long time = watch.takenAndRestart();
4148
if (time > 0) {
4249
long delta = currentReading - last;
43-
if (delta > 0) {
44-
// need to calculate with fractions
45-
thp = (1000d / time) * delta;
46-
} else {
47-
thp = 0;
48-
}
49-
} else {
50-
thp = 0;
50+
// instantaneous rate in exchanges/second for this interval
51+
double instantRate = Math.max(0, (1000d / time) * delta);
52+
// apply EWMA smoothing
53+
thp = instantRate + EXP_1 * (thp - instantRate);
5154
}
5255
}
5356
last = currentReading;
@@ -58,6 +61,7 @@ public double getThroughput() {
5861
}
5962

6063
public void reset() {
64+
watch.stop();
6165
last = 0;
6266
thp = 0;
6367
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.camel.management;
18+
19+
import java.util.concurrent.TimeUnit;
20+
import java.util.concurrent.atomic.AtomicInteger;
21+
import java.util.concurrent.atomic.AtomicLong;
22+
23+
import org.apache.camel.management.mbean.LoadThroughput;
24+
import org.junit.jupiter.api.Test;
25+
26+
import static org.awaitility.Awaitility.await;
27+
import static org.junit.jupiter.api.Assertions.assertEquals;
28+
import static org.junit.jupiter.api.Assertions.assertTrue;
29+
30+
public class LoadThroughputTest {
31+
32+
@Test
33+
public void testInitialValueIsZero() {
34+
LoadThroughput t = new LoadThroughput();
35+
assertEquals(0.0, t.getThroughput());
36+
}
37+
38+
@Test
39+
public void testConvergesToSteadyRate() {
40+
LoadThroughput t = new LoadThroughput();
41+
AtomicLong total = new AtomicLong(0);
42+
t.update(total.get());
43+
44+
await().pollInterval(10, TimeUnit.MILLISECONDS)
45+
.atMost(5, TimeUnit.SECONDS)
46+
.untilAsserted(() -> {
47+
total.incrementAndGet();
48+
t.update(total.get());
49+
assertTrue(t.getThroughput() > 5.0,
50+
"Throughput should converge toward steady rate: " + t.getThroughput());
51+
});
52+
}
53+
54+
@Test
55+
public void testSmoothing() {
56+
LoadThroughput t = new LoadThroughput();
57+
AtomicLong total = new AtomicLong(0);
58+
AtomicInteger count = new AtomicInteger(0);
59+
t.update(total.get());
60+
61+
await().pollInterval(10, TimeUnit.MILLISECONDS)
62+
.atMost(5, TimeUnit.SECONDS)
63+
.untilAsserted(() -> {
64+
int i = count.incrementAndGet();
65+
if (i % 5 == 0) {
66+
total.incrementAndGet();
67+
}
68+
t.update(total.get());
69+
double thp = t.getThroughput();
70+
assertTrue(thp > 1.0, "Smoothed throughput should be well above zero: " + thp);
71+
assertTrue(thp < 80.0, "Smoothed throughput should be below the instantaneous spike: " + thp);
72+
});
73+
}
74+
75+
@Test
76+
public void testReset() {
77+
LoadThroughput t = new LoadThroughput();
78+
AtomicLong total = new AtomicLong(0);
79+
t.update(total.get());
80+
81+
await().pollInterval(10, TimeUnit.MILLISECONDS)
82+
.atMost(5, TimeUnit.SECONDS)
83+
.untilAsserted(() -> {
84+
total.addAndGet(10);
85+
t.update(total.get());
86+
assertTrue(t.getThroughput() > 0);
87+
});
88+
89+
t.reset();
90+
assertEquals(0.0, t.getThroughput());
91+
}
92+
93+
}

docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ String chat(AiAgentBody<?> aiAgentBody, ToolProvider toolProvider);
3030
Result<String> chat(AiAgentBody<?> aiAgentBody, ToolProvider toolProvider);
3131
----
3232

33+
=== camel-management - Throughput MBean attribute uses EWMA smoothing
34+
35+
The `Throughput` attribute on the `ManagedPerformanceCounter` JMX MBean now reports an EWMA
36+
(exponentially weighted moving average) value with a 1-minute decay window, instead of the
37+
previous raw instantaneous rate. This produces a smoother, more stable reading that converges
38+
to the true average throughput rather than oscillating between zero and spike values.
39+
40+
If you have tooling that consumes the JMX throughput value and expects the old instantaneous
41+
behavior, be aware that the reported value will now ramp up and decay gradually.
42+
3343
=== camel-fory with JDK 25+ - Breaking change
3444

3545
Due to new requirements from Apache Fory, when using Apache Fory Dataformat, the JVM parameter `--add-opens java.base/java.lang.invoke=ALL-UNNAMED` must be provided.

dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointsTab.java

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -444,17 +444,19 @@ private void renderEndpointFlow(
444444

445445
Line chartTitle = Line.from(
446446
Span.styled("▬", Style.EMPTY.fg(Color.ansi(AnsiColor.BRIGHT_GREEN))),
447-
Span.raw(String.format(" in:%-4d ", curIn)),
447+
Span.raw(String.format(" in:%-4s ", MetricsCollector.formatThroughput(curIn))),
448448
Span.styled("▬", Style.EMPTY.fg(Color.CYAN)),
449-
Span.raw(String.format(" out:%-4d msg/s", curOut)));
449+
Span.raw(String.format(" out:%-4s msg/s", MetricsCollector.formatThroughput(curOut))));
450450

451451
Rect rightArea = hSplit.get(1);
452+
long maxEp = MetricsCollector.niceMax(Math.max(maxOf(inArr), maxOf(outArr)));
452453
frame.renderWidget(DualSparkline.builder()
453454
.topData(inArr)
454455
.bottomData(outArr)
456+
.max(maxEp)
455457
.topStyle(Style.EMPTY.fg(Color.ansi(AnsiColor.BRIGHT_GREEN)))
456458
.bottomStyle(Style.EMPTY.fg(Color.CYAN))
457-
.showYAxis(true)
459+
.showYAxis(false)
458460
.xLabels("-" + renderPoints + "s", "-" + (renderPoints * 3 / 4) + "s",
459461
"-" + (renderPoints / 2) + "s", "-" + (renderPoints / 4) + "s", "now")
460462
.block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL)
@@ -555,16 +557,20 @@ private void renderSingleEndpointChart(Frame frame, Rect area, String selectedUr
555557
Span.styled(uriLabel, Style.EMPTY.fg(Color.YELLOW)),
556558
Span.raw("] "),
557559
Span.styled("▬", Style.EMPTY.fg(Color.ansi(AnsiColor.BRIGHT_GREEN))),
558-
Span.raw(String.format(" in:%-4d ", curIn)),
560+
Span.raw(String.format(" in:%-4s ", MetricsCollector.formatThroughput(curIn))),
559561
Span.styled("▬", Style.EMPTY.fg(Color.CYAN)),
560-
Span.raw(String.format(" out:%-4d msg/s", curOut)));
562+
Span.raw(String.format(" out:%-4s msg/s", MetricsCollector.formatThroughput(curOut))));
561563

564+
// TODO: use .showYAxis(true).yAxisFormatter(MetricsCollector::formatThroughput) when tamboui 0.5.0 is released
565+
// see https://github.com/tamboui/tamboui/pull/396
566+
long maxEpSingle = MetricsCollector.niceMax(Math.max(maxOf(inArr), maxOf(outArr)));
562567
frame.renderWidget(DualSparkline.builder()
563568
.topData(inArr)
564569
.bottomData(outArr)
570+
.max(maxEpSingle)
565571
.topStyle(Style.EMPTY.fg(Color.ansi(AnsiColor.BRIGHT_GREEN)))
566572
.bottomStyle(Style.EMPTY.fg(Color.CYAN))
567-
.showYAxis(true)
573+
.showYAxis(false)
568574
.xLabels("-" + renderPoints + "s", "-" + (renderPoints * 3 / 4) + "s",
569575
"-" + (renderPoints / 2) + "s", "-" + (renderPoints / 4) + "s", "now")
570576
.block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL)
@@ -770,4 +776,15 @@ public JsonObject getTableDataAsJson() {
770776
result.put("selectedIndex", sel != null ? sel : -1);
771777
return result;
772778
}
779+
780+
private static long maxOf(long[] arr) {
781+
long max = 0;
782+
for (long v : arr) {
783+
if (v > max) {
784+
max = v;
785+
}
786+
}
787+
return max;
788+
}
789+
773790
}

dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsCollector.java

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.time.Duration;
2020
import java.util.LinkedHashMap;
2121
import java.util.LinkedList;
22+
import java.util.Locale;
2223
import java.util.Map;
2324
import java.util.Optional;
2425
import java.util.concurrent.ConcurrentHashMap;
@@ -33,6 +34,8 @@ class MetricsCollector {
3334
static final int MAX_ENDPOINT_CHART_POINTS = 300;
3435
static final int MAX_HEAP_HISTORY_POINTS = 120;
3536
static final long HEAP_SAMPLE_INTERVAL_MS = 5000;
37+
// Throughput values are stored scaled by this factor so sub-1.0 msg/s rates are preserved as longs
38+
static final long THROUGHPUT_SCALE = 100;
3639

3740
// Throughput history per PID (one point per second)
3841
private final Map<String, LinkedList<Long>> throughputHistory = new ConcurrentHashMap<>();
@@ -164,15 +167,28 @@ void updateThroughputHistory(IntegrationInfo info) {
164167
samples.remove(0);
165168
}
166169

170+
// Use the EWMA throughput from the status JSON (already smoothed in camel-core)
171+
// and store scaled by 100 so sub-1.0 rates (e.g. 0.20 msg/s) are preserved as integers
172+
long tp = 0;
173+
if (info.throughput != null) {
174+
try {
175+
tp = Math.round(Double.parseDouble(info.throughput) * THROUGHPUT_SCALE);
176+
} catch (NumberFormatException e) {
177+
// ignore
178+
}
179+
}
180+
181+
// Failed throughput still computed from delta (no EWMA source for failed-only)
182+
long fp = 0;
167183
if (samples.size() >= 2) {
168184
long[] oldest = samples.get(0);
169185
long[] newest = samples.get(samples.size() - 1);
170-
long deltaTotal = newest[1] - oldest[1];
171186
long deltaFailed = newest[2] - oldest[2];
172187
long deltaTimeMs = newest[0] - oldest[0];
173-
long tp = deltaTimeMs > 0 ? (deltaTotal * 1000) / deltaTimeMs : 0;
174-
long fp = deltaTimeMs > 0 ? (deltaFailed * 1000) / deltaTimeMs : 0;
188+
fp = deltaTimeMs > 0 ? (deltaFailed * 1000 * THROUGHPUT_SCALE) / deltaTimeMs : 0;
189+
}
175190

191+
{
176192
Long lastTime = previousExchangesTime.get(pid);
177193
if (lastTime == null || now - lastTime >= 1000) {
178194
previousExchangesTime.put(pid, now);
@@ -262,8 +278,8 @@ private void recordEndpointSample(
262278
long[] oldest = samples.get(0);
263279
long[] newest = samples.get(samples.size() - 1);
264280
long deltaMs = newest[0] - oldest[0];
265-
long inRate = deltaMs > 0 ? (newest[1] - oldest[1]) * 1000 / deltaMs : 0;
266-
long outRate = deltaMs > 0 ? (newest[2] - oldest[2]) * 1000 / deltaMs : 0;
281+
long inRate = deltaMs > 0 ? (newest[1] - oldest[1]) * 1000 * THROUGHPUT_SCALE / deltaMs : 0;
282+
long outRate = deltaMs > 0 ? (newest[2] - oldest[2]) * 1000 * THROUGHPUT_SCALE / deltaMs : 0;
267283
Long lastTime = prevTimeMap.get(pid);
268284
if (lastTime == null || now - lastTime >= 1000) {
269285
prevTimeMap.put(pid, now);
@@ -403,4 +419,36 @@ private void removeByPrefix(String prefix, Map<String, ?>... maps) {
403419
map.keySet().removeIf(k -> k.startsWith(prefix));
404420
}
405421
}
422+
423+
// --- Shared throughput formatting utilities ---
424+
425+
static long niceMax(long rawMax) {
426+
if (rawMax <= 0) {
427+
return THROUGHPUT_SCALE;
428+
}
429+
int[] steps = { 1, 2, 5 };
430+
long multiplier = THROUGHPUT_SCALE;
431+
while (true) {
432+
for (int s : steps) {
433+
long candidate = s * multiplier;
434+
if (candidate >= rawMax) {
435+
return candidate;
436+
}
437+
}
438+
multiplier *= 10;
439+
}
440+
}
441+
442+
static String formatThroughput(long scaledValue) {
443+
double v = scaledValue / (double) THROUGHPUT_SCALE;
444+
if (v >= 10) {
445+
return String.valueOf(Math.round(v));
446+
} else if (v >= 1) {
447+
return String.format(Locale.US, "%.1f", v);
448+
} else if (scaledValue > 0) {
449+
return String.format(Locale.US, "%.2f", v);
450+
} else {
451+
return "0";
452+
}
453+
}
406454
}

0 commit comments

Comments
 (0)