Skip to content
Start free evaluation

Diagnose failures and report them

When communication fails, find the layer that failed first, reduce the reproduction, then collect evidence under the same conditions. In this tutorial you build a TCP peer that never answers, reproduce a timeout and confirm its cause from the diagnostic log.

  • Narrow a failure down to one layer: SDK loading, configuration, connection, transaction or device meaning.
  • Turn on the JSON Lines diagnostic log with PDCOMM_LOG and read the record of a timeout.
  • Turn logging on and off at run time through the API and commands.
  • Collect what a support request needs.
  • The SDK, examples and C++ console from the quickstart
  • Python 3 and jq
  • Two terminals

Check the layers in this order; once one fails, the later ones don’t matter yet.

Layer Check Failures at this layer
SDK loading Does {"action":"capabilities"} answer? DllNotFoundException, library not found
Configuration Is the configure result ok? Invalid endpoint configuration, missing field `port`
Connection Does connect report connected as true? Io(ConnectionRefused, ...), Io(AddrInUse, ...)
Transaction Does request return a result? Timeout, Closed, Protocol(...)
Device meaning Is status success, and does the value match the manual? Exceptions 01, 02, 03; a value from the neighboring address
  • ok set to true can still carry a Modbus exception; check status too.
  • A result with source local isn’t communication. Judge communication with a device by wire results.
  • no_response for broadcasts and in listen-only mode is intentional.
  1. In a new process, record capabilities and state.
  2. If you can, check functions 06 and 03 first on 127.0.0.1 on the same computer, without sharing ports with other equipment.
  3. Keep only the one failing request, and record its exact PDU, unit, response, status and source.
  4. Read the client’s and the server’s trace and line them up.
  5. Record the processes’ exit codes.

If a write timed out, the peer may already have applied it. Read the value before writing again: PDComm never resends writes automatically.

In terminal A, run a TCP peer that accepts a connection but never sends anything. It ends by itself after 30 seconds.

터미널 창
python3 -c "import socket,time; s=socket.create_server(('127.0.0.1',1503)); c,_=s.accept(); time.sleep(30)"

In terminal B, enable the log with environment variables and start the C++ console:

터미널 창
mkdir -p logs
PDCOMM_LOG=debug PDCOMM_LOG_FILE=logs/client.jsonl ./build/modbus-tutorial/pdcomm-modbus-tutorial
{"action":"configure","config":{"transport":"tcp","role":"client","address":"127.0.0.1:1503","port":"","unit":1,"baud":19200,"parity":"even","stop_bits":1,"timeout_ms":1000}}
{"action":"connect"}
{"action":"request","pdu":"0300000001"}
{"action":"logging_flush"}
{"action":"logging_status"}

Finally, enter quit to end the console. The request fails after one second with Command error: {"error":"Timeout","ok":false}. In logging_status, written is the number of records written; dropped, write_errors and configuration_errors at 0 mean no records were lost.

Variable Meaning Default
PDCOMM_LOG error, warn, info, debug, trace or off Off
PDCOMM_LOG_FILE Log file path; without it, records go to standard error. Standard error
PDCOMM_LOG_MAX_BYTES Rotation size (8192 bytes to 1 GiB) 10 MiB
PDCOMM_LOG_FILES Files kept, including the current one (1–16) 3
PDCOMM_LOG_FORENSICS Adds periodic process samples; debug or trace only Off
  • The library reads these variables once, when it first uses its logger. Restart the program after changing them. Start the GUI from such an environment too.
  • The headless simulator and the MCP server never mix log records into standard output; without a file they log to standard error.
  • When you run two processes, such as a client and a server, give each its own file.
  • The log never contains PDU contents, keys or credentials.

Show only the error-level records:

터미널 창
jq -c 'select(.level=="error") | {component, event, fields}' logs/client.jsonl
{"component":"tcp_client","event":"failed","fields":{"connection_id":3,"error_kind":null,"error_type":"Timeout","max_poll_gap_us":2692,"pending":[{"deadline_exceeded":true,"elapsed_us":1000930,"function":3,"max_poll_gap_us":2692,"request_id":4,"transaction_id":0,"unit":1}],"protocol_error":null,"queued":0,"rx_bytes":0,"rx_partial_bytes":0,"timeout":true,"tx_bytes":12,"tx_offset":0}}
{"component":"service","event":"action_completed","fields":{"action":"request","elapsed_us":1001041,"error_code":"Timeout","success":false,"transport":"tcp"}}
{"component":"ffi","event":"call_completed","fields":{"elapsed_us":1001107,"endpoint_id":1,"success":false}}

The failed record explains the cause:

  • tx_bytes 12: the whole request ADU (7-byte MBAP header plus 5-byte PDU) was sent.
  • rx_bytes 0 and rx_partial_bytes 0: not a single response byte arrived. The peer didn’t process the request or didn’t answer.
  • elapsed_us of about 1000000 and deadline_exceeded true: the client waited the full timeout_ms of 1000.
  • A small max_poll_gap_us: the timeout wasn’t caused by the SDK processing late.

If part of a response had arrived, rx_partial_bytes would be above 0, meaning the peer’s response was cut off; look at the peer device or the network.

Collect every record of one request by its request_id. The number differs between runs.

터미널 창
jq -c 'select(.request_id==4) | [.component, .event]' logs/client.jsonl
["ffi","call_entered"]
["ffi","lock_acquired"]
["service","action_started"]
["tcp_client","submitted"]
["tcp_client","socket_write"]
["tcp_client","failed"]
["service","action_completed"]
["ffi","call_completed"]

A successful request continues with socket_read and completed after socket_write. transaction_id starts again at 0 on every connection, so match records by connection_id as well.

You can also turn logging on and off from inside a program. The configuration is JSON with the same items as the variables (enabled, level, file, max_bytes, files, forensics).

Use DiagnosticLogging.

examples/diagnostics/modbus/logging.cpp
#include <pdcomm/modbus/pdcomm_modbus.hpp>
#include <pdcomm/modbus/pdcomm_modbus_logging.hpp>
#include <iostream>
int main(int argc, char **argv) {
if (argc != 2) return 2;
try {
pdcomm::modbus::DiagnosticLogging::configure(argv[1]);
pdcomm::modbus::Endpoint endpoint;
std::cout << endpoint.call(R"({"action":"execute","pdu":[6,0,0,0,42]})") << '\n';
pdcomm::modbus::DiagnosticLogging::flush();
std::cout << pdcomm::modbus::DiagnosticLogging::status() << '\n';
pdcomm::modbus::DiagnosticLogging::configure(R"({"enabled":false})");
std::cout << pdcomm::modbus::DiagnosticLogging::status() << '\n';
} catch (const std::exception &error) {
std::cerr << error.what() << '\n';
return 1;
}
return 0;
}
터미널 창
c++ -std=c++17 -I "$MODBUS_SDK/include" "$MODBUS_EXAMPLES/diagnostics/modbus/logging.cpp" -L "$MODBUS_SDK/lib" -lpdcomm_modbus_ffi -Wl,-rpath,"$MODBUS_SDK/lib" -o logging-example
./logging-example '{"level":"debug","file":"logs/logging-cpp.jsonl"}'

Both examples print three lines: the command result, the status with logging on ("enabled":true, written increased) and the status with logging off ("enabled":false).

The headless simulator and the MCP server offer the same control as commands (logging_configure, logging_status, logging_flush; MCP adds the modbus_ prefix):

{"action":"logging_configure","config":{"enabled":true,"level":"debug","file":"logs/headless.jsonl"}}
{"action":"logging_configure","config":{"enabled":false}}

An invalid configuration such as "level":"verbose" fails with Invalid logging level, keeps the previous configuration and increments configuration_errors. In C, use pdcomm_modbus_logging_configure. Configuring and flushing can wait up to two seconds, so call them outside your communication loop.

To ship the log with a collector, make it read the rotated files (*.jsonl, .1, .2) as well. Here’s a Fluent Bit example; adjust the paths and version for your environment:

examples/diagnostics/collector/fluent-bit.conf
[SERVICE]
Flush 1
Parsers_File parsers.conf
[INPUT]
Name tail
Path /var/log/pdcomm/*.jsonl*
Tag pdcomm
Parser pdcomm_json
DB /var/lib/fluent-bit/pdcomm-offsets.db
DB.Sync Full
DB.Compare_Filename Off
Refresh_Interval 1
Rotate_Wait 30
Mem_Buf_Limit 10MB
Buffer_Chunk_Size 16k
Buffer_Max_Size 16k
Skip_Long_Lines Off
Read_From_Head On
[OUTPUT]
Name stdout
Match pdcomm
Format json_lines
Json_date_key ingested_at
Json_date_format double

In the same session, save the results of:

{"action":"state"}
{"action":"diagnostics"}
{"action":"trace"}
{"action":"project_export"}
  • trace keeps only the last 1000 entries. Run trace_clear before reproducing and save right after the failure.
  • Save only the result of project_export; that file works as is with project_import.
  • Serial trace times are PC I/O batch times; don’t report them as UART bit times.
  • Save standard output and standard error separately, and keep the exit codes.

Fill this in with real values and send it to your contact. Nothing in this process sends data anywhere automatically.

SDK version / capabilities and abi from manifest.json:
OS version / CPU / compiler / .NET SDK and runtime:
Transport / client and server roles / unit / address or serial settings:
Preparation and the exact commands or GUI actions:
Expected response and values:
Actual response / status / source / error message / exit code:
Client and server trace, project with sensitive data removed, screenshots:
Diagnostic log (JSONL) and dropped, write_errors from logging_status:
How often it happens / first failing step / recovery:
Device model, adapter and wiring (serial devices only):

Never include private keys, API keys or raw customer data. For certificates, give paths, the public certificate fingerprint and the validity period only.

The peer in terminal A ends after 30 seconds; press Ctrl+C to end it sooner. Delete the logs folder once you’ve used it in your request.