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.
@@ -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)
@@ -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,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,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