moat-link-metrics 0.0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ MIT License
2
+
3
+ Copyright (c) Matthias Urlichs
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/make -f
2
+
3
+ PACKAGE = moat-link-metrics
4
+ MAKEINCL ?= $(shell python3 -mmoat src path)/make/py
5
+
6
+ ifneq ($(wildcard $(MAKEINCL)),)
7
+ include $(MAKEINCL)
8
+ # availabe via http://github.com/smurfix/sourcemgr
9
+
10
+ else
11
+ %:
12
+ @echo "Please fix 'python3 -mmoat src path'."
13
+ @exit 1
14
+ endif
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: moat-link-metrics
3
+ Version: 0.0.4
4
+ Summary: Metrics time-series connector for MoaT-Link
5
+ Author-email: Matthias Urlichs <matthias@urlichs.de>
6
+ Project-URL: homepage, https://m-o-a-t.org
7
+ Project-URL: repository, https://github.com/M-o-a-T/moat
8
+ Keywords: MoaT
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Framework :: Trio
13
+ Classifier: Framework :: AnyIO
14
+ Classifier: Development Status :: 4 - Beta
15
+ Requires-Python: >=3.13
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE.txt
18
+ Requires-Dist: anyio~=4.2
19
+ Requires-Dist: asyncclick
20
+ Requires-Dist: attrs
21
+ Requires-Dist: moat-util~=0.62.6
22
+ Requires-Dist: moat-link~=0.3.2
23
+ Requires-Dist: moat-lib-config~=0.1.2
24
+ Provides-Extra: akumuli
25
+ Requires-Dist: moat-link-metrics-akumuli~=0.0.1; extra == "akumuli"
26
+ Provides-Extra: victoria
27
+ Requires-Dist: moat-link-metrics-victoria~=0.0.1; extra == "victoria"
28
+ Dynamic: license-file
29
+
30
+ # Metrics time-series connector
31
+
32
+ % start synopsis
33
+ Forwards MoaT-Link values to metrics time-series storage backends.
34
+ % end synopsis
35
+
36
+ % start main
37
+
38
+ This module watches MoaT-Link entries and writes their values to metrics backends
39
+ whenever they change. It replaces the legacy ``moat-kv-akumuli`` and ``moat-link-akumuli`` packages.
40
+
41
+ ## Features
42
+
43
+ - Pluggable backend support (currently: Akumuli)
44
+ - Per-server configuration stored in MoaT-Link
45
+ - Dynamic series management: add, remove, or modify series at runtime
46
+ - Configurable scaling (factor/offset) and rate limiting (t_min)
47
+ - Attribute extraction from nested source values
48
+
49
+ ## Quick start
50
+
51
+ 1. Configure a metrics server:
52
+
53
+ ```shell
54
+ moat link metrics add myserver my.entry source.path series_name host=myhost
55
+ ```
56
+
57
+ 2. Run the connector:
58
+
59
+ ```shell
60
+ moat link metrics monitor myserver
61
+ ```
62
+
63
+ ## Backends
64
+
65
+ The default backend is Akumuli. To specify a different backend, set the `backend`
66
+ field in the server configuration to the backend module name (e.g., `akumuli`).
67
+
68
+ ## Deprecation
69
+
70
+ This package supersedes ``moat-kv-akumuli`` and ``moat-link-akumuli``. The raw
71
+ MQTT topic monitoring feature has been removed; use MoaT-Link's native data paths instead.
72
+
73
+ % end main
@@ -0,0 +1,44 @@
1
+ # Metrics time-series connector
2
+
3
+ % start synopsis
4
+ Forwards MoaT-Link values to metrics time-series storage backends.
5
+ % end synopsis
6
+
7
+ % start main
8
+
9
+ This module watches MoaT-Link entries and writes their values to metrics backends
10
+ whenever they change. It replaces the legacy ``moat-kv-akumuli`` and ``moat-link-akumuli`` packages.
11
+
12
+ ## Features
13
+
14
+ - Pluggable backend support (currently: Akumuli)
15
+ - Per-server configuration stored in MoaT-Link
16
+ - Dynamic series management: add, remove, or modify series at runtime
17
+ - Configurable scaling (factor/offset) and rate limiting (t_min)
18
+ - Attribute extraction from nested source values
19
+
20
+ ## Quick start
21
+
22
+ 1. Configure a metrics server:
23
+
24
+ ```shell
25
+ moat link metrics add myserver my.entry source.path series_name host=myhost
26
+ ```
27
+
28
+ 2. Run the connector:
29
+
30
+ ```shell
31
+ moat link metrics monitor myserver
32
+ ```
33
+
34
+ ## Backends
35
+
36
+ The default backend is Akumuli. To specify a different backend, set the `backend`
37
+ field in the server configuration to the backend module name (e.g., `akumuli`).
38
+
39
+ ## Deprecation
40
+
41
+ This package supersedes ``moat-kv-akumuli`` and ``moat-link-akumuli``. The raw
42
+ MQTT topic monitoring feature has been removed; use MoaT-Link's native data paths instead.
43
+
44
+ % end main
@@ -0,0 +1,7 @@
1
+ /files
2
+ /*.log
3
+ /*.debhelper
4
+ /*.debhelper-build-stamp
5
+ /*.substvars
6
+ /debhelper-build-stamp
7
+ /moat-link-metrics
@@ -0,0 +1,5 @@
1
+ moat-link-metrics (0.0.4-2) unstable; urgency=medium
2
+
3
+ * Initial release for 26.0.1
4
+
5
+ -- Matthias Urlichs <matthias@urlichs.de> Wed, 18 Feb 2026 18:28:43 +0100
@@ -0,0 +1,29 @@
1
+ Source: moat-link-metrics
2
+ Maintainer: Matthias Urlichs <matthias@urlichs.de>
3
+ Section: python
4
+ Priority: optional
5
+ Build-Depends: dh-python, python3-all, debhelper (>= 13),
6
+ python3-setuptools,
7
+ python3-wheel,
8
+ Standards-Version: 3.9.6
9
+ Homepage: https://github.com/M-o-a-T/moat
10
+ X-DH-Compat: 13
11
+
12
+ Package: moat-link-metrics
13
+ Architecture: all
14
+ Depends: ${misc:Depends}, ${python3:Depends},
15
+ moat-util,
16
+ moat-link,
17
+ Recommends:
18
+ python3-trio (>= 0.22),
19
+ moat-link-metrics-backend-akumuli,
20
+ Suggests:
21
+ moat-link-metrics-backend-akumuli,
22
+ Description: Metrics time-series connector for MoaT-Link
23
+ Watches MoaT-Link data entries and writes their values to metrics
24
+ time-series backends whenever they change.
25
+ .
26
+ Backends are provided by separate packages (e.g.,
27
+ moat-link-metrics-backend-akumuli for Akumuli support).
28
+ .
29
+ Part of the MoaT ecosystem for distributed home automation.
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/make -f
2
+
3
+ export PYBUILD_NAME=moat-link-akumuli
4
+ %:
5
+ dh $@ --with python3 --buildsystem=pybuild
6
+
7
+ override_dh_auto_install:
8
+ dh_auto_install
9
+ install -D -m 644 systemd/moat-link-metrics@.service \
10
+ debian/moat-link-akumuli/usr/lib/systemd/system/moat-link-metrics@.service
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ build-backend = "setuptools.build_meta"
3
+ requires = [ "setuptools", "wheel",]
4
+
5
+ [project]
6
+ classifiers = [
7
+ "Intended Audience :: Developers",
8
+ "Programming Language :: Python :: 3",
9
+ "Framework :: AsyncIO",
10
+ "Framework :: Trio",
11
+ "Framework :: AnyIO",
12
+ "Development Status :: 4 - Beta",
13
+ ]
14
+ dependencies = [
15
+ "anyio ~= 4.2",
16
+ "asyncclick",
17
+ "attrs",
18
+ "moat-util ~= 0.62.6",
19
+ "moat-link ~= 0.3.2",
20
+ "moat-lib-config ~= 0.1.2",
21
+ ]
22
+ version = "0.0.4"
23
+ keywords = [ "MoaT",]
24
+ requires-python = ">=3.13"
25
+ name = "moat-link-metrics"
26
+ description = "Metrics time-series connector for MoaT-Link"
27
+ readme = "README.md"
28
+
29
+ [project.optional-dependencies]
30
+ akumuli = [
31
+ "moat-link-metrics-akumuli ~= 0.0.1",
32
+ ]
33
+ victoria = [
34
+ "moat-link-metrics-victoria ~= 0.0.1",
35
+ ]
36
+
37
+ [[project.authors]]
38
+ name = "Matthias Urlichs"
39
+ email = "matthias@urlichs.de"
40
+
41
+ [project.urls]
42
+ homepage = "https://m-o-a-t.org"
43
+ repository = "https://github.com/M-o-a-T/moat"
44
+
45
+ [tool.setuptools]
46
+ [tool.setuptools.packages.find]
47
+ where = ["src"]
48
+
49
+ [tool.setuptools.package-data]
50
+ "*" = ["*.yaml"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ """Metrics time-series connector for MoaT-Link."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from moat.lib.config import register as _register
6
+
7
+ __all__: list[str] = []
8
+
9
+ _register(__name__)
@@ -0,0 +1,5 @@
1
+ prefix: !P metrics
2
+ server_default:
3
+ backend: akumuli
4
+ host: localhost
5
+ port: 8282
@@ -0,0 +1,122 @@
1
+ """
2
+ Command-line interface for moat.link.metrics.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+
9
+ import asyncclick as click
10
+
11
+ from moat.util import NotGiven
12
+ from moat.lib.path import P
13
+ from moat.link._data import data_get
14
+ from moat.link.client import Link
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @click.group(short_help="Manage metrics series.")
20
+ @click.pass_context
21
+ async def cli(ctx):
22
+ """Configure metrics time-series forwarding.
23
+
24
+ Series entries live below the configured ``link.metrics.prefix``
25
+ path, grouped by server name.
26
+ """
27
+ obj = ctx.obj
28
+ cfg = obj.cfg["link"]
29
+ obj.conn = await ctx.with_async_resource(Link(cfg))
30
+ obj.metrics_cfg = obj.cfg.link.metrics
31
+ obj.prefix = obj.metrics_cfg.prefix
32
+
33
+
34
+ @cli.command("list")
35
+ @click.argument("server", type=str)
36
+ @click.pass_obj
37
+ async def list_(obj, server):
38
+ """List series entries for a server."""
39
+ path = obj.prefix + P(server)
40
+ await data_get(obj.conn, path, recursive=True, out=obj.stdout)
41
+
42
+
43
+ @cli.command("show")
44
+ @click.argument("server", type=str)
45
+ @click.argument("name", type=P)
46
+ @click.pass_obj
47
+ async def show_(obj, server, name):
48
+ """Show a single series entry."""
49
+ path = obj.prefix + P(server) + name
50
+ await data_get(obj.conn, path, recursive=False, out=obj.stdout)
51
+
52
+
53
+ @cli.command("add")
54
+ @click.argument("server", type=str)
55
+ @click.argument("name", type=P)
56
+ @click.argument("source", type=P)
57
+ @click.argument("series", type=str)
58
+ @click.argument("tags", nargs=-1)
59
+ @click.option("-m", "--mode", default="gauge", help="Mode (default: gauge).")
60
+ @click.option(
61
+ "-a",
62
+ "--attr",
63
+ default=None,
64
+ type=P,
65
+ help="Attribute path to extract from the source value.",
66
+ )
67
+ @click.option("-f", "--force", is_flag=True, help="Overwrite existing entry.")
68
+ @click.pass_obj
69
+ async def add_(obj, server, name, source, series, tags, mode, attr, force):
70
+ """Add a series entry.
71
+
72
+ \\b
73
+ SERVER: server name.
74
+ NAME: unique entry name (path).
75
+ SOURCE: MoaT-Link path of the value to watch.
76
+ SERIES: Backend series name.
77
+ TAGS: key=value pairs for backend tags.
78
+ """
79
+ path = obj.prefix + P(server) + name
80
+ if not tags:
81
+ raise click.UsageError("At least one tag is required")
82
+
83
+ tagged = {}
84
+ for t in tags:
85
+ try:
86
+ k, v = t.split("=", 1)
87
+ except ValueError:
88
+ raise click.UsageError("Tags must be key=value") from None
89
+ tagged[k] = v
90
+
91
+ if not force:
92
+ try:
93
+ await obj.conn.d_get(path)
94
+ except (KeyError, ValueError):
95
+ pass
96
+ else:
97
+ raise click.UsageError("Entry exists. Use --force to overwrite.")
98
+
99
+ val: dict = {"source": source, "series": series, "tags": tagged, "mode": mode}
100
+ if attr:
101
+ val["attr"] = attr
102
+ await obj.conn.d_set(path, val)
103
+
104
+
105
+ @cli.command("delete")
106
+ @click.argument("server", type=str)
107
+ @click.argument("name", type=P)
108
+ @click.pass_obj
109
+ async def delete_(obj, server, name):
110
+ """Remove a series entry."""
111
+ path = obj.prefix + P(server) + name
112
+ await obj.conn.d_set(path, NotGiven)
113
+
114
+
115
+ @cli.command("monitor")
116
+ @click.argument("server", type=str)
117
+ @click.pass_obj
118
+ async def monitor_(obj, server):
119
+ """Run the metrics connector for one server."""
120
+ from .task import task # noqa:PLC0415
121
+
122
+ await task(obj.conn, obj.metrics_cfg, server)
@@ -0,0 +1,12 @@
1
+ """
2
+ Common code for metrics backends.
3
+
4
+ Currently the only backend supported is Akumuli, but who knows.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ._base import Backend as Backend
10
+ from ._base import get_backend as get_backend
11
+
12
+ __all__ = ["Backend", "get_backend"]
@@ -0,0 +1,65 @@
1
+ """
2
+ Common code for metrics backends.
3
+
4
+ Currently the only backend supported is Akumuli, but who knows.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from abc import ABCMeta, abstractmethod
11
+ from contextlib import asynccontextmanager
12
+
13
+ from moat.util import CtxObj
14
+
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ from moat.link.metrics.model import MetricPoint
19
+
20
+ from collections.abc import AsyncIterator
21
+ from typing import Self
22
+
23
+ __all__ = ["Backend", "get_backend"]
24
+
25
+
26
+ class Backend(CtxObj, metaclass=ABCMeta):
27
+ """Base class for metrics backends."""
28
+
29
+ def __init__(self, cfg: dict, name: str):
30
+ self.cfg = cfg
31
+ self.name = name
32
+ self.logger = logging.getLogger(f"moat.link.metrics.backend.{name}")
33
+
34
+ @abstractmethod
35
+ @asynccontextmanager
36
+ async def connect(self, *a, **k) -> AsyncIterator[Self]:
37
+ """
38
+ This async context manager returns a connection to the backend.
39
+ """
40
+
41
+ @asynccontextmanager
42
+ async def _ctx(self) -> AsyncIterator[Self]:
43
+ async with self.connect():
44
+ yield self
45
+
46
+ @abstractmethod
47
+ async def put(self, point: MetricPoint) -> None:
48
+ """
49
+ Send a metric data point to the backend.
50
+
51
+ Args:
52
+ point: The data point to send.
53
+ """
54
+
55
+
56
+ def get_backend(cfg: dict, name: str) -> Backend:
57
+ """
58
+ Fetch the backend named in the config and initialize it.
59
+ """
60
+ from importlib import import_module # noqa: PLC0415
61
+
62
+ backend_name = cfg.get("backend", "akumuli")
63
+ if "." not in backend_name:
64
+ backend_name = "moat.link.metrics.backend." + backend_name
65
+ return import_module(backend_name).Backend(cfg, name)
@@ -0,0 +1,154 @@
1
+ """
2
+ Node model for metrics series configuration.
3
+
4
+ The tree mirrors the MoaT-Link subtree under the configured prefix.
5
+ Each server entry contains per-series child nodes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from attrs import define, field
13
+
14
+ from moat.util import NotGiven
15
+ from moat.link.node import Node
16
+
17
+ from typing import TYPE_CHECKING
18
+
19
+ if TYPE_CHECKING:
20
+ import anyio
21
+
22
+ from moat.lib.rpc import Key
23
+
24
+ from typing import Any
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ @define
30
+ class MetricPoint:
31
+ """
32
+ A backend-agnostic metric data point.
33
+
34
+ Args:
35
+ series: Series name.
36
+ value: Numeric value.
37
+ tags: Dictionary of tags.
38
+ mode: Backend-specific mode string.
39
+ timestamp: Optional timestamp (seconds since epoch, or ``None``
40
+ for "now").
41
+ """
42
+
43
+ series: str
44
+ value: float
45
+ tags: dict[str, Any]
46
+ mode: str
47
+ timestamp: float | None = None
48
+
49
+
50
+ def _test_hook(e: MetricPoint) -> None:
51
+ """Hook for testing; called with every MetricPoint before sending."""
52
+
53
+
54
+ @define
55
+ class MetricsEntry(Node):
56
+ """A single metrics series mapping.
57
+
58
+ Reads a source value from MoaT-Link, applies *factor* and *offset*,
59
+ and writes the result to a metrics backend.
60
+ """
61
+
62
+ _work: anyio.CancelScope | None = field(init=False, default=None)
63
+ _t_last: float | None = field(init=False, default=None)
64
+
65
+ # --- helpers --------------------------------------------------------
66
+
67
+ @property
68
+ def source(self) -> tuple | None:
69
+ """Source path from the stored config, or None."""
70
+ d = self.data_
71
+ if d is NotGiven or not isinstance(d, dict):
72
+ return None
73
+ return d.get("source")
74
+
75
+ @property
76
+ def series(self) -> str | None:
77
+ """Akumuli series name."""
78
+ d = self.data_
79
+ if d is NotGiven or not isinstance(d, dict):
80
+ return None
81
+ return d.get("series")
82
+
83
+ @property
84
+ def tags(self) -> dict | None:
85
+ """Akumuli tags dict."""
86
+ d = self.data_
87
+ if d is NotGiven or not isinstance(d, dict):
88
+ return None
89
+ return d.get("tags")
90
+
91
+ @property
92
+ def mode(self) -> str:
93
+ """Backend-specific data-series mode."""
94
+ d = self.data_ if self.data_ is not NotGiven else {}
95
+ m = d.get("mode", "gauge") if isinstance(d, dict) else "gauge"
96
+ return m if isinstance(m, str) else str(m)
97
+
98
+ @property
99
+ def attr(self) -> tuple:
100
+ """Attribute path to extract from the watched value."""
101
+ d = self.data_ if self.data_ is not NotGiven else {}
102
+ return d.get("attr", ()) if isinstance(d, dict) else ()
103
+
104
+ @property
105
+ def factor(self) -> float:
106
+ """Multiplicative scaling factor."""
107
+ d = self.data_ if self.data_ is not NotGiven else {}
108
+ return d.get("factor", 1) if isinstance(d, dict) else 1
109
+
110
+ @property
111
+ def offset(self) -> float:
112
+ """Additive offset."""
113
+ d = self.data_ if self.data_ is not NotGiven else {}
114
+ return d.get("offset", 0) if isinstance(d, dict) else 0
115
+
116
+ @property
117
+ def t_min(self) -> float | None:
118
+ """Minimum interval between writes, in seconds."""
119
+ d = self.data_ if self.data_ is not NotGiven else {}
120
+ return d.get("t_min") if isinstance(d, dict) else None
121
+
122
+ def is_complete(self) -> bool:
123
+ """Check whether this entry has enough data to start a worker."""
124
+ return bool(self.source and self.series and self.tags)
125
+
126
+
127
+ @define
128
+ class MetricsServer(Node):
129
+ """Represents one metrics server instance in the configuration tree.
130
+
131
+ Children are :class:`MetricsEntry` nodes.
132
+ """
133
+
134
+ def add_child(self, item: Key) -> MetricsEntry:
135
+ """Create child entries as :class:`MetricsEntry`."""
136
+ if item in self._sub:
137
+ raise ValueError("exists")
138
+ self._sub[item] = s = MetricsEntry()
139
+ return s
140
+
141
+
142
+ @define
143
+ class MetricsRoot(Node):
144
+ """Root of the metrics configuration tree.
145
+
146
+ Children are :class:`MetricsServer` nodes.
147
+ """
148
+
149
+ def add_child(self, item: Key) -> MetricsServer:
150
+ """Create child servers as :class:`MetricsServer`."""
151
+ if item in self._sub:
152
+ raise ValueError("exists")
153
+ self._sub[item] = s = MetricsServer()
154
+ return s
@@ -0,0 +1,117 @@
1
+ """
2
+ Main supervisor task for metrics.
3
+
4
+ Connects to a metrics backend and monitors the MoaT-Link configuration
5
+ subtree. Spawns / cancels per-series workers as entries appear or change.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import anyio
11
+ import logging
12
+
13
+ from moat.util import combine_dict
14
+ from moat.lib.path import Path
15
+
16
+ from .backend import get_backend
17
+ from .model import MetricsEntry, MetricsServer
18
+ from .worker import run_entry
19
+
20
+ from typing import TYPE_CHECKING
21
+
22
+ if TYPE_CHECKING:
23
+ from moat.link.client import LinkSender
24
+
25
+ from collections.abc import Mapping
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ async def task(
31
+ link: LinkSender,
32
+ cfg: Mapping,
33
+ server_name: str,
34
+ *,
35
+ task_status: anyio.abc.TaskStatus = anyio.TASK_STATUS_IGNORED,
36
+ ) -> None:
37
+ """Run the metrics connector for one server.
38
+
39
+ Args:
40
+ link: an active MoaT-Link sender.
41
+ cfg: the ``link.metrics`` configuration section.
42
+ server_name: the server entry name inside the config subtree.
43
+ task_status: task-status for ``tg.start``.
44
+ """
45
+ prefix = Path.build(cfg["prefix"])
46
+ server_path = prefix / server_name
47
+
48
+ # Fetch the server-level config (host/port/backend) from its stored value
49
+ server_data = await link.d_get(server_path)
50
+ srv_cfg = combine_dict(
51
+ (server_data if isinstance(server_data, dict) else {}).get("server", {}),
52
+ cfg.get("server_default", {}),
53
+ )
54
+
55
+ # Get the backend from config
56
+ backend = get_backend(srv_cfg, server_name)
57
+
58
+ async with (
59
+ backend,
60
+ anyio.create_task_group() as tg,
61
+ ):
62
+ workers: dict[Path, anyio.CancelScope] = {}
63
+
64
+ def _cancel(p: Path) -> None:
65
+ sc = workers.pop(p, None)
66
+ if sc is not None:
67
+ sc.cancel()
68
+
69
+ async def _start(p: Path, entry: MetricsEntry) -> None:
70
+ _cancel(p)
71
+ if not entry.is_complete():
72
+ logger.warning("Incomplete entry at %s, skipping", p)
73
+ return
74
+
75
+ async def _run(
76
+ *,
77
+ task_status: anyio.abc.TaskStatus = anyio.TASK_STATUS_IGNORED,
78
+ ) -> None:
79
+ with anyio.CancelScope() as sc:
80
+ workers[p] = sc
81
+ task_status.started()
82
+ try:
83
+ await run_entry(link, entry, backend, p)
84
+ except Exception:
85
+ logger.exception("Worker for %s failed", p)
86
+
87
+ await tg.start(_run)
88
+
89
+ # Watch the server subtree for configuration entries.
90
+ # mark=True yields None when the initial state is complete.
91
+ async with link.d_watch(
92
+ server_path,
93
+ subtree=True,
94
+ mark=True,
95
+ cls=MetricsServer,
96
+ ) as mon:
97
+ task_status.started()
98
+
99
+ async for msg in mon:
100
+ if msg is None:
101
+ # Initial state loaded; all entries already spawned.
102
+ continue
103
+ p, _data = msg
104
+ if not p:
105
+ # Server-level data changed (host/port); ignore here.
106
+ continue
107
+
108
+ node = mon._node.get(p) # noqa:SLF001
109
+ if isinstance(node, MetricsEntry):
110
+ if node.is_complete():
111
+ await _start(p, node)
112
+ else:
113
+ _cancel(p)
114
+ else:
115
+ _cancel(p)
116
+
117
+ tg.cancel_scope.cancel()
@@ -0,0 +1,77 @@
1
+ """
2
+ Per-series worker for metrics.
3
+
4
+ Each worker watches a single MoaT-Link value and writes updates
5
+ to the corresponding metrics backend.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import anyio
11
+ import logging
12
+
13
+ from moat.lib.path import Path
14
+
15
+ from . import model as _model
16
+ from .model import MetricPoint
17
+
18
+ from typing import TYPE_CHECKING
19
+
20
+ if TYPE_CHECKING:
21
+ from moat.link.client import LinkSender
22
+
23
+ from .backend import Backend
24
+ from .model import MetricsEntry
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ async def run_entry(
30
+ link: LinkSender,
31
+ entry: MetricsEntry,
32
+ backend: Backend,
33
+ subpath: Path,
34
+ ) -> None:
35
+ """Watch one source value and forward it to the metrics backend.
36
+
37
+ Args:
38
+ link: an active MoaT-Link sender.
39
+ entry: the configuration node for this series.
40
+ backend: the metrics backend connection.
41
+ subpath: path of this entry relative to the server node
42
+ (used for error reporting).
43
+ """
44
+ source = Path.build(entry.source)
45
+ series = entry.series
46
+ tags = entry.tags
47
+ mode = entry.mode
48
+ attr = entry.attr
49
+ factor = entry.factor
50
+ offset = entry.offset
51
+ t_min = entry.t_min
52
+ t_last: float | None = None
53
+
54
+ async with link.d_watch(source) as wp:
55
+ async for raw in wp:
56
+ if t_min is not None:
57
+ t = anyio.current_time()
58
+ if t_last is not None and t_last + t_min > t:
59
+ continue
60
+ t_last = t
61
+
62
+ # Drill into nested data via attr path
63
+ val = raw
64
+ for k in attr:
65
+ try:
66
+ val = val[k]
67
+ except (KeyError, TypeError, IndexError):
68
+ logger.warning("Missing attr %r in %s", attr, subpath)
69
+ break
70
+ else:
71
+ if not isinstance(val, (int, float)):
72
+ logger.warning("Non-numeric value at %s: %r", subpath, val)
73
+ continue
74
+ out = val * factor + offset
75
+ point = MetricPoint(series=series, mode=mode, value=out, tags=tags)
76
+ _model._test_hook(point) # noqa:SLF001
77
+ await backend.put(point)
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: moat-link-metrics
3
+ Version: 0.0.4
4
+ Summary: Metrics time-series connector for MoaT-Link
5
+ Author-email: Matthias Urlichs <matthias@urlichs.de>
6
+ Project-URL: homepage, https://m-o-a-t.org
7
+ Project-URL: repository, https://github.com/M-o-a-T/moat
8
+ Keywords: MoaT
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Framework :: Trio
13
+ Classifier: Framework :: AnyIO
14
+ Classifier: Development Status :: 4 - Beta
15
+ Requires-Python: >=3.13
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE.txt
18
+ Requires-Dist: anyio~=4.2
19
+ Requires-Dist: asyncclick
20
+ Requires-Dist: attrs
21
+ Requires-Dist: moat-util~=0.62.6
22
+ Requires-Dist: moat-link~=0.3.2
23
+ Requires-Dist: moat-lib-config~=0.1.2
24
+ Provides-Extra: akumuli
25
+ Requires-Dist: moat-link-metrics-akumuli~=0.0.1; extra == "akumuli"
26
+ Provides-Extra: victoria
27
+ Requires-Dist: moat-link-metrics-victoria~=0.0.1; extra == "victoria"
28
+ Dynamic: license-file
29
+
30
+ # Metrics time-series connector
31
+
32
+ % start synopsis
33
+ Forwards MoaT-Link values to metrics time-series storage backends.
34
+ % end synopsis
35
+
36
+ % start main
37
+
38
+ This module watches MoaT-Link entries and writes their values to metrics backends
39
+ whenever they change. It replaces the legacy ``moat-kv-akumuli`` and ``moat-link-akumuli`` packages.
40
+
41
+ ## Features
42
+
43
+ - Pluggable backend support (currently: Akumuli)
44
+ - Per-server configuration stored in MoaT-Link
45
+ - Dynamic series management: add, remove, or modify series at runtime
46
+ - Configurable scaling (factor/offset) and rate limiting (t_min)
47
+ - Attribute extraction from nested source values
48
+
49
+ ## Quick start
50
+
51
+ 1. Configure a metrics server:
52
+
53
+ ```shell
54
+ moat link metrics add myserver my.entry source.path series_name host=myhost
55
+ ```
56
+
57
+ 2. Run the connector:
58
+
59
+ ```shell
60
+ moat link metrics monitor myserver
61
+ ```
62
+
63
+ ## Backends
64
+
65
+ The default backend is Akumuli. To specify a different backend, set the `backend`
66
+ field in the server configuration to the backend module name (e.g., `akumuli`).
67
+
68
+ ## Deprecation
69
+
70
+ This package supersedes ``moat-kv-akumuli`` and ``moat-link-akumuli``. The raw
71
+ MQTT topic monitoring feature has been removed; use MoaT-Link's native data paths instead.
72
+
73
+ % end main
@@ -0,0 +1,22 @@
1
+ LICENSE.txt
2
+ Makefile
3
+ README.md
4
+ pyproject.toml
5
+ debian/.gitignore
6
+ debian/changelog
7
+ debian/control
8
+ debian/rules
9
+ src/moat/link/metrics/__init__.py
10
+ src/moat/link/metrics/_cfg.yaml
11
+ src/moat/link/metrics/_main.py
12
+ src/moat/link/metrics/model.py
13
+ src/moat/link/metrics/task.py
14
+ src/moat/link/metrics/worker.py
15
+ src/moat/link/metrics/backend/__init__.py
16
+ src/moat/link/metrics/backend/_base.py
17
+ src/moat_link_metrics.egg-info/PKG-INFO
18
+ src/moat_link_metrics.egg-info/SOURCES.txt
19
+ src/moat_link_metrics.egg-info/dependency_links.txt
20
+ src/moat_link_metrics.egg-info/requires.txt
21
+ src/moat_link_metrics.egg-info/top_level.txt
22
+ systemd/moat-link-metrics@.service
@@ -0,0 +1,12 @@
1
+ anyio~=4.2
2
+ asyncclick
3
+ attrs
4
+ moat-lib-config~=0.1.2
5
+ moat-link~=0.3.2
6
+ moat-util~=0.62.6
7
+
8
+ [akumuli]
9
+ moat-link-metrics-akumuli~=0.0.1
10
+
11
+ [victoria]
12
+ moat-link-metrics-victoria~=0.0.1
@@ -0,0 +1,21 @@
1
+ [Unit]
2
+ Description=MoaT-Link metrics connector (%I)
3
+ After=moat-link-server.service
4
+ Requires=moat-link-server.service
5
+
6
+ ConditionFileNotEmpty=/etc/moat/moat.yaml
7
+
8
+ [Install]
9
+ WantedBy=multi-user.target
10
+
11
+ [Service]
12
+ Type=notify
13
+ ExecStart=/usr/bin/moat link metrics monitor %I
14
+
15
+ EnvironmentFile=-/etc/moat/link.env
16
+
17
+ TimeoutSec=300
18
+ WatchdogSec=10
19
+
20
+ Restart=always
21
+ RestartSec=30