smart-data-engine-sdk 0.1.0.dev0__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.
sde/logging.py ADDED
@@ -0,0 +1,132 @@
1
+ """Structured logging, with one rule: every event has a name from a closed vocabulary.
2
+
3
+ We never have access to a client's machine. When something goes wrong there, the log is the only
4
+ diagnostic we will ever see, so it has to be greppable and stable rather than prose that changes
5
+ with each refactor. Hence ``sde.``-prefixed event names, structured fields, and no interpolated
6
+ values in the event name itself.
7
+
8
+ No dependency on a logging framework, and no configuration required. The library emits through the
9
+ standard :mod:`logging` module under the ``sde`` logger and stays silent unless the application
10
+ configures a handler - a library that prints on import is a library people vendor and patch.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from typing import Any, Final
17
+
18
+ __all__ = ["EVENTS", "log", "logger"]
19
+
20
+ logger: Final = logging.getLogger("sde")
21
+
22
+ EVENTS: Final[frozenset[str]] = frozenset(
23
+ {
24
+ # Once per process, and the single most useful line here: it names the model_version every
25
+ # other artefact keys on. "Why was my map refused for model version X" is answerable from
26
+ # this line and unanswerable without it.
27
+ "sde.model.built",
28
+ # A map parsed and was accepted. `signed` says which kind of document it was and
29
+ # `forward_only` whether the rollback check applies - fields rather than two event names,
30
+ # so that the account-free mode emits nothing the account mode does not.
31
+ "sde.map.loaded",
32
+ # A map was refused, with the error class and the structural reason. The exception goes to
33
+ # the application; this goes to whoever is looking at why it will not start.
34
+ "sde.map.rejected",
35
+ # A signed map was accepted and is not older than any already applied here. Emitted once
36
+ # per session, so the fields are the ones an operator wants when a start is refused later.
37
+ "sde.map.forward_only",
38
+ # No engine in this map can keep the bookkeeping, so a map that goes backwards cannot be
39
+ # recognised. Not a failure - our own orderbook engine has a fixed schema and nowhere to
40
+ # put it - but it is the one state where a documented protection is genuinely absent.
41
+ "sde.map.rollback_unprotected",
42
+ "sde.route.resolved",
43
+ "sde.route.fallback",
44
+ "sde.schema.applied",
45
+ # extra_columns fires when a table the map describes has columns the map does not
46
+ # name. Allowed rather than refused - a client may have added one outside SDE and
47
+ # writes are unaffected - but logged, because the alternative to refusing is saying
48
+ # nothing, and a schema that has quietly diverged is worth one line.
49
+ "sde.schema.extra_columns",
50
+ "sde.write.failed",
51
+ # A dual-write fan-out did not reach a copy. Not an application failure: the row is in the
52
+ # source, which is authoritative, and VERIFY is the gate that refuses to switch reads while
53
+ # any divergence remains. This is the only place in the library that swallows a write error.
54
+ "sde.migration.divergence",
55
+ # One backfill chunk landed in a copy and the marker moved. Emitted per chunk rather than
56
+ # per run, because a backfill is the one operation here that can take hours and an operator
57
+ # watching it needs to see it move. The marker itself is a row count and never a key value:
58
+ # a log line is the last place a client's own data should turn up.
59
+ "sde.migration.backfill_progress",
60
+ # The orderbook engine only. A write there is invisible to a query until flush(), and
61
+ # flush() in local mode tears the engine down and reopens it - 3.4 ms measured. Reads flush
62
+ # lazily, so this line is where the cost shows up and how many rows it bought.
63
+ "sde.orderbook.flushed",
64
+ # A ClickHouse server whose analyzer does not answer EXPLAIN QUERY TREE, so the table
65
+ # names behind a plan come from EXPLAIN ESTIMATE instead. Not a failure - the plan is
66
+ # returned - but the ReplacingMergeTree finding is a property of a table, so an operator
67
+ # who was expecting one and did not get it should be able to see why.
68
+ "sde.explain.no_query_tree",
69
+ "sde.internal.error",
70
+ # Telemetry. `window_closed` fires when a period ends and its aggregate is buffered for
71
+ # the application to collect; it was called `window_sent` until somebody read the log to
72
+ # check whether this library phones home and found a line saying it had. Nothing here
73
+ # sends anything - there is no channel, no address and no dependency that could open one -
74
+ # so the old name described an event that cannot happen, and a closed vocabulary exists so
75
+ # that an alert built on a name keeps working, not so that a wrong name outlives the
76
+ # reading of it. Renamed while the cost of renaming is zero.
77
+ "sde.telemetry.window_closed",
78
+ # dropped fires when the buffer is full and the oldest window is discarded - telemetry is
79
+ # the thing that gets lost when we run out of room, never an operation.
80
+ "sde.telemetry.dropped",
81
+ }
82
+ )
83
+
84
+
85
+ def log(event: str, /, **fields: Any) -> None:
86
+ """Emit one structured event.
87
+
88
+ Nothing here can raise. The handler belongs to the client's application: it might write to a
89
+ socket that just closed, or be a custom formatter with a bug in it. A library that fails a
90
+ request because its own log line could not be written has no business being in somebody else's
91
+ process.
92
+
93
+ An unknown event name used to raise, on the argument that it is our programming error and the
94
+ test suite is where it should surface. **Measured, it did not.** The names that go unnoticed
95
+ are the ones on rare paths, and a rare path is precisely where no test goes:
96
+ ``sde.explain.no_query_tree`` shipped absent from the vocabulary behind a ``pragma: no cover``,
97
+ and a client on a ClickHouse without the new analyzer got ``ValueError: ... is not a known
98
+ event. Add it to EVENTS ...`` - a note addressed to us, delivered to them, instead of the query
99
+ plan, on the one path written to degrade gracefully.
100
+
101
+ So the guard moved to where it can be total: a static test reads every ``log()`` call site in
102
+ the package and requires the vocabulary and the code to agree in both directions. That fails in
103
+ CI on every path rather than in production on one. Here an unknown name is counted through the
104
+ same registry as a broken handler and nothing is emitted - so the vocabulary stays closed, a
105
+ client can still see that it happened, and it costs them no call.
106
+ """
107
+ if event not in EVENTS:
108
+ _count("logging.unknown_event")
109
+ return
110
+ # Checked before anything is built. Routing logs on every operation and an unconfigured logger
111
+ # is
112
+ # the normal case in production, so the cost of a log line nobody consumes has to be one
113
+ # comparison rather than a dictionary construction.
114
+ if not logger.isEnabledFor(logging.INFO):
115
+ return
116
+ try:
117
+ logger.info(event, extra={"sde_event": event, "sde_fields": fields})
118
+ except BaseException as exc:
119
+ if isinstance(exc, (KeyboardInterrupt, SystemExit)):
120
+ raise
121
+ _count("logging.emit")
122
+
123
+
124
+ def _count(what: str) -> None:
125
+ """Record one of our own failures without going through :func:`~sde.internal.guard`.
126
+
127
+ guard() reports through log(), so calling it from here would be a loop. Same registry, by hand.
128
+ """
129
+ from .internal import _failures, _lock
130
+
131
+ with _lock:
132
+ _failures[what] = _failures.get(what, 0) + 1