Skip to content

Commit 7425f5a

Browse files
authored
Add telemetry & Prometheus metrics endpoint, tests, docs, and lint fixes (#57)
* first draft * increase overall read and write counters * some changes * revert previous import change * using psutil to output memory and cpu statistics * round load avaergae pct metrics * expose metrics endpoint port * fix indent * add documentation and example for metrics endpoint * add persistence examples and fix indent * implement fix for metrics implementation * adding unit tests * upgrade linters and fix linter issues
1 parent 62eed28 commit 7425f5a

23 files changed

Lines changed: 1242 additions & 108 deletions

.pre-commit-config.yaml

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
repos:
33
- repo: https://github.com/pre-commit/pre-commit-hooks
4-
rev: v5.0.0
4+
rev: v6.0.0
55
hooks:
66
- id: fix-byte-order-marker
77
- id: check-ast
@@ -10,36 +10,39 @@ repos:
1010
- id: debug-statements
1111
- id: end-of-file-fixer
1212
- id: trailing-whitespace
13-
- id: fix-encoding-pragma
1413
- id: requirements-txt-fixer
1514
- id: mixed-line-ending
1615
args: ['--fix=lf']
1716
description: Forces to replace line ending by the UNIX 'lf' character
1817
- id: detect-aws-credentials
1918
args: ['--allow-missing-credentials']
2019
- id: detect-private-key
20+
- repo: https://github.com/asottile/pyupgrade
21+
rev: v3.21.2
22+
hooks:
23+
- id: pyupgrade
2124
- repo: https://github.com/myint/autoflake
22-
rev: v2.3.1
25+
rev: v2.3.3
2326
hooks:
2427
- id: autoflake
2528
args:
2629
- --in-place
2730
- --remove-unused-variables
2831
- --remove-all-unused-imports
2932
- repo: https://github.com/hadolint/hadolint
30-
rev: v2.12.0
33+
rev: v2.14.0
3134
hooks:
3235
- id: hadolint-docker
3336
- repo: https://github.com/charliermarsh/ruff-pre-commit
34-
rev: v0.7.0
37+
rev: v0.15.2
3538
hooks:
3639
- id: ruff
3740
args:
3841
- '--line-length=120'
3942
- '--fix'
4043
- '--exit-non-zero-on-fix'
4144
- repo: https://github.com/pycqa/isort
42-
rev: 5.13.2
45+
rev: 8.0.0
4346
hooks:
4447
- id: isort
4548
name: isort (python)
@@ -48,7 +51,7 @@ repos:
4851
- black
4952
- '--filter-files'
5053
- repo: https://github.com/psf/black
51-
rev: 24.10.0
54+
rev: 26.1.0
5255
hooks:
5356
- id: black
5457
args:

Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
FROM alpine:3.23.3
22

33
LABEL maintainer="Michael Oberdorf IT-Consulting <info@oberdorf-itc.de>"
4-
LABEL site.local.program.version="2.1.0"
4+
LABEL site.local.program.version="2.2.0"
55

66
RUN apk upgrade --available --no-cache --update \
77
&& apk add --no-cache --update \
@@ -19,6 +19,7 @@ RUN pip3 install --no-cache-dir -r /requirements.txt --break-system-packages
1919

2020
EXPOSE 5020/tcp
2121
EXPOSE 5020/udp
22+
EXPOSE 9090/tcp
2223

2324
USER 1434:1434
2425

Dockerfile.test

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
FROM alpine:3.23.2
22

33
LABEL maintainer="Michael Oberdorf IT-Consulting <info@oberdorf-itc.de>"
4-
LABEL site.local.program.version="2.1.0"
4+
LABEL site.local.program.version="2.2.0"
55

66
RUN apk upgrade --available --no-cache --update \
77
&& apk add --no-cache --update \
@@ -20,6 +20,7 @@ RUN pip3 install --no-cache-dir -r /requirements.txt --break-system-packages
2020

2121
EXPOSE 5020/tcp
2222
EXPOSE 5020/udp
23+
EXPOSE 9090/tcp
2324

2425
USER 1434:1434
2526

README.md

Lines changed: 138 additions & 69 deletions
Large diffs are not rendered by default.

examples/abb_coretec_example.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@
1313
"logLevel": "DEBUG"
1414
}
1515
},
16+
"persistence": {
17+
"enabled": false,
18+
"file": "/data/modbus_registers.json",
19+
"saveInterval": 30
20+
},
21+
"metrics": {
22+
"enabled": false,
23+
"address": "0.0.0.0",
24+
"port": 9090,
25+
"path": "/metrics"
26+
},
1627
"registers": {
1728
"description": "initial values for the register types",
1829
"initializeUndefinedRegisters": true,

examples/metrics_example.txt

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# HELP memory_total_bytes Total available memory in bytes
2+
# TYPE memory_total_bytes gauge
3+
memory_total_bytes 4093587456
4+
5+
# HELP memory_available_bytes Current available memory in bytes
6+
# TYPE memory_available_bytes gauge
7+
memory_available_bytes 325832704
8+
9+
# HELP memory_consumption_bytes Current memory consumption in bytes
10+
# TYPE memory_consumption_bytes gauge
11+
memory_consumption_bytes 3524468736
12+
13+
# HELP memory_consumption_percentage Current memory consumption percentage
14+
# TYPE memory_consumption_percentage gauge
15+
memory_consumption_percentage 92.0
16+
17+
# HELP cpu_usage_percentage Current CPU usage percentage
18+
# TYPE cpu_usage_percentage gauge
19+
cpu_usage_percentage 0.0
20+
21+
# HELP cpu_count Number of CPU cores
22+
# TYPE cpu_count gauge
23+
cpu_count 2
24+
25+
# HELP cpu_load1 Load average over 1 minute
26+
# TYPE cpu_load1 gauge
27+
cpu_load1 0.085
28+
29+
# HELP cpu_load5 Load average over 5 minutes
30+
# TYPE cpu_load5 gauge
31+
cpu_load5 0.215
32+
33+
# HELP cpu_load15 Load average over 15 minutes
34+
# TYPE cpu_load15 gauge
35+
cpu_load15 0.22
36+
37+
# HELP cpu_load1_percentage Load average percentage over 1 minute
38+
# TYPE cpu_load1_percentage gauge
39+
cpu_load1_percentage 8.5
40+
41+
# HELP cpu_load5_percentage Load average percentage over 5 minutes
42+
# TYPE cpu_load5_percentage gauge
43+
cpu_load5_percentage 21.5
44+
45+
# HELP cpu_load15_percentage Load average percentage over 15 minutes
46+
# TYPE cpu_load15_percentage gauge
47+
cpu_load15_percentage 22.0
48+
49+
# HELP modbus_requests_total Total number of Modbus requests received, by function code
50+
# TYPE modbus_requests_total counter
51+
modbus_requests_total{function_code="01",function_name="read_coils"} 43
52+
modbus_requests_total{function_code="02",function_name="read_discrete_inputs"} 1
53+
modbus_requests_total{function_code="03",function_name="read_holding_registers"} 34
54+
modbus_requests_total{function_code="05",function_name="write_single_coil"} 3
55+
modbus_requests_total{function_code="06",function_name="write_single_register"} 2
56+
57+
# HELP modbus_register_reads_total Total number of read operations per register
58+
# TYPE modbus_register_reads_total counter
59+
modbus_register_reads_total{address="2",type="coil"} 37
60+
modbus_register_reads_total{address="3",type="coil"} 34
61+
modbus_register_reads_total{address="4",type="coil"} 34
62+
modbus_register_reads_total{address="5",type="coil"} 34
63+
modbus_register_reads_total{address="6",type="coil"} 37
64+
modbus_register_reads_total{address="7",type="coil"} 34
65+
modbus_register_reads_total{address="8",type="coil"} 34
66+
modbus_register_reads_total{address="9",type="coil"} 34
67+
modbus_register_reads_total{address="10",type="coil"} 37
68+
modbus_register_reads_total{address="11",type="coil"} 34
69+
modbus_register_reads_total{address="2",type="discrete_input"} 1
70+
modbus_register_reads_total{address="3",type="discrete_input"} 1
71+
modbus_register_reads_total{address="4",type="discrete_input"} 1
72+
modbus_register_reads_total{address="5",type="discrete_input"} 1
73+
modbus_register_reads_total{address="6",type="discrete_input"} 1
74+
modbus_register_reads_total{address="7",type="discrete_input"} 1
75+
modbus_register_reads_total{address="8",type="discrete_input"} 1
76+
modbus_register_reads_total{address="9",type="discrete_input"} 1
77+
modbus_register_reads_total{address="10",type="discrete_input"} 1
78+
modbus_register_reads_total{address="11",type="discrete_input"} 1
79+
modbus_register_reads_total{address="2",type="holding"} 29
80+
modbus_register_reads_total{address="3",type="holding"} 29
81+
modbus_register_reads_total{address="4",type="holding"} 29
82+
modbus_register_reads_total{address="5",type="holding"} 29
83+
modbus_register_reads_total{address="6",type="holding"} 29
84+
modbus_register_reads_total{address="7",type="holding"} 31
85+
modbus_register_reads_total{address="8",type="holding"} 29
86+
modbus_register_reads_total{address="9",type="holding"} 29
87+
modbus_register_reads_total{address="10",type="holding"} 29
88+
modbus_register_reads_total{address="11",type="holding"} 32
89+
90+
# HELP modbus_register_writes_total Total number of write operations per register
91+
# TYPE modbus_register_writes_total counter
92+
modbus_register_writes_total{address="2",type="coil"} 1
93+
modbus_register_writes_total{address="6",type="coil"} 1
94+
modbus_register_writes_total{address="10",type="coil"} 1
95+
modbus_register_writes_total{address="7",type="holding"} 1
96+
modbus_register_writes_total{address="11",type="holding"} 1
97+
98+
# HELP modbus_errors_total Total number of Modbus errors returned, by exception code
99+
# TYPE modbus_errors_total counter
100+
101+
# HELP modbus_server_uptime_seconds Total uptime of the mock server in seconds
102+
# TYPE modbus_server_uptime_seconds counter
103+
modbus_server_uptime_seconds 90.30

examples/test.json

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,17 @@
1313
"logLevel": "DEBUG"
1414
}
1515
},
16-
"persistence": {
17-
"enabled": true,
18-
"file": "/data/modbus_registers.json",
19-
"saveInterval": 30
20-
},
16+
"persistence": {
17+
"enabled": true,
18+
"file": "/data/modbus_registers.json",
19+
"saveInterval": 30
20+
},
21+
"metrics": {
22+
"enabled": true,
23+
"address": "0.0.0.0",
24+
"port": 9090,
25+
"path": "/metrics"
26+
},
2127
"registers": {
2228
"description": "initial values for the register types",
2329
"initializeUndefinedRegisters": true,

examples/udp.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@
1313
"logLevel": "DEBUG"
1414
}
1515
},
16+
"persistence": {
17+
"enabled": false,
18+
"file": "/data/modbus_registers.json",
19+
"saveInterval": 30
20+
},
21+
"metrics": {
22+
"enabled": false,
23+
"address": "0.0.0.0",
24+
"port": 9090,
25+
"path": "/metrics"
26+
},
1627
"registers": {
1728
"description": "initial values for the register types",
1829
"initializeUndefinedRegisters": true,

src/app/lib/register_persistence/__init__.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
# -*- coding: utf-8 -*-
21
"""
32
###############################################################################
43
# Library to make register writes persistent across restarts of the modbus server.
@@ -7,14 +6,14 @@
76
# Author: Michael Oberdorf
87
# Date: 2026-02-07
98
# Last modified by: Michael Oberdorf
10-
# Last modified at: 2026-02-08
9+
# Last modified at: 2026-02-21
1110
###############################################################################\n
1211
"""
1312

1413
__author__ = "Michael Oberdorf <info@oberdorf-itc.de>"
1514
__status__ = "production"
16-
__date__ = "2026-02-08"
17-
__version_info__ = ("1", "0", "2")
15+
__date__ = "2026-02-21"
16+
__version_info__ = ("1", "1", "0")
1817
__version__ = ".".join(__version_info__)
1918

2019
__all__ = ["RegisterPersistence"]
@@ -73,7 +72,7 @@ def load_registers(self) -> Optional[dict]:
7372
return None
7473

7574
try:
76-
with open(self.persistence_file, "r", encoding="utf-8") as f:
75+
with open(self.persistence_file, encoding="utf-8") as f:
7776
data = json.load(f)
7877
self.logger.info(f"Successfully loaded register data from {self.persistence_file}")
7978
return data
@@ -147,6 +146,15 @@ def _extract_register_values(self, slave_context: ModbusServerContext, register_
147146
else:
148147
return result
149148

149+
# Unwrap any wrapper blocks (e.g. metrics wrappers) to access the underlying store
150+
try:
151+
# unwrap multiple layers if necessary
152+
while hasattr(store, "wrapped_block"):
153+
store = getattr(store, "wrapped_block")
154+
except Exception:
155+
# if unwrapping fails, log and continue with original store
156+
self.logger.debug("Failed to unwrap store wrapper, continuing with original store")
157+
150158
# Check if it's a sparse or sequential block
151159
if isinstance(store, ModbusSparseDataBlock):
152160
# Sparse blocks have a values dict

src/app/lib/telemetry/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)