moat-link-error 0.0.1__py3-none-any.whl

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,19 @@
1
+ """
2
+ Error trace monitoring for :mod:`moat.link`.
3
+
4
+ The server strips the backtrace from an error record before storing it.
5
+ The full record is only published, non-retained, to ``run.error.*``. The
6
+ :class:`ErrorMon` daemon in this package subscribes to that topic and
7
+ remembers the first and the last trace per error path, so that
8
+ ``moat link error PATH get`` can show them after the fact.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from moat.lib.config import register as _register
14
+
15
+ from ._impl import ErrorMon
16
+
17
+ _register(__name__)
18
+
19
+ __all__ = ["ErrorMon"]
@@ -0,0 +1,6 @@
1
+ # The trace monitor ("moat link error monitor")
2
+ monitor:
3
+ # forget traces that haven't been refreshed for this long (seconds)
4
+ max_age: 86400
5
+ # keep a process's announcement (run.id.*) this long after it ended (seconds)
6
+ id_keep: 60
@@ -0,0 +1,171 @@
1
+ """
2
+ The MoaT-Link error trace monitor.
3
+
4
+ This daemon watches ``run.error.*`` — the non-retained MQTT topic that
5
+ carries the *full* error record, including transient data like
6
+ backtraces — and remembers the first and the last trace per error path.
7
+ ``moat link error PATH get`` retrieves the stored traces via the
8
+ service announced by this daemon.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import anyio
14
+ import logging
15
+
16
+ from moat.util import NotGiven, attrdict
17
+ from moat.lib.path import P, Path
18
+ from moat.lib.priomap import TimerMap
19
+ from moat.lib.rpc import MsgHandler
20
+
21
+ from collections.abc import Mapping
22
+ from typing import TYPE_CHECKING
23
+
24
+ if TYPE_CHECKING:
25
+ from moat.link.backend import Message
26
+ from moat.link.client import Link
27
+
28
+ from collections.abc import AsyncIterator, Sequence
29
+ from typing import Any
30
+
31
+ __all__ = ["ErrorMon"]
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ _TRACE_FIELDS = ("bt", "exc", "log")
36
+ "Fields of an error record that describe one occurrence."
37
+
38
+ _ERR_PATH = P(":R.run.error")
39
+ "The topic to watch, relative to the link root."
40
+
41
+ _ID_PATH = P(":R.run.id")
42
+ "The topic where processes announce themselves."
43
+
44
+
45
+ class ErrorMon(MsgHandler):
46
+ """
47
+ Record transient error data.
48
+
49
+ The server persists error records to ``error.<path>`` but strips the
50
+ backtrace from them. The full record is published, non-retained, to
51
+ ``run.error.<path>``. This daemon subscribes to that topic and
52
+ stores, per error path, the first and the last record that carries a
53
+ backtrace.
54
+
55
+ Stored traces are dropped when no new one has arrived for ``max_age``
56
+ seconds (default: a day). Storage is in memory only; restarting the
57
+ daemon loses the traces of errors that don't recur, and an error
58
+ that is active when the daemon starts has no ``first`` trace until a
59
+ new one arrives.
60
+
61
+ Error messages don't say which process sent them, or where it runs.
62
+ The daemon therefore also keeps the ``run.id.<ident>`` announcements
63
+ (host, PID, command line). A stored trace includes the announcement of
64
+ the process that caused it (the message's origin) as ``origin``. The
65
+ announcement disappears when the process ends, but a late error
66
+ message may still arrive, so the daemon keeps it for ``id_keep``
67
+ seconds (default: a minute) afterwards.
68
+
69
+ The daemon announces itself as the ``error.monitor`` service; its
70
+ ``get`` command returns the stored data for an error path.
71
+ """
72
+
73
+ def __init__(self, cfg: attrdict) -> None:
74
+ """
75
+ Args:
76
+ cfg: The ``link.error.monitor`` sub-configuration.
77
+ """
78
+ super().__init__()
79
+ self.max_age: float = cfg.get("max_age", 86400)
80
+ self.id_keep: float = cfg.get("id_keep", 60)
81
+ self.traces: dict[Path, dict[str, Any]] = {}
82
+ "The stored trace data, by error path."
83
+ self.ids: dict[str, dict[str, Any]] = {}
84
+ "The announcements of known processes, by ident."
85
+ self._timers: TimerMap[Path | str] = TimerMap()
86
+ "Expiry of traces (keyed by error path) and of ended processes (by ident)."
87
+
88
+ async def run(self, link: Link, evt: anyio.Event | None = None) -> None:
89
+ """
90
+ Watch ``run.error.*`` and record transient data.
91
+
92
+ Args:
93
+ link: A connected link client.
94
+ evt: Set once the subscription is up.
95
+ """
96
+ async with (
97
+ anyio.create_task_group() as tg,
98
+ link.monitor(_ID_PATH, subtree=True) as ids,
99
+ link.monitor(_ERR_PATH, subtree=True, retained=False) as mon,
100
+ ):
101
+ tg.start_soon(self._expire)
102
+ tg.start_soon(self._watch_ids, ids)
103
+ if evt is not None:
104
+ evt.set()
105
+ async for msg in mon:
106
+ self.record(
107
+ msg.topic[len(_ERR_PATH) :], msg.data, msg.meta.timestamp, msg.meta.origin
108
+ )
109
+
110
+ async def _watch_ids(self, mon: AsyncIterator[Message]) -> None:
111
+ "Track the processes' announcements."
112
+ async for msg in mon:
113
+ ident = msg.topic[-1]
114
+ if not isinstance(ident, str):
115
+ continue
116
+ if msg.data is NotGiven:
117
+ if ident in self.ids:
118
+ self._timers[ident] = self.id_keep
119
+ elif isinstance(msg.data, Mapping):
120
+ self.ids[ident] = dict(msg.data)
121
+ self._timers.pop(ident, None)
122
+
123
+ async def _expire(self) -> None:
124
+ """
125
+ Drop traces that haven't been refreshed for ``max_age``, and the
126
+ announcements of processes that ended more than ``id_keep`` ago.
127
+ """
128
+ async for key in self._timers:
129
+ if isinstance(key, Path):
130
+ del self.traces[key]
131
+ else:
132
+ del self.ids[key]
133
+
134
+ def record(self, path: Path, data: Any, ts: float, origin: str | None = None) -> None:
135
+ """
136
+ Store the transient part of one ``run.error`` message.
137
+
138
+ Messages without a backtrace (counter updates, clearing an error)
139
+ are ignored: the stored record that the server includes with them
140
+ also has ``exc`` and ``log``, which would overwrite the last trace.
141
+
142
+ Args:
143
+ path: The error path, without the ``run.error`` prefix.
144
+ data: The message's payload.
145
+ ts: The message's timestamp.
146
+ origin: The ident of the process that sent the message.
147
+ """
148
+ if len(path) == 0 or not isinstance(data, Mapping) or "bt" not in data:
149
+ return
150
+
151
+ trace = {k: data[k] for k in _TRACE_FIELDS if k in data}
152
+ trace["t"] = ts
153
+ if origin:
154
+ trace["origin"] = {**self.ids.get(origin, {}), "id": origin}
155
+ if (entry := self.traces.get(path)) is None:
156
+ self.traces[path] = {"trace": {"first": trace, "last": trace}, "seen": ts}
157
+ else:
158
+ entry["trace"]["last"] = trace
159
+ entry["seen"] = ts
160
+ self._timers[path] = self.max_age
161
+
162
+ async def cmd_get(self, path: Sequence[str | int]) -> dict[str, Any]:
163
+ """
164
+ Return the stored trace data for this error path, or an empty dict.
165
+
166
+ Args:
167
+ path: The error path elements, without the ``error`` prefix.
168
+ """
169
+ return self.traces.get(Path.build(path), {})
170
+
171
+ doc_get = "Return the stored first/last trace for an error path, or nothing."
@@ -0,0 +1,258 @@
1
+ # command line interface
2
+ from __future__ import annotations
3
+
4
+ import anyio
5
+ from contextlib import nullcontext
6
+
7
+ import asyncclick as click
8
+
9
+ from moat.util import NotGiven, yprint
10
+ from moat.util import time as mtime
11
+ from moat.lib.path import P, Path
12
+ from moat.lib.run import AliasedGroup, attr_args
13
+ from moat.link._data import data_get, node_attr
14
+ from moat.link.announce import announcing
15
+ from moat.link.client import Link
16
+ from moat.link.meta import MsgMeta
17
+ from moat.util.times import humandelta
18
+
19
+ from typing import Any
20
+
21
+
22
+ @click.group(cls=AliasedGroup, short_help="Manage error data", invoke_without_command=True) # pylint: disable=undefined-variable
23
+ @click.option("-m", "--meta", is_flag=True, help="include metadata")
24
+ @click.argument("path", type=P, nargs=1)
25
+ @click.pass_context
26
+ async def cli(ctx, path, meta) -> None:
27
+ """
28
+ This subcommand reads and manipulates errors stored in the MoaT-Link service.
29
+
30
+ The bare command "moat link error monitor" runs the trace monitor
31
+ daemon, which remembers the backtraces that the server doesn't store.
32
+ To address an error named "monitor", add a subcommand, e.g.
33
+ "moat link error monitor get".
34
+ """
35
+ obj = ctx.obj
36
+ cfg = obj.cfg["link"]
37
+ obj.conn = await ctx.with_async_resource(Link(cfg))
38
+ obj.meta = meta
39
+ if ctx.invoked_subcommand is None and path == P("monitor"):
40
+ await _run_monitor(obj)
41
+ return
42
+ path = P("error") + path
43
+ if ctx.invoked_subcommand is None:
44
+ await data_get(obj.conn, path, meta=obj.meta, out=obj.stdout, recursive=False)
45
+ else:
46
+ obj.path = path
47
+
48
+
49
+ async def _run_monitor(obj) -> None:
50
+ "Run the trace monitor and announce its service."
51
+ from ._impl import ErrorMon # noqa: PLC0415
52
+
53
+ mon = ErrorMon(obj.cfg.link.error.monitor)
54
+ async with announcing(
55
+ obj.conn, host=False, name=P("error.monitor"), wait=True, service=mon
56
+ ) as ann:
57
+ await mon.run(obj.conn, evt=ann)
58
+
59
+
60
+ async def _traces(link: Link, path: Path) -> dict[str, Any] | None:
61
+ """
62
+ Ask the trace monitor for the stored traces of this error.
63
+
64
+ Returns `None` if the monitor isn't running or knows nothing.
65
+ """
66
+ try:
67
+ with anyio.fail_after(2):
68
+ srv = await link.get_service(P("error.monitor"))
69
+ res = await srv.get(path[1:])
70
+ except (KeyError, TimeoutError):
71
+ return None
72
+ return dict(res.kw) or None
73
+
74
+
75
+ @cli.command
76
+ @click.pass_obj
77
+ async def get(obj, **k) -> None:
78
+ """
79
+ Retrieve a MoaT-Link error entry.
80
+
81
+ If you read a sub-tree recursively, be aware that the whole subtree
82
+ will be read before anything is printed. Use the "watch --state" subcommand
83
+ for incremental output.
84
+
85
+ If the trace monitor ("moat link error monitor") is running, the
86
+ first and last backtrace it has seen are added as "trace".
87
+ """
88
+ res = await data_get(obj.conn, obj.path, meta=obj.meta, out=False, **k)
89
+ if (trace := await _traces(obj.conn, obj.path)) is not None:
90
+ entry = res.get("_") if isinstance(res, dict) else None
91
+ if obj.meta and isinstance(entry, dict):
92
+ entry = entry.get("data")
93
+ if isinstance(entry, dict):
94
+ entry["trace"] = trace["trace"]
95
+ yprint(res, stream=obj.stdout)
96
+
97
+
98
+ @cli.command("set", short_help="Update an entry")
99
+ @click.option("-k/-K", "--ok/--bad", is_flag=True, help="Mark as ok / not ok")
100
+ @attr_args
101
+ @click.pass_obj
102
+ async def set_(obj, ok, **kw) -> None:
103
+ """
104
+ Update some MoaT-Link error entry.
105
+ """
106
+ if ok is not None:
107
+ kw["eval_"] = (*kw["eval_"], ("ok", repr(ok)))
108
+ res = await node_attr(obj, obj.path, **kw)
109
+
110
+ if obj.meta:
111
+ yprint(res, stream=obj.stdout)
112
+
113
+
114
+ @cli.command(short_help="Delete an entry / subtree")
115
+ @click.option(
116
+ "-b",
117
+ "--before",
118
+ type=float,
119
+ help="Don't delete entries created after this timestamp",
120
+ )
121
+ @click.option("-r", "--recursive", is_flag=True, help="Delete a complete subtree")
122
+ @click.pass_obj
123
+ async def delete(obj, before, recursive) -> None:
124
+ """
125
+ Delete an entry, or a subtree.
126
+
127
+ You might want to use "--before" flag to ensure that no other change
128
+ arrived after you viewed the data in question.
129
+
130
+ Non-recursively deleting an entry with children works and does *not*
131
+ affect the child entries.
132
+
133
+ The root entry cannot be deleted.
134
+ """
135
+ args = {}
136
+ if recursive:
137
+ args["rec"] = recursive
138
+ if before:
139
+ args["ts"] = before
140
+ res = await obj.conn.d.delete(obj.path, **args)
141
+ if obj.meta:
142
+ res = dict(data=res[0], meta=MsgMeta.restore(res[1:]).repr())
143
+ else:
144
+ res = res[0]
145
+ yprint(res, stream=obj.stdout)
146
+
147
+
148
+ @cli.command("list")
149
+ @click.pass_obj
150
+ @click.option("-k/-A", "--ok/--all", is_flag=True, help="only list if ok / list all")
151
+ @click.option("-r", "--raw", is_flag=True, help="show age in seconds")
152
+ async def list_(obj, ok, raw) -> None:
153
+ """
154
+ List all errors with their paths and age.
155
+
156
+ Shows the name and age of each error.
157
+
158
+ The default is to list only open errors.
159
+ """
160
+ seen = False
161
+ now = mtime.time()
162
+ if ok is None:
163
+ ok = False
164
+ elif ok is False:
165
+ ok = None
166
+
167
+ async with obj.conn.d_watch(
168
+ obj.path,
169
+ state=True,
170
+ meta=True,
171
+ subtree=True,
172
+ ) as mon:
173
+ async for pdm in mon:
174
+ if pdm is None:
175
+ break
176
+ p, d, m = pdm
177
+ if ok is not None and d.get("ok", False) != ok:
178
+ continue
179
+
180
+ print(
181
+ f"{int(now - m.timestamp) if raw else humandelta(now - m.timestamp):10} {p}",
182
+ file=obj.stdout,
183
+ )
184
+ seen = True
185
+
186
+ if not seen:
187
+ print("No errors found.", file=obj.stdout)
188
+
189
+
190
+ @cli.command()
191
+ @click.option("-m", "--mode", type=str, help="Retrieval mode", default="s")
192
+ @click.option("-M", "--mark", is_flag=True, help="Retrieval mode")
193
+ @click.option("-s", "--subtree", is_flag=True, help="Read the whole tree.")
194
+ @click.option("-D", "--add-date", is_flag=True, help="Add *_date entries")
195
+ @click.option("-i", "--ignore", multiple=True, type=P, help="Skip this (sub)tree")
196
+ @click.option("-n", "--min-length", type=int, help="Minimum path length")
197
+ @click.option("-N", "--max-length", type=int, help="Maximum path length")
198
+ @click.option("-a", "--max-age", type=int, help="Skip entries older than N seconds")
199
+ @click.option("-t", "--timeout", type=int, help="Stop reading after N seconds")
200
+ @click.pass_obj
201
+ async def monitor(
202
+ obj,
203
+ mode,
204
+ add_date, # noqa: ARG001
205
+ ignore,
206
+ mark,
207
+ subtree,
208
+ min_length,
209
+ max_length,
210
+ max_age,
211
+ timeout,
212
+ ) -> None:
213
+ """Monitor a MoaT-Link subtree.
214
+
215
+ The mode can be:
216
+ * c/current read current data from the server
217
+ * u/update read updates from MQTT
218
+ * s/stream current plus updates
219
+ * m/mqtt subscribe to MQTT stream, including retained data
220
+ """
221
+
222
+ match mode:
223
+ case "c" | "current":
224
+ state = True
225
+ case "u" | "update":
226
+ state = False
227
+ case "s" | "stream":
228
+ state = None
229
+ case "m" | "mqtt":
230
+ state = NotGiven
231
+ case _:
232
+ raise click.UsageError("Mode must be current|update|stream|mqtt")
233
+ if mark and state is not None:
234
+ raise click.UsageError("You can only add a mark in Stream mode")
235
+
236
+ def pm(p) -> bool:
237
+ for ip in ignore:
238
+ if len(p) >= len(ip) and p[: len(ip)] == ip:
239
+ return True
240
+ return False
241
+
242
+ with anyio.move_on_after(timeout) if timeout else nullcontext():
243
+ async with obj.conn.d_watch(
244
+ obj.path,
245
+ state=state,
246
+ mark=mark,
247
+ meta=True,
248
+ subtree=subtree,
249
+ max_length=max_length,
250
+ min_length=min_length,
251
+ age=max_age,
252
+ ) as mon:
253
+ async for res in mon:
254
+ if res is None:
255
+ res = "*** Snapshot data ends ***" # noqa:PLW2901
256
+ yprint(res, stream=obj.stdout)
257
+ print("---", file=obj.stdout)
258
+ obj.stdout.flush()
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: moat-link-error
3
+ Version: 0.0.1
4
+ Summary: Error trace monitor 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.8
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE.txt
18
+ Requires-Dist: anyio~=4.2
19
+ Requires-Dist: asyncclick~=8.3
20
+ Requires-Dist: attrs
21
+ Requires-Dist: moat-util~=0.64.0
22
+ Requires-Dist: moat-link~=0.12.0
23
+ Dynamic: license-file
24
+
25
+ # Error traces
26
+
27
+ % start synopsis
28
+ % start main
29
+
30
+ Remember the backtraces of MoaT-Link errors.
31
+
32
+ The server stores error records without their backtrace. The full record
33
+ is only published, without retaining it, to `run.error.*`. This package
34
+ adds the `moat link error` command and a daemon (`moat link error monitor`)
35
+ that keeps the first and the last backtrace it sees for each error, for a
36
+ day by default, and hands them to `moat link error PATH get`, along with the host, PID and
37
+ command line of the process that caused the error.
38
+
39
+ % end synopsis
40
+
41
+ The daemon keeps its data in memory. A restart forgets the traces of
42
+ errors that don't recur.
43
+
44
+ % end main
45
+
46
+ ## Usage
47
+
48
+ Run `moat link error monitor` (the `moat-link-error-monitor` systemd unit
49
+ does that) on one host. Then `moat link error PATH get` adds the stored
50
+ traces to the error's record, as `trace.first` and `trace.last`.
51
+
52
+ How long traces are kept is configured with `link.error.monitor.max_age`.
53
+ See `docs/moat-link/errors.md` for details.
54
+
55
+ ## License
56
+
57
+ This project is licensed under the same terms as MoaT.
@@ -0,0 +1,9 @@
1
+ moat/link/error/__init__.py,sha256=o10-A8NZviQrmTBAboxqVlU-ZzaY98ds0oGto4H4Q-k,552
2
+ moat/link/error/_cfg.yaml,sha256=mjSs37GsoYPoFe5sDBkdA7zXJPforBBjsNwsDUFFlio,238
3
+ moat/link/error/_impl.py,sha256=DZEWNQVKqXqAJ2qAcT7_m2DpQLiqPnB9Vv9ZkQKTTzc,6346
4
+ moat/link/error/_main.py,sha256=KZxw0WEOglojCnj6HlnFO3iB5NWRVIXujEEcYUfVLiA,8051
5
+ moat_link_error-0.0.1.dist-info/licenses/LICENSE.txt,sha256=L5vKJLVOg5t0CEEPpW9-O_0vzbP0PEjEF06tLvnIDuk,541
6
+ moat_link_error-0.0.1.dist-info/METADATA,sha256=I6Ba59PE4skIlXq3zgSJ7OFA2M9zWRfLRzwUg2wwWg8,1817
7
+ moat_link_error-0.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ moat_link_error-0.0.1.dist-info/top_level.txt,sha256=pcs9fl5w5AB5GVi4SvBqIVmFrkRwQkVw_dEvW0Q0cSA,5
9
+ moat_link_error-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,14 @@
1
+ The code in this repository, and all MoaT submodules it refers to,
2
+ is part of the MoaT project.
3
+
4
+ Unless a submodule's LICENSE.txt states otherwise, all included files are
5
+ licensed under the LGPL V3, as published by the FSF at
6
+ https://www.gnu.org/licenses/lgpl-3.0.html .
7
+
8
+ In addition to the LGPL's terms, the author(s) respectfully ask all users of
9
+ this code to contribute any bug fixes or enhancements. Also, please link back to
10
+ https://M-o-a-T.org.
11
+
12
+ Thank you.
13
+
14
+ Copyright © 2021 ff.: the MoaT contributor(s), as per the git changelog(s).
@@ -0,0 +1 @@
1
+ moat