trigerr-logging 0.4.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.
- trigerr_logging/__init__.py +568 -0
- trigerr_logging/health.py +251 -0
- trigerr_logging/jobs.py +73 -0
- trigerr_logging-0.4.1.dist-info/METADATA +42 -0
- trigerr_logging-0.4.1.dist-info/RECORD +8 -0
- trigerr_logging-0.4.1.dist-info/WHEEL +5 -0
- trigerr_logging-0.4.1.dist-info/licenses/LICENSE +21 -0
- trigerr_logging-0.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""Shared structured (JSON-line) logging for Trigerr services and the SDK itself.
|
|
2
|
+
|
|
3
|
+
Kept schema-compatible with the legacy logging module it replaced, apart from
|
|
4
|
+
this docstring and the contextvar names: both write into one Loki, so a query
|
|
5
|
+
written against either has to work against the other. That compatibility is
|
|
6
|
+
pinned by tests/fixtures/envelope.golden.json rather than by convention — the
|
|
7
|
+
same file exists on both sides, and a drift in either fails that side's tests.
|
|
8
|
+
(An earlier "keep the two in sync" comment did not survive contact with
|
|
9
|
+
reality; the fixture is the enforcement.)
|
|
10
|
+
|
|
11
|
+
Stdlib-only by design: this module is imported by every trigerr-* service repo
|
|
12
|
+
but is also shipped in the pip-installable SDK, so it must not assume any
|
|
13
|
+
platform infrastructure. Where log lines end up (a file, journald, stdout) is
|
|
14
|
+
decided by the caller; a separate log-shipping agent (Grafana Alloy) is
|
|
15
|
+
responsible for getting them into centralized storage.
|
|
16
|
+
|
|
17
|
+
Every line carries the same identity block (IDENTITY_FIELDS) in the same
|
|
18
|
+
order, blank where unknown, so a consumer can group by repo, host, market or
|
|
19
|
+
environment without knowing which service wrote the line. Domain fields
|
|
20
|
+
(request_id, mode, segment, job, ...) appear only on the repos that have
|
|
21
|
+
those concepts — see _DOMAIN_ORDER.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
from trigerr_logging import get_logger, bind, bound, clear_context
|
|
25
|
+
|
|
26
|
+
logger = get_logger("oms", log_file="logs/oms.jsonl",
|
|
27
|
+
service="trigerr-oms")
|
|
28
|
+
bind(request_id=request_id, tenant=tenant)
|
|
29
|
+
logger.info("order placed", extra={"order_id": order_id})
|
|
30
|
+
|
|
31
|
+
with bound(request_id=request_id):
|
|
32
|
+
place_live_order(...)
|
|
33
|
+
|
|
34
|
+
`service` names the repo and is passed by each repo's create_logger from its
|
|
35
|
+
own SERVICE constant; `name` is the per-script or per-request logger name and
|
|
36
|
+
appears as `logger`. Set LOG_FORMAT=text for a human-readable formatter
|
|
37
|
+
during local development / tailing; the default is single-line JSON.
|
|
38
|
+
"""
|
|
39
|
+
__version__ = "0.4.1"
|
|
40
|
+
|
|
41
|
+
import contextvars
|
|
42
|
+
import json
|
|
43
|
+
import logging
|
|
44
|
+
import logging.handlers
|
|
45
|
+
import os
|
|
46
|
+
import re
|
|
47
|
+
import socket
|
|
48
|
+
import sys
|
|
49
|
+
from contextlib import contextmanager
|
|
50
|
+
|
|
51
|
+
# Keys that are part of the stdlib LogRecord and must not be treated as
|
|
52
|
+
# user-supplied `extra` fields when flattening a record to JSON.
|
|
53
|
+
_STD_RECORD_KEYS = frozenset(logging.makeLogRecord({}).__dict__.keys()) | {"message", "asctime"}
|
|
54
|
+
|
|
55
|
+
# Bumped when IDENTITY_FIELDS changes. Emitted on every line so a format
|
|
56
|
+
# change stays detectable across a fleet that is mid-rollout — repos and
|
|
57
|
+
# instances pick up a new SDK at different times, and without this a query
|
|
58
|
+
# cannot tell a line that predates the change from one that lost a field.
|
|
59
|
+
SCHEMA_VERSION = "1"
|
|
60
|
+
|
|
61
|
+
# The identity block: emitted on EVERY line, in this order, blank when
|
|
62
|
+
# unknown. These answer "which repo, which part of it, which host, which
|
|
63
|
+
# market, which environment, and what kind of line" — everything needed to
|
|
64
|
+
# find and group logs without knowing in advance which repo wrote them. A
|
|
65
|
+
# blank here means misconfigured, not "not applicable": every one of these
|
|
66
|
+
# applies to every process.
|
|
67
|
+
#
|
|
68
|
+
# event/status are padded despite being blank on most lines because they are
|
|
69
|
+
# the operational vocabulary — with them guaranteed present, one dashboard
|
|
70
|
+
# panel works unchanged against every service.
|
|
71
|
+
IDENTITY_FIELDS = (
|
|
72
|
+
"ts", "level", "schema", "service", "component", "env", "host",
|
|
73
|
+
"tenant", "market", "event", "status", "logger", "message",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Computed from the record itself; never overridable by bind()/extra=.
|
|
77
|
+
_INTRINSIC_FIELDS = frozenset({"ts", "level", "schema", "logger", "message"})
|
|
78
|
+
|
|
79
|
+
# Distinguishes "key absent" from "key present with a falsy value" when
|
|
80
|
+
# resolving a field: bind(market="") must win over MARKET=IN, the same way
|
|
81
|
+
# bind(market="IN") does.
|
|
82
|
+
_MISSING = object()
|
|
83
|
+
|
|
84
|
+
# Identity fields that fall back to an environment variable before their
|
|
85
|
+
# blank default. bind()/extra= still win over the environment — a per-request
|
|
86
|
+
# bind(market="IN") must beat a process-wide MARKET=US.
|
|
87
|
+
_ENV_FIELDS = {
|
|
88
|
+
"service": "SERVICE",
|
|
89
|
+
"env": "ENVIRONMENT",
|
|
90
|
+
"host": "HOSTNAME",
|
|
91
|
+
"tenant": "TENANT",
|
|
92
|
+
"market": "MARKET",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Every deployment that ships logs is production; dev is the exception and
|
|
96
|
+
# generally runs LOG_FORMAT=text without shipping at all. Defaulting to ""
|
|
97
|
+
# instead would leave the field blank on every host forever, which is worse
|
|
98
|
+
# than a wrong-but-visible value.
|
|
99
|
+
_DEFAULT_ENV = "prod"
|
|
100
|
+
|
|
101
|
+
# Domain fields: reserved names with fixed meanings, emitted ONLY when the
|
|
102
|
+
# call site supplies them. Deliberately not blank-padded — a `request_id: ""`
|
|
103
|
+
# on a data-collection line would be indistinguishable from a trading line
|
|
104
|
+
# that failed to populate one, and data collection has no such concept at
|
|
105
|
+
# all. Ordered here purely so output stays readable; anything not listed is
|
|
106
|
+
# still emitted, just afterwards.
|
|
107
|
+
_DOMAIN_ORDER = (
|
|
108
|
+
# Trading: trading-strategies, backtesting-strategies, orders-api,
|
|
109
|
+
# brokers-automation. request_id is the Mongo {mode}_requests._id and is
|
|
110
|
+
# never generated — batch work uses run_id below, so a query for one can
|
|
111
|
+
# never pick up the other.
|
|
112
|
+
"request_id", "mode", "strategy", "strategy_id", "user_id",
|
|
113
|
+
"order_id", "broker", "credential_id", "idempotency_key",
|
|
114
|
+
# Data collection.
|
|
115
|
+
"segment", "session", "symbol",
|
|
116
|
+
# Batch jobs (see jobs.job_run).
|
|
117
|
+
"job", "run_id", "duration_s",
|
|
118
|
+
# Long-running processes (see health.HealthReporter).
|
|
119
|
+
"pid", "uptime_s",
|
|
120
|
+
"exc",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
_LOG_CTX: "contextvars.ContextVar[dict]" = contextvars.ContextVar("trigerr_log_ctx", default=None)
|
|
124
|
+
_CURRENT_LOGGER: "contextvars.ContextVar[logging.Logger]" = contextvars.ContextVar("trigerr_current_logger", default=None)
|
|
125
|
+
|
|
126
|
+
_loggers_configured = set()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_current_logger():
|
|
130
|
+
"""Returns the logger most recently passed to redirect_stdout_to() in
|
|
131
|
+
this context, or None if none has been set yet. Lets shared helper
|
|
132
|
+
modules that receive no logger argument (e.g. sts_common.py, called from
|
|
133
|
+
many different per-request scripts) still log at the correct level and
|
|
134
|
+
with structured extra= fields into whichever logger the caller is
|
|
135
|
+
currently using, instead of only being able to print()."""
|
|
136
|
+
return _CURRENT_LOGGER.get()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# --------------------------------------------------------------------------
|
|
140
|
+
# Context propagation
|
|
141
|
+
# --------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def bind(**ctx):
|
|
144
|
+
"""Merge keys into the current logging context (copy-on-write). None
|
|
145
|
+
values are dropped so callers can pass optional fields unconditionally,
|
|
146
|
+
e.g. bind(request_id=maybe_none)."""
|
|
147
|
+
current = dict(_LOG_CTX.get() or {})
|
|
148
|
+
for k, v in ctx.items():
|
|
149
|
+
if v is not None:
|
|
150
|
+
current[k] = v
|
|
151
|
+
_LOG_CTX.set(current)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def clear_context():
|
|
155
|
+
"""Reset the logging context to empty. Call at the start of each unit of
|
|
156
|
+
work (Celery task, HTTP request) so context never leaks across tasks
|
|
157
|
+
sharing a worker/thread — critical for `mode` (paper vs. real-money),
|
|
158
|
+
which must never bleed from one request's log lines into another's."""
|
|
159
|
+
_LOG_CTX.set({})
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_context():
|
|
163
|
+
"""Read-only copy of the current logging context."""
|
|
164
|
+
return dict(_LOG_CTX.get() or {})
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@contextmanager
|
|
168
|
+
def bound(**ctx):
|
|
169
|
+
"""Bind keys for the duration of a with-block, restoring the prior
|
|
170
|
+
context on exit (including on exception)."""
|
|
171
|
+
token_ctx = dict(_LOG_CTX.get() or {})
|
|
172
|
+
bind(**ctx)
|
|
173
|
+
try:
|
|
174
|
+
yield
|
|
175
|
+
finally:
|
|
176
|
+
_LOG_CTX.set(token_ctx)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --------------------------------------------------------------------------
|
|
180
|
+
# Scrubbing
|
|
181
|
+
# --------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
REDACT_KEYS = frozenset({
|
|
184
|
+
"api_key", "apikey", "x_api_key", "x-api-key", "api_secret",
|
|
185
|
+
"access_token", "refresh_token", "session_token", "session_token_key",
|
|
186
|
+
"auth_token", "token", "request_token",
|
|
187
|
+
"password", "passwd", "pin", "totp", "secret", "client_secret",
|
|
188
|
+
"authorization", "jwt", "aws_secret_access_key", "s3_access_secret",
|
|
189
|
+
"s3_secret", "private_key",
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
_REDACTED = "[REDACTED]"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _normalize_key(key):
|
|
196
|
+
return str(key).strip().lower().replace("-", "_")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _scrub_value(value, depth):
|
|
200
|
+
if depth <= 0:
|
|
201
|
+
return value
|
|
202
|
+
if isinstance(value, dict):
|
|
203
|
+
out = {}
|
|
204
|
+
for k, v in value.items():
|
|
205
|
+
if _normalize_key(k) in REDACT_KEYS:
|
|
206
|
+
out[k] = _REDACTED
|
|
207
|
+
else:
|
|
208
|
+
out[k] = _scrub_value(v, depth - 1)
|
|
209
|
+
return out
|
|
210
|
+
if isinstance(value, (list, tuple)):
|
|
211
|
+
return [_scrub_value(v, depth - 1) for v in value]
|
|
212
|
+
return value
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# Matches key=value, 'key': 'value', "key": "value" style occurrences of a
|
|
216
|
+
# redacted key inside a formatted message string, e.g. an f-string dump of a
|
|
217
|
+
# request dict. Value is any run of non-comma/non-brace/non-quote characters,
|
|
218
|
+
# or a quoted string.
|
|
219
|
+
_MSG_KEY_PATTERN = re.compile(
|
|
220
|
+
r"""(?P<prefix>['"]?\b(?:%s)\b['"]?\s*[:=]\s*)(?P<value>'[^']*'|"[^"]*"|[^,}\]\s]+)"""
|
|
221
|
+
% "|".join(re.escape(k) for k in sorted(REDACT_KEYS, key=len, reverse=True)),
|
|
222
|
+
re.IGNORECASE,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _scrub_message(message):
|
|
227
|
+
return _MSG_KEY_PATTERN.sub(lambda m: m.group("prefix") + _REDACTED, message)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class ScrubFilter(logging.Filter):
|
|
231
|
+
"""Redacts credential-shaped values from both the rendered message and
|
|
232
|
+
any extra/context fields before a record is formatted. Defense-in-depth
|
|
233
|
+
only — call sites that handle real credentials must still avoid logging
|
|
234
|
+
them in the first place."""
|
|
235
|
+
|
|
236
|
+
def filter(self, record):
|
|
237
|
+
try:
|
|
238
|
+
record.msg = _scrub_message(record.getMessage())
|
|
239
|
+
record.args = None
|
|
240
|
+
except Exception:
|
|
241
|
+
pass
|
|
242
|
+
for key in list(record.__dict__.keys()):
|
|
243
|
+
if key in _STD_RECORD_KEYS:
|
|
244
|
+
continue
|
|
245
|
+
if _normalize_key(key) in REDACT_KEYS:
|
|
246
|
+
setattr(record, key, _REDACTED)
|
|
247
|
+
else:
|
|
248
|
+
setattr(record, key, _scrub_value(getattr(record, key), depth=4))
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# --------------------------------------------------------------------------
|
|
253
|
+
# Formatting
|
|
254
|
+
# --------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
def _format_time_iso(record):
|
|
257
|
+
import datetime
|
|
258
|
+
dt = datetime.datetime.fromtimestamp(record.created, tz=datetime.timezone.utc)
|
|
259
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class JsonFormatter(logging.Formatter):
|
|
263
|
+
"""Renders a record as the identity block (always, in a fixed order,
|
|
264
|
+
blank when unknown) followed by whatever domain fields the call site
|
|
265
|
+
supplied.
|
|
266
|
+
|
|
267
|
+
The identity block is what makes one Alloy config and one dashboard
|
|
268
|
+
panel work across every repo: a consumer can rely on those keys being
|
|
269
|
+
present without knowing which service wrote the line. Domain fields are
|
|
270
|
+
not padded — see _DOMAIN_ORDER."""
|
|
271
|
+
|
|
272
|
+
def __init__(self, service, defaults=None):
|
|
273
|
+
super().__init__()
|
|
274
|
+
self.service = service
|
|
275
|
+
# Per-logger identity values, e.g. {"segment": "options",
|
|
276
|
+
# "component": "stream_ticks.py"}. Held on the formatter rather than
|
|
277
|
+
# bound into the context on purpose: a new thread starts with an
|
|
278
|
+
# EMPTY contextvar context, and the data-collection repos run their
|
|
279
|
+
# tick handlers and processor workers on their own threads — a
|
|
280
|
+
# bind() in the main thread would silently vanish from exactly the
|
|
281
|
+
# lines that carry the data. A formatter attribute is read-only and
|
|
282
|
+
# shared, so every thread sees it.
|
|
283
|
+
self.defaults = dict(defaults or {})
|
|
284
|
+
|
|
285
|
+
def _identity_default(self, field, record):
|
|
286
|
+
if field == "ts":
|
|
287
|
+
return _format_time_iso(record)
|
|
288
|
+
if field == "level":
|
|
289
|
+
return record.levelname
|
|
290
|
+
if field == "schema":
|
|
291
|
+
return SCHEMA_VERSION
|
|
292
|
+
if field == "logger":
|
|
293
|
+
return record.name
|
|
294
|
+
if field == "message":
|
|
295
|
+
return record.getMessage()
|
|
296
|
+
# Below extra=/bind() (a per-line value must still win) but above the
|
|
297
|
+
# environment: a per-logger default is more specific than a
|
|
298
|
+
# process-wide env var.
|
|
299
|
+
if field in self.defaults:
|
|
300
|
+
return self.defaults[field]
|
|
301
|
+
if field == "service":
|
|
302
|
+
# The constructor argument is the fallback, not the authority:
|
|
303
|
+
# bind(service=...) and SERVICE both outrank it, handled by the
|
|
304
|
+
# precedence chain in format().
|
|
305
|
+
return self.service or ""
|
|
306
|
+
if field == "host":
|
|
307
|
+
# HOSTNAME is a shell variable and is frequently not exported to
|
|
308
|
+
# a Python process, so falling back to the env var alone would
|
|
309
|
+
# leave this blank on most hosts.
|
|
310
|
+
return os.environ.get("HOSTNAME") or socket.gethostname()
|
|
311
|
+
if field == "env":
|
|
312
|
+
return os.environ.get("ENVIRONMENT") or _DEFAULT_ENV
|
|
313
|
+
env_var = _ENV_FIELDS.get(field)
|
|
314
|
+
if env_var:
|
|
315
|
+
return os.environ.get(env_var) or ""
|
|
316
|
+
return ""
|
|
317
|
+
|
|
318
|
+
def format(self, record):
|
|
319
|
+
ctx = get_context()
|
|
320
|
+
extras = {k: v for k, v in record.__dict__.items() if k not in _STD_RECORD_KEYS}
|
|
321
|
+
|
|
322
|
+
out = {}
|
|
323
|
+
for field in IDENTITY_FIELDS:
|
|
324
|
+
# Consume the key from BOTH sources whichever one wins: the tail
|
|
325
|
+
# below is built from what's left, and a leftover copy there
|
|
326
|
+
# would overwrite the identity value via out.update(remaining)
|
|
327
|
+
# — silently reinstating a stale bound field over the extra=
|
|
328
|
+
# that was meant to override it for this one line.
|
|
329
|
+
from_extras = extras.pop(field, _MISSING)
|
|
330
|
+
from_ctx = ctx.pop(field, _MISSING)
|
|
331
|
+
|
|
332
|
+
if field in _INTRINSIC_FIELDS:
|
|
333
|
+
# Computed from the record; a call site cannot displace it.
|
|
334
|
+
# (stdlib already rejects extra={"message"/"asctime": ...},
|
|
335
|
+
# but "level", "logger" and "ts" are not names it guards.)
|
|
336
|
+
out[field] = self._identity_default(field, record)
|
|
337
|
+
elif from_extras is not _MISSING:
|
|
338
|
+
out[field] = from_extras
|
|
339
|
+
elif from_ctx is not _MISSING:
|
|
340
|
+
out[field] = from_ctx
|
|
341
|
+
else:
|
|
342
|
+
out[field] = self._identity_default(field, record)
|
|
343
|
+
|
|
344
|
+
if record.exc_info:
|
|
345
|
+
extras["exc"] = self.formatException(record.exc_info)
|
|
346
|
+
|
|
347
|
+
remaining = dict(ctx)
|
|
348
|
+
remaining.update(extras)
|
|
349
|
+
|
|
350
|
+
# Formatter defaults for non-identity fields (segment, ...). Blank
|
|
351
|
+
# ones are skipped rather than emitted: domain fields are never
|
|
352
|
+
# padded, so a top-level script with no segment must have no segment
|
|
353
|
+
# key at all, not segment="".
|
|
354
|
+
for key, value in self.defaults.items():
|
|
355
|
+
if key not in out and key not in remaining and value not in ("", None):
|
|
356
|
+
remaining[key] = value
|
|
357
|
+
|
|
358
|
+
for field in _DOMAIN_ORDER:
|
|
359
|
+
if field in remaining:
|
|
360
|
+
out[field] = remaining.pop(field)
|
|
361
|
+
|
|
362
|
+
# Free-form tail: anything the call site passed that isn't a reserved
|
|
363
|
+
# name. Emitted unchanged so per-strategy payloads keep working.
|
|
364
|
+
out.update(remaining)
|
|
365
|
+
|
|
366
|
+
return json.dumps(out, default=str)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class TextFormatter(logging.Formatter):
|
|
370
|
+
"""Human-readable formatter for local dev / `tail -f` — selected via
|
|
371
|
+
LOG_FORMAT=text. Bound context (request_id, mode, strategy, ...) is
|
|
372
|
+
appended so it stays visible without parsing JSON."""
|
|
373
|
+
|
|
374
|
+
def __init__(self):
|
|
375
|
+
super().__init__(fmt="%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
|
376
|
+
datefmt="%Y-%m-%d %H:%M:%S")
|
|
377
|
+
|
|
378
|
+
def format(self, record):
|
|
379
|
+
base = super().format(record)
|
|
380
|
+
ctx = get_context()
|
|
381
|
+
if ctx:
|
|
382
|
+
ctx_str = " ".join(f"{k}={v}" for k, v in ctx.items())
|
|
383
|
+
base = f"{base} [{ctx_str}]"
|
|
384
|
+
if record.exc_info:
|
|
385
|
+
base = f"{base}\n{self.formatException(record.exc_info)}"
|
|
386
|
+
return base
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _make_formatter(service, defaults=None):
|
|
390
|
+
if os.environ.get("LOG_FORMAT", "json").lower() == "text":
|
|
391
|
+
return TextFormatter()
|
|
392
|
+
return JsonFormatter(service, defaults=defaults)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# --------------------------------------------------------------------------
|
|
396
|
+
# Logger factory
|
|
397
|
+
# --------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
def get_logger(name=None, log_file=None, stream=True, level=logging.INFO,
|
|
400
|
+
rotate_when="midnight", backup_count=14, service=None,
|
|
401
|
+
defaults=None):
|
|
402
|
+
"""Return a logger named `name` configured to emit structured log lines.
|
|
403
|
+
Safe to call more than once for the same name — handlers are only
|
|
404
|
+
attached the first time.
|
|
405
|
+
|
|
406
|
+
name: the logger name, which appears as `logger` on every line. Callers
|
|
407
|
+
pass a per-script or per-request value here (e.g.
|
|
408
|
+
"syss_sha_eod-vt-<request_id>"). Defaults to `service` so the
|
|
409
|
+
pre-split call style — get_logger(service="oms", ...), used across
|
|
410
|
+
project-trigerr — keeps working unchanged.
|
|
411
|
+
service: which repo the line came from, e.g. "trigerr-data-collection".
|
|
412
|
+
Falls back to the SERVICE environment variable, then to `name`.
|
|
413
|
+
These were previously the same argument, so every line reported a
|
|
414
|
+
per-request logger name as its service and no query could group by
|
|
415
|
+
repo; each repo's create_logger now passes its own SERVICE constant.
|
|
416
|
+
defaults: identity fields every line from this logger should carry, e.g.
|
|
417
|
+
{"segment": "options", "component": "stream_ticks.py"}. Use this
|
|
418
|
+
rather than bind() for values fixed at logger-creation time in a
|
|
419
|
+
process that spawns threads: a new thread starts with an empty
|
|
420
|
+
contextvar context, so a bind() in the main thread never reaches the
|
|
421
|
+
worker that does the logging.
|
|
422
|
+
log_file: path to a rotating log file (created, including parent dirs).
|
|
423
|
+
stream: also emit to sys.__stderr__ (not sys.stderr — under Celery,
|
|
424
|
+
stdout/stderr are wrapped by a LoggingProxy, and if this handler's
|
|
425
|
+
output were itself redirected back into logging via
|
|
426
|
+
redirect_stdout_to() below, writing to the wrapped stream would
|
|
427
|
+
recurse infinitely).
|
|
428
|
+
"""
|
|
429
|
+
name = name or service
|
|
430
|
+
if not name:
|
|
431
|
+
raise TypeError("get_logger() needs a name (or a service to name the logger after)")
|
|
432
|
+
|
|
433
|
+
logger = logging.getLogger(name)
|
|
434
|
+
logger.setLevel(level)
|
|
435
|
+
logger.propagate = False
|
|
436
|
+
|
|
437
|
+
if name in _loggers_configured:
|
|
438
|
+
return logger
|
|
439
|
+
_loggers_configured.add(name)
|
|
440
|
+
|
|
441
|
+
formatter = _make_formatter(service or os.environ.get("SERVICE") or name,
|
|
442
|
+
defaults=defaults)
|
|
443
|
+
scrub = ScrubFilter()
|
|
444
|
+
|
|
445
|
+
if log_file:
|
|
446
|
+
os.makedirs(os.path.dirname(log_file) or ".", exist_ok=True)
|
|
447
|
+
file_handler = logging.handlers.TimedRotatingFileHandler(
|
|
448
|
+
log_file, when=rotate_when, backupCount=backup_count, utc=True)
|
|
449
|
+
file_handler.setFormatter(formatter)
|
|
450
|
+
file_handler.addFilter(scrub)
|
|
451
|
+
logger.addHandler(file_handler)
|
|
452
|
+
|
|
453
|
+
if stream:
|
|
454
|
+
stream_handler = logging.StreamHandler(sys.__stderr__)
|
|
455
|
+
stream_handler.setFormatter(formatter)
|
|
456
|
+
stream_handler.addFilter(scrub)
|
|
457
|
+
logger.addHandler(stream_handler)
|
|
458
|
+
|
|
459
|
+
return logger
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class _StdoutToLogger:
|
|
463
|
+
"""File-like shim: routes writes to a logger, dropping whitespace-only
|
|
464
|
+
writes (print() always emits a trailing '\\n' as a separate write)."""
|
|
465
|
+
|
|
466
|
+
def __init__(self, logger, level=logging.INFO):
|
|
467
|
+
self._logger = logger
|
|
468
|
+
self._level = level
|
|
469
|
+
|
|
470
|
+
def write(self, message):
|
|
471
|
+
text = message.strip()
|
|
472
|
+
if text:
|
|
473
|
+
self._logger.log(self._level, text)
|
|
474
|
+
|
|
475
|
+
def flush(self):
|
|
476
|
+
pass
|
|
477
|
+
|
|
478
|
+
def isatty(self):
|
|
479
|
+
# Never a real terminal; libraries that probe this before deciding
|
|
480
|
+
# whether to color their output must get a real bool, not an
|
|
481
|
+
# AttributeError.
|
|
482
|
+
return False
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def redirect_stdout_to(logger, level=logging.INFO):
|
|
486
|
+
"""Route sys.stdout.write() to the given logger, so existing print()
|
|
487
|
+
call sites are captured without editing them. Replaces the
|
|
488
|
+
`sys.stdout.write = logger.info` pattern used previously, which also
|
|
489
|
+
logged a blank record for every bare print()'s trailing newline."""
|
|
490
|
+
sys.stdout = _StdoutToLogger(logger, level=level)
|
|
491
|
+
_CURRENT_LOGGER.set(logger)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def resolve_log_file(file_name, service, default_log_dir):
|
|
495
|
+
"""LOG_DIR env var (falling back to default_log_dir) + service
|
|
496
|
+
subdirectory + the caller's basename only. Log files always land in one
|
|
497
|
+
directory this way — without it a log is written wherever the caller
|
|
498
|
+
pointed, which Alloy does not tail, so the service looks healthy while
|
|
499
|
+
its logs reach nothing."""
|
|
500
|
+
log_dir = os.environ.get("LOG_DIR") or default_log_dir
|
|
501
|
+
return os.path.join(log_dir, service, os.path.basename(file_name))
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def resolve_segmented_log_file(file_name, service, default_log_dir, repo_root):
|
|
505
|
+
"""Data-collection variant of resolve_log_file(): several repos run the
|
|
506
|
+
same script name (stream_ticks.py) under multiple segment directories
|
|
507
|
+
(options/, futures/, stocks/, indices/), which would collide on one
|
|
508
|
+
flattened basename. Returns (log_file_path, segment, component) —
|
|
509
|
+
segment/component are meant to be passed as the formatter's defaults=,
|
|
510
|
+
since the filename that carried them is gone once Alloy tails the file
|
|
511
|
+
into Loki. file_name is expected in the form
|
|
512
|
+
f"{cwd}/logs/{name}.log", where cwd is the segment directory or the
|
|
513
|
+
repo root for top-level scripts."""
|
|
514
|
+
log_dir = os.environ.get("LOG_DIR") or default_log_dir
|
|
515
|
+
base = os.path.basename(file_name)
|
|
516
|
+
segment_dir = os.path.dirname(os.path.dirname(os.path.abspath(file_name)))
|
|
517
|
+
segment = "" if segment_dir == repo_root else os.path.basename(segment_dir)
|
|
518
|
+
stem = base[:-4] if base.endswith(".log") else base
|
|
519
|
+
component = f"{stem.split('-')[0]}.py"
|
|
520
|
+
if segment:
|
|
521
|
+
base = f"{segment}_{base}"
|
|
522
|
+
return os.path.join(log_dir, service, base), segment, component
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def resolve_log_level(default=logging.INFO):
|
|
526
|
+
"""LOG_LEVEL env var (DEBUG/INFO/WARNING/ERROR/CRITICAL), resolved to a
|
|
527
|
+
logging constant. Unset or unrecognized falls back to `default`."""
|
|
528
|
+
name = os.environ.get("LOG_LEVEL", "").upper()
|
|
529
|
+
if not name:
|
|
530
|
+
return default
|
|
531
|
+
level = getattr(logging, name, None)
|
|
532
|
+
if isinstance(level, int) and not isinstance(level, bool):
|
|
533
|
+
return level
|
|
534
|
+
return default
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
_service_name_warned = set()
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def check_service_name_drift(service, logger):
|
|
541
|
+
"""Warn once if SERVICE_NAME disagrees with the repo's hardcoded SERVICE constant."""
|
|
542
|
+
env_value = os.environ.get("SERVICE_NAME")
|
|
543
|
+
if env_value and env_value != service and service not in _service_name_warned:
|
|
544
|
+
_service_name_warned.add(service)
|
|
545
|
+
logger.warning(
|
|
546
|
+
"SERVICE_NAME env var disagrees with this repo's SERVICE constant "
|
|
547
|
+
"— SERVICE_NAME is documentation only and is ignored; fix .env or "
|
|
548
|
+
"the constant so they agree",
|
|
549
|
+
extra={"service_name_env": env_value, "service": service},
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def create_service_logger(file_name, name, service, default_log_dir, stream=False,
|
|
554
|
+
default_level=logging.INFO, defaults=None,
|
|
555
|
+
rotate_when="midnight", backup_count=14):
|
|
556
|
+
"""One-call replacement for resolve_log_file + resolve_log_level + get_logger."""
|
|
557
|
+
log_file = resolve_log_file(file_name, service, default_log_dir)
|
|
558
|
+
level = resolve_log_level(default=default_level)
|
|
559
|
+
logger = get_logger(name, log_file=log_file, stream=stream, level=level,
|
|
560
|
+
service=service, defaults=defaults,
|
|
561
|
+
rotate_when=rotate_when, backup_count=backup_count)
|
|
562
|
+
check_service_name_drift(service, logger)
|
|
563
|
+
return logger
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
from trigerr_logging.health import HealthReporter
|
|
567
|
+
from trigerr_logging.jobs import job_run, new_run_id
|
|
568
|
+
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Health reporting and heartbeat emitter for Trigerr data collection processes.
|
|
2
|
+
|
|
3
|
+
Provides HealthReporter: a thread-safe counter bag and background daemon thread
|
|
4
|
+
that periodically emits structured heartbeat events to Loki/Grafana.
|
|
5
|
+
"""
|
|
6
|
+
import atexit
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HealthReporter:
|
|
14
|
+
"""Emits process_start / heartbeat / process_stop for a long-running collector.
|
|
15
|
+
|
|
16
|
+
Holds its logger directly and guards counters with a Lock: the emitter runs on
|
|
17
|
+
its own daemon thread and callers bump counters from websocket/worker threads,
|
|
18
|
+
where contextvars are empty and a contextvar-based logger lookup returns None.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, logger, component, segment, interval_s=60,
|
|
22
|
+
session_fn=None, stall_after_s=None):
|
|
23
|
+
self.logger = logger
|
|
24
|
+
self.component = component
|
|
25
|
+
self.segment = segment
|
|
26
|
+
self.interval_s = interval_s
|
|
27
|
+
self.session_fn = session_fn
|
|
28
|
+
self.stall_after_s = stall_after_s
|
|
29
|
+
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
self._running = False
|
|
32
|
+
self._stopped = False
|
|
33
|
+
self._thread = None
|
|
34
|
+
self._start_time = None
|
|
35
|
+
self._last_activity_time = None
|
|
36
|
+
self._last_tick_time = None
|
|
37
|
+
self._last_message_time = None
|
|
38
|
+
self._last_candle_time = None
|
|
39
|
+
|
|
40
|
+
# Per-interval counters (reset on heartbeat)
|
|
41
|
+
self._ticks = 0
|
|
42
|
+
self._messages = 0
|
|
43
|
+
self._candles = 0
|
|
44
|
+
self._errors = 0
|
|
45
|
+
self._reconnects = 0
|
|
46
|
+
self._zero_sub_publishes = 0
|
|
47
|
+
|
|
48
|
+
# Persistent gauges
|
|
49
|
+
self._gauges = {}
|
|
50
|
+
|
|
51
|
+
def start(self):
|
|
52
|
+
with self._lock:
|
|
53
|
+
if self._running:
|
|
54
|
+
return
|
|
55
|
+
self._running = True
|
|
56
|
+
now = time.time()
|
|
57
|
+
self._start_time = now
|
|
58
|
+
self._last_activity_time = now
|
|
59
|
+
self._last_tick_time = now
|
|
60
|
+
self._last_message_time = now
|
|
61
|
+
self._last_candle_time = now
|
|
62
|
+
|
|
63
|
+
pid = os.getpid()
|
|
64
|
+
market = os.environ.get("MARKET")
|
|
65
|
+
extra = {
|
|
66
|
+
"event": "process_start",
|
|
67
|
+
"component": self.component,
|
|
68
|
+
"segment": self.segment,
|
|
69
|
+
"pid": pid,
|
|
70
|
+
}
|
|
71
|
+
if market:
|
|
72
|
+
extra["market"] = market
|
|
73
|
+
|
|
74
|
+
self.logger.info("process starting", extra=extra)
|
|
75
|
+
|
|
76
|
+
self._thread = threading.Thread(target=self._run_loop, name="health-reporter", daemon=True)
|
|
77
|
+
self._thread.start()
|
|
78
|
+
|
|
79
|
+
atexit.register(self._atexit_cleanup)
|
|
80
|
+
self._install_signal_handler()
|
|
81
|
+
|
|
82
|
+
def _atexit_cleanup(self):
|
|
83
|
+
self.stop(reason="shutdown")
|
|
84
|
+
|
|
85
|
+
def _install_signal_handler(self):
|
|
86
|
+
""" atexit alone does not run on SIGTERM — only on normal interpreter
|
|
87
|
+
exit or an unhandled exception in the main thread — so a plain
|
|
88
|
+
`kill <pid>` (the usual way these long-running collectors are
|
|
89
|
+
stopped) would otherwise leave no process_stop record. Chains to
|
|
90
|
+
whatever handler was previously installed so other shutdown logic
|
|
91
|
+
still runs. """
|
|
92
|
+
try:
|
|
93
|
+
previous = signal.getsignal(signal.SIGTERM)
|
|
94
|
+
|
|
95
|
+
def _handler(signum, frame):
|
|
96
|
+
self.stop(reason="sigterm")
|
|
97
|
+
if callable(previous) and previous not in (signal.SIG_DFL, signal.SIG_IGN):
|
|
98
|
+
previous(signum, frame)
|
|
99
|
+
else:
|
|
100
|
+
raise SystemExit(0)
|
|
101
|
+
|
|
102
|
+
signal.signal(signal.SIGTERM, _handler)
|
|
103
|
+
except (ValueError, OSError):
|
|
104
|
+
# signal.signal() only works in the main thread — skip silently
|
|
105
|
+
# elsewhere rather than crashing the caller's startup path.
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
def mark_tick(self, n=1):
|
|
109
|
+
now = time.time()
|
|
110
|
+
with self._lock:
|
|
111
|
+
self._ticks += n
|
|
112
|
+
self._last_tick_time = now
|
|
113
|
+
self._last_activity_time = now
|
|
114
|
+
|
|
115
|
+
def mark_message(self, n=1):
|
|
116
|
+
now = time.time()
|
|
117
|
+
with self._lock:
|
|
118
|
+
self._messages += n
|
|
119
|
+
self._last_message_time = now
|
|
120
|
+
self._last_activity_time = now
|
|
121
|
+
|
|
122
|
+
def mark_candle(self, n=1):
|
|
123
|
+
""" Called by processors (process_ticks.py etc.) when a candle is
|
|
124
|
+
completed and written. Counts as activity for stall detection the
|
|
125
|
+
same way mark_tick()/mark_message() do — a processor that only
|
|
126
|
+
produces candles would otherwise never refresh
|
|
127
|
+
last_activity_time and would eventually report stalled=True even
|
|
128
|
+
while working correctly. """
|
|
129
|
+
now = time.time()
|
|
130
|
+
with self._lock:
|
|
131
|
+
self._candles += n
|
|
132
|
+
self._last_candle_time = now
|
|
133
|
+
self._last_activity_time = now
|
|
134
|
+
|
|
135
|
+
def mark_error(self, n=1):
|
|
136
|
+
with self._lock:
|
|
137
|
+
self._errors += n
|
|
138
|
+
|
|
139
|
+
def mark_reconnect(self):
|
|
140
|
+
with self._lock:
|
|
141
|
+
self._reconnects += 1
|
|
142
|
+
|
|
143
|
+
def mark_zero_subscribers(self):
|
|
144
|
+
with self._lock:
|
|
145
|
+
self._zero_sub_publishes += 1
|
|
146
|
+
|
|
147
|
+
def set_gauge(self, name, value):
|
|
148
|
+
with self._lock:
|
|
149
|
+
self._gauges[name] = value
|
|
150
|
+
|
|
151
|
+
def _run_loop(self):
|
|
152
|
+
while self._running:
|
|
153
|
+
time.sleep(self.interval_s)
|
|
154
|
+
if self._running:
|
|
155
|
+
self._emit_heartbeat()
|
|
156
|
+
|
|
157
|
+
def _emit_heartbeat(self):
|
|
158
|
+
now = time.time()
|
|
159
|
+
with self._lock:
|
|
160
|
+
ticks = self._ticks
|
|
161
|
+
messages = self._messages
|
|
162
|
+
candles = self._candles
|
|
163
|
+
errors = self._errors
|
|
164
|
+
reconnects = self._reconnects
|
|
165
|
+
zero_subs = self._zero_sub_publishes
|
|
166
|
+
gauges = dict(self._gauges)
|
|
167
|
+
|
|
168
|
+
# Reset per-interval counters
|
|
169
|
+
self._ticks = 0
|
|
170
|
+
self._messages = 0
|
|
171
|
+
self._candles = 0
|
|
172
|
+
self._errors = 0
|
|
173
|
+
self._reconnects = 0
|
|
174
|
+
self._zero_sub_publishes = 0
|
|
175
|
+
|
|
176
|
+
start_time = self._start_time
|
|
177
|
+
last_activity = self._last_activity_time
|
|
178
|
+
last_tick_time = self._last_tick_time
|
|
179
|
+
last_message_time = self._last_message_time
|
|
180
|
+
last_candle_time = self._last_candle_time
|
|
181
|
+
|
|
182
|
+
uptime_s = int(now - start_time) if start_time else 0
|
|
183
|
+
last_activity_age_s = round(now - last_activity, 1) if last_activity else 0.0
|
|
184
|
+
|
|
185
|
+
session = "unknown"
|
|
186
|
+
if self.session_fn:
|
|
187
|
+
try:
|
|
188
|
+
session = self.session_fn() or "unknown"
|
|
189
|
+
except Exception:
|
|
190
|
+
session = "unknown"
|
|
191
|
+
|
|
192
|
+
status = "ok"
|
|
193
|
+
if session == "open" and self.stall_after_s and last_activity_age_s > self.stall_after_s:
|
|
194
|
+
status = "stalled"
|
|
195
|
+
elif errors > 0 or reconnects > 0:
|
|
196
|
+
status = "degraded"
|
|
197
|
+
|
|
198
|
+
extra = {
|
|
199
|
+
"event": "health",
|
|
200
|
+
"component": self.component,
|
|
201
|
+
"segment": self.segment,
|
|
202
|
+
"status": status,
|
|
203
|
+
"session": session,
|
|
204
|
+
"uptime_s": uptime_s,
|
|
205
|
+
"errors": errors,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# Include tick or message fields depending on usage
|
|
209
|
+
if ticks > 0 or last_tick_time != start_time or "symbols_subscribed" in gauges:
|
|
210
|
+
extra["ticks"] = ticks
|
|
211
|
+
extra["last_tick_age_s"] = round(now - last_tick_time, 1) if last_tick_time else 0.0
|
|
212
|
+
extra["reconnects"] = reconnects
|
|
213
|
+
extra["zero_sub_publishes"] = zero_subs
|
|
214
|
+
|
|
215
|
+
if messages > 0 or last_message_time != start_time or "candles_written" in gauges:
|
|
216
|
+
extra["messages"] = messages
|
|
217
|
+
extra["last_message_age_s"] = round(now - last_message_time, 1) if last_message_time else 0.0
|
|
218
|
+
|
|
219
|
+
if candles > 0 or last_candle_time != start_time:
|
|
220
|
+
extra["candles"] = candles
|
|
221
|
+
extra["last_candle_age_s"] = round(now - last_candle_time, 1) if last_candle_time else 0.0
|
|
222
|
+
|
|
223
|
+
extra.update(gauges)
|
|
224
|
+
|
|
225
|
+
msg = f"heartbeat status={status}"
|
|
226
|
+
if status != "ok":
|
|
227
|
+
self.logger.warning(msg, extra=extra)
|
|
228
|
+
else:
|
|
229
|
+
self.logger.info(msg, extra=extra)
|
|
230
|
+
|
|
231
|
+
def stop(self, reason="shutdown"):
|
|
232
|
+
with self._lock:
|
|
233
|
+
if self._stopped:
|
|
234
|
+
return
|
|
235
|
+
self._stopped = True
|
|
236
|
+
self._running = False
|
|
237
|
+
start_time = self._start_time
|
|
238
|
+
|
|
239
|
+
uptime_s = int(time.time() - start_time) if start_time else 0
|
|
240
|
+
extra = {
|
|
241
|
+
"event": "process_stop",
|
|
242
|
+
"component": self.component,
|
|
243
|
+
"segment": self.segment,
|
|
244
|
+
"reason": reason,
|
|
245
|
+
"uptime_s": uptime_s,
|
|
246
|
+
}
|
|
247
|
+
market = os.environ.get("MARKET")
|
|
248
|
+
if market:
|
|
249
|
+
extra["market"] = market
|
|
250
|
+
|
|
251
|
+
self.logger.info("process stopping", extra=extra)
|
trigerr_logging/jobs.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Lifecycle events for batch work — cron jobs, screeners, db/S3 syncs.
|
|
2
|
+
|
|
3
|
+
Promotes a pattern that was hand-rolled identically in every batch script
|
|
4
|
+
(job_start / job_complete / job_failed emitted around a try/except). Doing it
|
|
5
|
+
in one place means every job emits the same three events with the same field
|
|
6
|
+
names, so "did last night's sync run, and how long did it take" is one query
|
|
7
|
+
regardless of which repo owns the job.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from trigerr_logging import get_logger, job_run
|
|
11
|
+
|
|
12
|
+
logger = get_logger("s3_sync", log_file=..., service="trigerr-data-collection-in")
|
|
13
|
+
|
|
14
|
+
with job_run(logger, "s3_sync"):
|
|
15
|
+
sync_bucket()
|
|
16
|
+
|
|
17
|
+
A failure re-raises after logging, so a cron job still exits non-zero and
|
|
18
|
+
whatever supervises it still sees the failure.
|
|
19
|
+
"""
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
|
|
24
|
+
from trigerr_logging import bound
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def new_run_id():
|
|
28
|
+
"""Short, time-ordered id for one execution of a batch job.
|
|
29
|
+
|
|
30
|
+
Deliberately not called request_id: that name is the Mongo
|
|
31
|
+
{mode}_requests._id in the trading repos and is never generated. Keeping
|
|
32
|
+
the two names distinct means a query for one can never pick up the
|
|
33
|
+
other."""
|
|
34
|
+
return f"{int(time.time())}-{uuid.uuid4().hex[:8]}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextmanager
|
|
38
|
+
def job_run(logger, job, run_id=None, **ctx):
|
|
39
|
+
"""Emit job_start / job_complete / job_failed around a batch job.
|
|
40
|
+
|
|
41
|
+
Binds `job` and `run_id` for the duration so every line the job logs in
|
|
42
|
+
between carries them too — without that, the start and end events are
|
|
43
|
+
correlated but everything the job actually did is not.
|
|
44
|
+
|
|
45
|
+
Restores the prior context on exit (including on failure) so a script
|
|
46
|
+
running several jobs in sequence doesn't leak one job's id into the
|
|
47
|
+
next."""
|
|
48
|
+
run_id = run_id or new_run_id()
|
|
49
|
+
|
|
50
|
+
with bound(job=job, run_id=run_id, **ctx):
|
|
51
|
+
started = time.monotonic()
|
|
52
|
+
logger.info(f"job started: {job}", extra={"event": "job_start"})
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
yield run_id
|
|
56
|
+
except BaseException as exc:
|
|
57
|
+
# BaseException, not Exception: a job killed by SIGTERM (how
|
|
58
|
+
# these are usually stopped) raises SystemExit/KeyboardInterrupt,
|
|
59
|
+
# and those runs would otherwise leave a job_start with no
|
|
60
|
+
# terminal event — indistinguishable from a job still running.
|
|
61
|
+
logger.exception(f"job failed: {job}", extra={
|
|
62
|
+
"event": "job_failed",
|
|
63
|
+
"status": "failed",
|
|
64
|
+
"duration_s": round(time.monotonic() - started, 3),
|
|
65
|
+
"reason": type(exc).__name__,
|
|
66
|
+
})
|
|
67
|
+
raise
|
|
68
|
+
else:
|
|
69
|
+
logger.info(f"job complete: {job}", extra={
|
|
70
|
+
"event": "job_complete",
|
|
71
|
+
"status": "ok",
|
|
72
|
+
"duration_s": round(time.monotonic() - started, 3),
|
|
73
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trigerr-logging
|
|
3
|
+
Version: 0.4.1
|
|
4
|
+
Summary: Structured JSON-line logging for Trigerr services and the Trigerr SDK.
|
|
5
|
+
Author: Anurag Singh Kushwah
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://gitlab.com/trigerr/trigerr-logging
|
|
8
|
+
Project-URL: Repository, https://gitlab.com/trigerr/trigerr-logging
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Topic :: System :: Logging
|
|
14
|
+
Requires-Python: >=3.8
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# trigerr-logging
|
|
23
|
+
|
|
24
|
+
Structured (JSON-line) logging for Trigerr services and SDK.
|
|
25
|
+
|
|
26
|
+
Extracted from `trigerr/trigerr/logging_utils.py` as a standalone, zero-dependency package matching `sysstra-logging`'s architecture.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install -e .
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from trigerr_logging import get_logger, bind, bound, clear_context
|
|
38
|
+
|
|
39
|
+
logger = get_logger("oms", log_file="logs/oms.jsonl", service="trigerr-oms")
|
|
40
|
+
bind(request_id="req-123", tenant="trigerr")
|
|
41
|
+
logger.info("order placed", extra={"order_id": "ord-456"})
|
|
42
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
trigerr_logging/__init__.py,sha256=2nV9Z_66HOFMImNBYvR8qSnRwvQscgEa9wcCnI_Ghcc,23858
|
|
2
|
+
trigerr_logging/health.py,sha256=XU0kLMi_0ZG33XZaWziFWExqwWCLZBmJW_UhGs3QDRc,8539
|
|
3
|
+
trigerr_logging/jobs.py,sha256=GBBLoZYCTRG4wV1FjkZ7IiYhzRAjW7xt9Ka1kbf0mQc,2797
|
|
4
|
+
trigerr_logging-0.4.1.dist-info/licenses/LICENSE,sha256=xVcn09gavE9240rU0PB6S3eqrHr89eahfhdNhALNju4,1077
|
|
5
|
+
trigerr_logging-0.4.1.dist-info/METADATA,sha256=fGiTryI0gshc2BzvK53dD76-rDYX2MAvv7mgY_aoE1o,1305
|
|
6
|
+
trigerr_logging-0.4.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
trigerr_logging-0.4.1.dist-info/top_level.txt,sha256=A1UWSv3i5BRE9j8haSCXMv_a4ihVEcsoyP-8wEf9VcM,16
|
|
8
|
+
trigerr_logging-0.4.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anurag Singh Kushwah
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
trigerr_logging
|