Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion doc/src/asciidoc/module_saf.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,43 @@ The module integrates with the jPOS `Logger` and `LogEvent` utilities to provide

* Logs transmission attempts and responses.
* Reports expired or ignored messages.
* Provides status information using the `getStatus()` method.
* Provides status information using the `getStatus()` method.

==== Metrics

When Q2 has a Micrometer `MeterRegistry` configured, the SAF module registers
the following meters tagged with `saf=<service-name>`:

* `jpos.saf.queue.size`
+
Current queue depth gauge. This is registered when SAF uses a `JDBMSpace`
backing store.

* `jpos.saf.send.duration`
+
Timer for SAF send attempts, with percentile histogram publishing enabled and
percentiles `0.5`, `0.95`, and `0.99`.

* `jpos.saf.send.success`
+
Counter incremented on valid responses, tagged with `mti` and `rc`.

* `jpos.saf.send.retried`
+
Counter incremented when the response code is in `retry-response-codes`,
tagged with `mti` and `rc`.

* `jpos.saf.send.expired`
+
Counter incremented when a queued message is discarded because it expired or
reached the maximum retransmission count, tagged with `mti` and `reason`.
When both conditions apply to the same entry, both reason-tagged counters are
incremented.

* `jpos.saf.send.discarded`
+
Counter incremented when a response code is outside `valid-response-codes`,
tagged with `mti` and `reason`.

If no Micrometer registry is configured, SAF metrics remain disabled and SAF
continues operating without meter registration.
4 changes: 3 additions & 1 deletion modules/saf/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@ description = 'jPOS-EE :: Store and Forward (SAF) Module'

dependencies {
api project(':modules:core')
}

testImplementation testlibs.bundles.junit
testRuntimeOnly testlibs.bundles.junit.platform
}
1 change: 1 addition & 0 deletions modules/saf/src/main/java/module-info.java
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
module org.jpos.saf {
requires micrometer.core;
requires org.jpos.jpos;

exports org.jpos.saf;
Expand Down
30 changes: 29 additions & 1 deletion modules/saf/src/main/java/org/jpos/saf/SAF.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

package org.jpos.saf;

import io.micrometer.core.instrument.MeterRegistry;
import java.io.PrintStream;
import java.util.Date;
import java.io.Serializable;
Expand Down Expand Up @@ -53,11 +54,16 @@ public class SAF extends QBeanSupport implements Runnable, Loggeable {
String queue;
String head;
String delayQueue;
SAFMetrics metrics;

public void initService() {
queue = getName();
head = getName() + ".head";
delayQueue = getName() + ".delayed";
MeterRegistry registry = getServer() != null ? getServer().getMeterRegistry() : null;
metrics = new SAFMetrics(registry, getName());
if (psp instanceof JDBMSpace)
metrics.registerQueueDepthGauge(() -> ((JDBMSpace) psp).size(queue));
NameRegistrar.register(getName(), this);
}

Expand Down Expand Up @@ -189,6 +195,10 @@ public String getStatus() {
return sb.toString();
}

SAFMetrics getSAFMetrics() {
return metrics;
}

private boolean latchMsg() {
Entry entry = (Entry) psp.rdp(head);
if (entry == null) {
Expand All @@ -215,7 +225,12 @@ private void autoCommitOff() {
}

private Entry send(Entry entry) {
String mti = getMTI(entry);
if (shouldIgnore(entry)) {
if (isMaxRetransmission(entry))
metrics.sendExpired(mti, "max-retransmissions");
if (isExpired(entry))
metrics.sendExpired(mti, "expired");
LogEvent evt = getLog().createLogEvent("saf-warning");
if (isMaxRetransmission(entry))
evt.addMessage("max retransmission count (" + maxRetransmissions + ") has been reached.");
Expand All @@ -227,6 +242,7 @@ private Entry send(Entry entry) {
Logger.log(evt);
return null;
}
long start = System.nanoTime();
try {
ISOMsg resp = mux.request(entry.msg, waitForResponse);
if (resp == null) {
Expand All @@ -239,6 +255,7 @@ private Entry send(Entry entry) {
if (retryResponseCodes != null && retryResponseCodes.indexOf(rc) >= 0) {
// this result code requires retransmission, so we don't increase
// the retransmission counter. The request may expire though
metrics.sendRetried(mti, rc);
LogEvent evt = createLogEvent("info", entry, resp);
evt.addMessage("response code '"
+ resp.getString(39)
Expand All @@ -255,6 +272,7 @@ private Entry send(Entry entry) {
+ "' is in valid-response-codes list ("
+ validResponseCodes + ")");
Logger.log(evt);
metrics.sendSucceeded(mti, rc);
// GOOD - Message was sent
if (entry.responseKey != null) {
if (entry.wipePreviousResponse)
Expand All @@ -272,6 +290,7 @@ private Entry send(Entry entry) {
+ "' not in valid-response-codes list ("
+ validResponseCodes + ")");
Logger.log(evt);
metrics.sendDiscarded(mti, "not-in-valid-codes");
}
}
if (entry.count == 1 && flagRetransmissions.indexOf(entry.msg.getMTI()) >= 0)
Expand All @@ -282,10 +301,20 @@ private Entry send(Entry entry) {
evt.addMessage("--- stack trace ---");
evt.addMessage(e);
Logger.log(evt);
} finally {
metrics.sendCompleted(System.nanoTime() - start);
}
return entry;
}

private String getMTI(Entry entry) {
try {
return entry != null && entry.msg != null ? entry.msg.getMTI() : "unknown";
} catch (ISOException e) {
return "unknown";
}
}

private LogEvent createLogEvent(String type, Entry entry, ISOMsg resp) {
LogEvent evt = getLog().createLogEvent(type);
evt.addMessage(" Message timestamp: " + new Date(entry.time));
Expand Down Expand Up @@ -344,4 +373,3 @@ public Entry(ISOMsg msg, String responseKey, long responseTimeout, boolean wipeP
}
}
}

103 changes: 103 additions & 0 deletions modules/saf/src/main/java/org/jpos/saf/SAFMetrics.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* jPOS Project [http://jpos.org]
* Copyright (C) 2000-2026 jPOS Software SRL
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package org.jpos.saf;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tags;
import io.micrometer.core.instrument.Timer;

import java.util.function.Supplier;

final class SAFMetrics {
private static final String QUEUE_SIZE = "jpos.saf.queue.size";
private static final String SEND_DURATION = "jpos.saf.send.duration";
private static final String SEND_SUCCESS = "jpos.saf.send.success";
private static final String SEND_RETRIED = "jpos.saf.send.retried";
private static final String SEND_EXPIRED = "jpos.saf.send.expired";
private static final String SEND_DISCARDED = "jpos.saf.send.discarded";

private final MeterRegistry registry;
private final String name;

SAFMetrics(MeterRegistry registry, String name) {
this.registry = registry;
this.name = name;
}

boolean isEnabled() {
return registry != null;
}

void registerQueueDepthGauge(Supplier<Number> supplier) {
if (registry == null)
return;
Gauge.builder(QUEUE_SIZE, supplier)
.tags(tags())
.description("Current SAF queue depth")
.register(registry);
}

void sendCompleted(long elapsedNanos) {
if (registry == null)
return;
Timer.builder(SEND_DURATION)
.tags(tags())
.description("Time spent waiting for SAF send responses")
.publishPercentileHistogram()
.publishPercentiles(0.5, 0.95, 0.99)
.register(registry)
.record(Math.max(0L, elapsedNanos), java.util.concurrent.TimeUnit.NANOSECONDS);
}

void sendSucceeded(String mti, String rc) {
if (registry == null)
return;
counter(SEND_SUCCESS, Tags.of("mti", mti, "rc", rc)).increment();
}

void sendRetried(String mti, String rc) {
if (registry == null)
return;
counter(SEND_RETRIED, Tags.of("mti", mti, "rc", rc)).increment();
}

void sendExpired(String mti, String reason) {
if (registry == null)
return;
counter(SEND_EXPIRED, Tags.of("mti", mti, "reason", reason)).increment();
}

void sendDiscarded(String mti, String reason) {
if (registry == null)
return;
counter(SEND_DISCARDED, Tags.of("mti", mti, "reason", reason)).increment();
}

private Counter counter(String name, Tags tags) {
return Counter.builder(name)
.tags(tags().and(tags))
.register(registry);
}

private Tags tags() {
return Tags.of("saf", name);
}
}
105 changes: 105 additions & 0 deletions modules/saf/src/test/java/org/jpos/saf/SAFMetricsTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* jPOS Project [http://jpos.org]
* Copyright (C) 2000-2026 jPOS Software SRL
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package org.jpos.saf;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;

import java.util.concurrent.TimeUnit;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

class SAFMetricsTest {
@Test
void noopWhenRegistryIsNull() {
SAFMetrics metrics = new SAFMetrics(null, "saf");
assertFalse(metrics.isEnabled());

metrics.registerQueueDepthGauge(() -> 3);
metrics.sendCompleted(1_000L);
metrics.sendSucceeded("0200", "00");
metrics.sendRetried("0200", "91");
metrics.sendExpired("0200", "expired");
metrics.sendDiscarded("0200", "not-in-valid-codes");
}

@Test
void registersQueueDepthGauge() {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
SAFMetrics metrics = new SAFMetrics(registry, "saf");

metrics.registerQueueDepthGauge(() -> 7);

Gauge gauge = registry.find("jpos.saf.queue.size").tag("saf", "saf").gauge();
assertNotNull(gauge);
assertEquals(7.0, gauge.value(), 0.0001);
}

@Test
void recordsSendDuration() {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
SAFMetrics metrics = new SAFMetrics(registry, "saf");

metrics.sendCompleted(TimeUnit.MILLISECONDS.toNanos(25));

Timer timer = registry.find("jpos.saf.send.duration").tag("saf", "saf").timer();
assertNotNull(timer);
assertEquals(1L, timer.count());
assertTrue(timer.totalTime(TimeUnit.MILLISECONDS) >= 25.0);
}

@Test
void recordsOutcomeCountersWithTags() {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
SAFMetrics metrics = new SAFMetrics(registry, "saf");

metrics.sendSucceeded("0200", "00");
metrics.sendRetried("0200", "91");
metrics.sendExpired("0200", "expired");
metrics.sendDiscarded("0200", "not-in-valid-codes");

Counter success = registry.find("jpos.saf.send.success")
.tags("saf", "saf", "mti", "0200", "rc", "00")
.counter();
Counter retried = registry.find("jpos.saf.send.retried")
.tags("saf", "saf", "mti", "0200", "rc", "91")
.counter();
Counter expired = registry.find("jpos.saf.send.expired")
.tags("saf", "saf", "mti", "0200", "reason", "expired")
.counter();
Counter discarded = registry.find("jpos.saf.send.discarded")
.tags("saf", "saf", "mti", "0200", "reason", "not-in-valid-codes")
.counter();

assertNotNull(success);
assertNotNull(retried);
assertNotNull(expired);
assertNotNull(discarded);
assertEquals(1.0, success.count(), 0.0001);
assertEquals(1.0, retried.count(), 0.0001);
assertEquals(1.0, expired.count(), 0.0001);
assertEquals(1.0, discarded.count(), 0.0001);
}
}
Loading
Loading