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.
- moat_link_metrics-0.0.4/LICENSE.txt +3 -0
- moat_link_metrics-0.0.4/Makefile +14 -0
- moat_link_metrics-0.0.4/PKG-INFO +73 -0
- moat_link_metrics-0.0.4/README.md +44 -0
- moat_link_metrics-0.0.4/debian/.gitignore +7 -0
- moat_link_metrics-0.0.4/debian/changelog +5 -0
- moat_link_metrics-0.0.4/debian/control +29 -0
- moat_link_metrics-0.0.4/debian/rules +10 -0
- moat_link_metrics-0.0.4/pyproject.toml +50 -0
- moat_link_metrics-0.0.4/setup.cfg +4 -0
- moat_link_metrics-0.0.4/src/moat/link/metrics/__init__.py +9 -0
- moat_link_metrics-0.0.4/src/moat/link/metrics/_cfg.yaml +5 -0
- moat_link_metrics-0.0.4/src/moat/link/metrics/_main.py +122 -0
- moat_link_metrics-0.0.4/src/moat/link/metrics/backend/__init__.py +12 -0
- moat_link_metrics-0.0.4/src/moat/link/metrics/backend/_base.py +65 -0
- moat_link_metrics-0.0.4/src/moat/link/metrics/model.py +154 -0
- moat_link_metrics-0.0.4/src/moat/link/metrics/task.py +117 -0
- moat_link_metrics-0.0.4/src/moat/link/metrics/worker.py +77 -0
- moat_link_metrics-0.0.4/src/moat_link_metrics.egg-info/PKG-INFO +73 -0
- moat_link_metrics-0.0.4/src/moat_link_metrics.egg-info/SOURCES.txt +22 -0
- moat_link_metrics-0.0.4/src/moat_link_metrics.egg-info/dependency_links.txt +1 -0
- moat_link_metrics-0.0.4/src/moat_link_metrics.egg-info/requires.txt +12 -0
- moat_link_metrics-0.0.4/src/moat_link_metrics.egg-info/top_level.txt +1 -0
- moat_link_metrics-0.0.4/systemd/moat-link-metrics@.service +21 -0
|
@@ -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
moat
|
|
@@ -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
|