Recording an Audit-Log
When something goes wrong between your application and a PLC, the interesting question is almost never "what did my code do?" - it is "what actually went over the wire, and what came back?".
The audit-log answers that. Switch it on and PLC4X writes a complete, ordered trace of one connection to a file: the API calls you made, the protocol messages the driver built from them, the raw bytes it put on the wire, the bytes that came back, and every state change in between.
You do not change a line of code to get it. It is a connection-string parameter.
| The audit-log is part of PLC4J (Java). PLC4Go, PLC4Py and PLC4C have no equivalent. |
Switching it on
Add log.audit-log-file to your connection string, pointing at the file you want written:
modbus-tcp://192.168.1.100?log.audit-log-file=/tmp/modbus-debug.log
That is the whole configuration. The parameter works on every driver and every transport, and it combines with all the other connection-string options as usual:
s7://10.0.0.1?remote-rack=0&remote-slot=1&log.audit-log-file=/var/log/plc4x/s7-line-3.log
Parent directories are created for you if they do not exist yet.
If the parameter is missing or empty, audit logging is off and costs nothing at all - the driver holds a no-op implementation that does not even format the messages it is not writing.
What you need on the classpath
The audit-log is split into an API and an implementation, so that ordinary deployments never carry the file-writing machinery. Drivers compile against the API; the implementation is picked up reflectively at runtime if it is there.
To actually get a file, add the implementation at runtime scope:
<dependency>
<groupId>org.apache.plc4x</groupId>
<artifactId>plc4j-utils-audit-log-impl</artifactId>
<version>1.0.0</version>
<scope>runtime</scope>
</dependency>
The implementation writes through Logback, which PLC4X declares as provided - so
logback-classic has to be on your runtime classpath too. If your application already uses
Logback as its SLF4J binding (most do), you have it. If you use a different binding, add
ch.qos.logback:logback-classic alongside it for as long as you need the audit-log.
This is the one thing that fails quietly. Without the implementation module, PLC4X logs
Audit log implementation not found on classpath. Audit logging will be disabled. at INFO level
through your normal logger and carries on with no audit file. If you configured a path and got no
file, check that message first.
|
When it has found everything, you will see this instead:
INFO org.apache.plc4x.java.utils.auditlog.api.AuditLog -- Audit log implementation found on classpath. Audit logging enabled.
INFO org.apache.plc4x.java.utils.auditlog.api.AuditLog -- Audit log initialized for file: /tmp/modbus-debug.log
What the log looks like
Every line has the same shape:
[timestamp] [eventType] [source] message
source is the protocol code of the driver that produced the entry - modbus-tcp, s7, ads.
It is what lets you tell two connections apart if you ever point them at the same file.
Here is a complete trace of one connection that opens, reads a single holding register and closes again:
[2026-09-05 18:33:37.883] [SYSTEM] [modbus-tcp] Creating Transport with config: {"connectTimeout":5000,"readTimeout":0,"writeTimeout":0,"tcpNoDelay":true,"keepAlive":false,"sendBufferSize":81920,"receiveBufferSize":81920,"localAddress":null,"localPort":0,"defaultPort":502}
[2026-09-05 18:33:37.895] [CONNECT] [modbus-tcp] Connected to: localhost:61188 with local address: localhost:61189
[2026-09-05 18:33:37.900] [CONFIG] [modbus-tcp] Starting connection using configuration: ModbusTcpConfiguration{requestTimeout=5000, unitIdentifier=1, pingAddress=4x00001:BOOL, defaultPayloadByteOrder=BIG_ENDIAN, maxCoilsPerRequest=2000, maxRegistersPerRequest=125}
[2026-09-05 18:33:37.907] [SYSTEM] [modbus-tcp] Started async event-driven receive mode
[2026-09-05 18:33:37.907] [CONNECT] [modbus-tcp] Modbus TCP connection established
[2026-09-05 18:33:37.907] [SYSTEM] [modbus-tcp] Connection state changed: CONNECTED
[2026-09-05 18:33:37.912] [API_REQUEST] [modbus-tcp] Read request: [temperature]
[2026-09-05 18:33:37.921] [OUTGOING_MESSAGE] [modbus-tcp] Sending Modbus TCP request, txId=1
[2026-09-05 18:33:37.932] [OUTGOING_BYTES] [modbus-tcp] Write: 000100000006010300000001
[2026-09-05 18:33:37.932] [INCOMING_BYTES] [modbus-tcp] 00010000000701030412345678
[2026-09-05 18:33:37.943] [INCOMING_MESSAGE] [modbus-tcp] Received Modbus TCP response, txId=1
[2026-09-05 18:33:37.945] [API_RESPONSE] [modbus-tcp] Block read response: 4 bytes for 1 tags
[2026-09-05 18:33:37.954] [CLOSE] [modbus-tcp] Connection closed
[2026-09-05 18:33:37.954] [SYSTEM] [modbus-tcp] Connection state changed: DISCONNECTED
Read top to bottom, that is the entire life of the request: your read() call
(API_REQUEST), the Modbus PDU the driver built (OUTGOING_MESSAGE), the twelve bytes it sent
(OUTGOING_BYTES), the thirteen that came back (INCOMING_BYTES), the parsed response
(INCOMING_MESSAGE) and finally the values handed back to you (API_RESPONSE).
The OUTGOING_BYTES and INCOMING_BYTES lines are plain hex. Paste one into Wireshark’s
"Import from Hex Dump" and you get the same dissection you would have got from a capture - without
having to be on the machine when it went wrong.
|
Event types
| Event type | What produced it |
|---|---|
|
The configuration the connection was started with. |
|
Transport setup, receive-mode selection, connection state changes. |
|
Transport-level and protocol-level connection establishment. |
|
A call you made: read, write, subscribe, browse, ping. |
|
The result handed back to your code. |
|
A parsed protocol message on its way out. |
|
The raw bytes written to the wire, as hex. |
|
The raw bytes read from the wire, as hex. |
|
A parsed protocol message that came in. |
|
Asynchronous events such as subscription notifications. |
|
Connection teardown. |
|
Anything that failed - unexpected disconnects, protocol errors, timeouts. |
How much detail you get per event type depends on the driver. The framework contributes the
API_*, CONFIG, CLOSE and connection-state entries for every driver alike; the byte-level and
message-level entries come from the transport and the driver, and the more mature drivers say more.
Rolling and file size
A byte-level trace of a busy connection grows fast, so the file rolls itself:
-
a new file is started when the current one reaches 10 MB, and once per day
-
archives are named
{filename}.{date}.{index}.gz- e.g.modbus-debug.log.2026-09-05.0.gz -
30 days of history are kept, capped at 1 GB total
-
archives are GZIP-compressed
None of this is configurable. The audit-log is a debugging instrument you switch on for a while, not a permanent logging pipeline - if you want to keep traces indefinitely, copy them somewhere else.
Reading and writing the log from code
Every connection that PLC4X builds implements AuditLogProvider, so if you need it, the same log
object is reachable from your own code:
import org.apache.plc4x.java.utils.auditlog.api.AuditLog;
import org.apache.plc4x.java.utils.auditlog.api.AuditLogEventType;
import org.apache.plc4x.java.utils.auditlog.api.AuditLogProvider;
try (PlcConnection connection = PlcDriverManager.getDefault().getConnectionFactory()
.getConnection("modbus-tcp://192.168.1.100?log.audit-log-file=/tmp/modbus-debug.log")) {
if (connection instanceof AuditLogProvider auditLogProvider) {
AuditLog auditLog = auditLogProvider.getAuditLog();
if (auditLog.isEnabled()) { (1)
auditLog.write(AuditLogEventType.SYSTEM, "Starting batch 47"); (2)
auditLog.write(AuditLogEventType.SYSTEM, "Recipe", recipe); (3)
}
}
// ... your reads and writes, which land in the same file, in order ...
}
| 1 | Cheap, and worth checking before you build a message you may not need. |
| 2 | Your entry appears in the trace with the same [timestamp] [type] [source] prefix. |
| 3 | The three-argument form serialises the object to JSON with Jackson, falling back to
toString() if it cannot. |
This is the useful trick for correlating a trace with your own application: mark the beginning of a batch, a recipe change, a shift, and you can find that point again in a 10 MB trace of hex.
If you want an audit-log that is not tied to a connection - in a tool of your own, say - build one directly:
AuditLog auditLog = AuditLog.builder()
.withSource("line-3-supervisor")
.withAuditLogFile("/var/log/plc4x/supervisor.log")
.build();
auditLog.write(AuditLogEventType.SYSTEM, "Supervisor started");
// ...
auditLog.close();
The builder applies the same rules as the connection string: no file path, or no implementation on the classpath, and you get the no-op instance back rather than an exception.
Turning a trace into a test
The audit-log was built with a second purpose in mind. A trace that contains both the raw bytes and the parsed messages of a real conversation is most of what is needed to reproduce that conversation as a driver test - which is how a bug you saw once on a machine you cannot reach becomes a regression test that runs on every build.
If you hit a driver bug, an audit-log attached to the issue is by far the most useful thing you can send. See Preparing Issues & Bug Reports for what else helps.
Before you share the file
An audit-log is a recording of everything your application said to the PLC and everything the PLC said back. Treat it accordingly:
-
Payloads are in there in full. Every value you read and, more to the point, every value you wrote, as hex and usually as a parsed message too.
-
The
CONFIGline contains the connection configuration. Drivers mask the parameters they declare as secret - an OPC UA password shows up as- but the file was never designed as a redaction boundary. Read the first few lines before you attach it to a public issue. -
TLS session keys, if you asked for them. The
tls-psk.log-session-keystransport option writes session keys into the audit-log inSSLKEYLOGFILEformat, so Wireshark can decrypt a capture of the connection. That is exactly as sensitive as it sounds; use it on a test system.