trigerr-logging 0.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trigerr_logging-0.4.1/LICENSE +21 -0
- trigerr_logging-0.4.1/PKG-INFO +42 -0
- trigerr_logging-0.4.1/README.md +21 -0
- trigerr_logging-0.4.1/pyproject.toml +49 -0
- trigerr_logging-0.4.1/setup.cfg +4 -0
- trigerr_logging-0.4.1/tests/test_logging_utils.py +319 -0
- trigerr_logging-0.4.1/tests/test_schema_parity.py +99 -0
- trigerr_logging-0.4.1/tests/test_stdlib_only.py +54 -0
- trigerr_logging-0.4.1/trigerr_logging/__init__.py +568 -0
- trigerr_logging-0.4.1/trigerr_logging/health.py +251 -0
- trigerr_logging-0.4.1/trigerr_logging/jobs.py +73 -0
- trigerr_logging-0.4.1/trigerr_logging.egg-info/PKG-INFO +42 -0
- trigerr_logging-0.4.1/trigerr_logging.egg-info/SOURCES.txt +14 -0
- trigerr_logging-0.4.1/trigerr_logging.egg-info/dependency_links.txt +1 -0
- trigerr_logging-0.4.1/trigerr_logging.egg-info/requires.txt +4 -0
- trigerr_logging-0.4.1/trigerr_logging.egg-info/top_level.txt +1 -0
|
@@ -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,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,21 @@
|
|
|
1
|
+
# trigerr-logging
|
|
2
|
+
|
|
3
|
+
Structured (JSON-line) logging for Trigerr services and SDK.
|
|
4
|
+
|
|
5
|
+
Extracted from `trigerr/trigerr/logging_utils.py` as a standalone, zero-dependency package matching `sysstra-logging`'s architecture.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -e .
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from trigerr_logging import get_logger, bind, bound, clear_context
|
|
17
|
+
|
|
18
|
+
logger = get_logger("oms", log_file="logs/oms.jsonl", service="trigerr-oms")
|
|
19
|
+
bind(request_id="req-123", tenant="trigerr")
|
|
20
|
+
logger.info("order placed", extra={"order_id": "ord-456"})
|
|
21
|
+
```
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "trigerr-logging"
|
|
7
|
+
# Version is single-sourced from trigerr_logging/__init__.py's __version__ —
|
|
8
|
+
# bump it there only; the build reads it via the dynamic attr below. Same
|
|
9
|
+
# arrangement as the trigerr package, so the two cannot drift in how they are
|
|
10
|
+
# released.
|
|
11
|
+
dynamic = ["version"]
|
|
12
|
+
description = "Structured JSON-line logging for Trigerr services and the Trigerr SDK."
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
requires-python = ">=3.8"
|
|
15
|
+
license = "MIT"
|
|
16
|
+
license-files = ["LICENSE"]
|
|
17
|
+
authors = [
|
|
18
|
+
{ name = "Anurag Singh Kushwah" },
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 4 - Beta",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
25
|
+
"Topic :: System :: Logging",
|
|
26
|
+
]
|
|
27
|
+
# Deliberately empty: this module is imported by every trigerr-* service and is
|
|
28
|
+
# shipped inside the SDK, so it must not drag a dependency tree behind it.
|
|
29
|
+
# tests/test_stdlib_only.py enforces that.
|
|
30
|
+
dependencies = []
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://gitlab.com/trigerr/trigerr-logging"
|
|
34
|
+
Repository = "https://gitlab.com/trigerr/trigerr-logging"
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
dev = [
|
|
38
|
+
"build>=1.2",
|
|
39
|
+
"pytest>=7.0",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.dynamic]
|
|
43
|
+
version = {attr = "trigerr_logging.__version__"}
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.packages.find]
|
|
46
|
+
include = ["trigerr_logging*"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import concurrent.futures
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
import trigerr_logging as lu
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture(autouse=True)
|
|
13
|
+
def _clean_context():
|
|
14
|
+
lu.clear_context()
|
|
15
|
+
yield
|
|
16
|
+
lu.clear_context()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# --------------------------------------------------------------------------
|
|
20
|
+
# Context propagation
|
|
21
|
+
# --------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
def test_bind_merges_and_drops_none_values():
|
|
24
|
+
lu.bind(request_id="r1", user_id=None)
|
|
25
|
+
assert lu.get_context() == {"request_id": "r1"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_bind_is_copy_on_write_across_calls():
|
|
29
|
+
lu.bind(request_id="r1")
|
|
30
|
+
ctx1 = lu.get_context()
|
|
31
|
+
lu.bind(order_id="o1")
|
|
32
|
+
assert ctx1 == {"request_id": "r1"}
|
|
33
|
+
assert lu.get_context() == {"request_id": "r1", "order_id": "o1"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_clear_context_resets():
|
|
37
|
+
lu.bind(request_id="r1")
|
|
38
|
+
lu.clear_context()
|
|
39
|
+
assert lu.get_context() == {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_bound_restores_prior_context_on_exit():
|
|
43
|
+
lu.bind(request_id="outer")
|
|
44
|
+
with lu.bound(request_id="inner", order_id="o1"):
|
|
45
|
+
assert lu.get_context() == {"request_id": "inner", "order_id": "o1"}
|
|
46
|
+
assert lu.get_context() == {"request_id": "outer"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_bound_restores_on_exception():
|
|
50
|
+
lu.bind(request_id="outer")
|
|
51
|
+
with pytest.raises(ValueError):
|
|
52
|
+
with lu.bound(request_id="inner"):
|
|
53
|
+
raise ValueError("boom")
|
|
54
|
+
assert lu.get_context() == {"request_id": "outer"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_context_isolated_across_threads():
|
|
58
|
+
seen = {}
|
|
59
|
+
|
|
60
|
+
def worker():
|
|
61
|
+
lu.bind(request_id="thread-local")
|
|
62
|
+
seen["thread"] = lu.get_context()
|
|
63
|
+
|
|
64
|
+
lu.bind(request_id="main-thread")
|
|
65
|
+
t = threading.Thread(target=worker)
|
|
66
|
+
t.start()
|
|
67
|
+
t.join()
|
|
68
|
+
|
|
69
|
+
assert seen["thread"] == {"request_id": "thread-local"}
|
|
70
|
+
assert lu.get_context() == {"request_id": "main-thread"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_context_isolated_across_thread_pool_executor():
|
|
74
|
+
# Proxy for Celery prefork workers: each task should see its own bound
|
|
75
|
+
# context, not one leaked from a previous task on the same worker.
|
|
76
|
+
def worker(rid):
|
|
77
|
+
lu.clear_context()
|
|
78
|
+
lu.bind(request_id=rid)
|
|
79
|
+
return lu.get_context()["request_id"]
|
|
80
|
+
|
|
81
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
|
82
|
+
results = list(pool.map(worker, [f"r{i}" for i in range(8)]))
|
|
83
|
+
|
|
84
|
+
assert sorted(results) == sorted(f"r{i}" for i in range(8))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --------------------------------------------------------------------------
|
|
88
|
+
# Scrubbing
|
|
89
|
+
# --------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def test_scrub_redacts_top_level_extra_key():
|
|
92
|
+
record = logging.makeLogRecord({"msg": "hello", "api_key": "sk-live-123"})
|
|
93
|
+
lu.ScrubFilter().filter(record)
|
|
94
|
+
assert record.api_key == "[REDACTED]"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_scrub_redacts_nested_dict_values():
|
|
98
|
+
creds = {"broker": "zerodha", "credentials": {"access_token": "abc123", "user_id": "AB1234"}}
|
|
99
|
+
record = logging.makeLogRecord({"msg": "resolved", "context": creds})
|
|
100
|
+
lu.ScrubFilter().filter(record)
|
|
101
|
+
assert record.context["credentials"]["access_token"] == "[REDACTED]"
|
|
102
|
+
assert record.context["credentials"]["user_id"] == "AB1234"
|
|
103
|
+
assert record.context["broker"] == "zerodha"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_scrub_redacts_dicts_inside_lists():
|
|
107
|
+
payload = [{"password": "hunter2"}, {"note": "ok"}]
|
|
108
|
+
record = logging.makeLogRecord({"msg": "batch", "items": payload})
|
|
109
|
+
lu.ScrubFilter().filter(record)
|
|
110
|
+
assert record.items[0]["password"] == "[REDACTED]"
|
|
111
|
+
assert record.items[1]["note"] == "ok"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_scrub_redacts_key_value_pairs_in_message_string():
|
|
115
|
+
record = logging.makeLogRecord({"msg": "request_dict : {'api_key': 'sk-live-999', 'symbol': 'INFY'}"})
|
|
116
|
+
lu.ScrubFilter().filter(record)
|
|
117
|
+
rendered = record.getMessage()
|
|
118
|
+
assert "sk-live-999" not in rendered
|
|
119
|
+
assert "[REDACTED]" in rendered
|
|
120
|
+
assert "INFY" in rendered
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_scrub_redacts_equals_style_message_pairs():
|
|
124
|
+
record = logging.makeLogRecord({"msg": "auth failed token=abcdef123456 path=/place_order"})
|
|
125
|
+
lu.ScrubFilter().filter(record)
|
|
126
|
+
rendered = record.getMessage()
|
|
127
|
+
assert "abcdef123456" not in rendered
|
|
128
|
+
assert "path=/place_order" in rendered
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_scrub_does_not_mutate_callers_original_dict():
|
|
132
|
+
creds = {"password": "hunter2"}
|
|
133
|
+
record = logging.makeLogRecord({"msg": "x", "creds": creds})
|
|
134
|
+
lu.ScrubFilter().filter(record)
|
|
135
|
+
assert creds["password"] == "hunter2"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# --------------------------------------------------------------------------
|
|
139
|
+
# JSON formatting / schema
|
|
140
|
+
# --------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def _format_one(logger, formatter, level=logging.INFO, message="hello", extra=None):
|
|
143
|
+
record = logger.makeRecord(logger.name, level, __file__, 1, message, (), None, extra=extra)
|
|
144
|
+
return json.loads(formatter.format(record))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_json_formatter_emits_required_keys():
|
|
148
|
+
logger = logging.getLogger("test-schema")
|
|
149
|
+
formatter = lu.JsonFormatter("oms")
|
|
150
|
+
payload = _format_one(logger, formatter)
|
|
151
|
+
assert payload["level"] == "INFO"
|
|
152
|
+
assert payload["service"] == "oms"
|
|
153
|
+
assert payload["logger"] == "test-schema"
|
|
154
|
+
assert payload["message"] == "hello"
|
|
155
|
+
assert payload["ts"].endswith("Z")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_json_formatter_omits_unset_domain_keys():
|
|
159
|
+
""" Domain fields stay absent when unset — padding them would make "the
|
|
160
|
+
OMS has no such concept" indistinguishable from "this line failed to
|
|
161
|
+
populate it".
|
|
162
|
+
|
|
163
|
+
`tenant` used to be asserted here too. It is now part of the identity
|
|
164
|
+
block, so it is present-but-blank instead: a consumer can select it
|
|
165
|
+
without first knowing whether the writing service is tenant-aware. """
|
|
166
|
+
logger = logging.getLogger("test-schema-2")
|
|
167
|
+
formatter = lu.JsonFormatter("oms")
|
|
168
|
+
payload = _format_one(logger, formatter)
|
|
169
|
+
for key in ("request_id", "order_id", "credential_id"):
|
|
170
|
+
assert key not in payload
|
|
171
|
+
assert payload["tenant"] == ""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_json_formatter_includes_bound_context_and_extra():
|
|
175
|
+
lu.bind(request_id="r1", tenant="acme")
|
|
176
|
+
logger = logging.getLogger("test-schema-3")
|
|
177
|
+
formatter = lu.JsonFormatter("oms")
|
|
178
|
+
payload = _format_one(logger, formatter, extra={"order_id": "o1"})
|
|
179
|
+
assert payload["request_id"] == "r1"
|
|
180
|
+
assert payload["tenant"] == "acme"
|
|
181
|
+
assert payload["order_id"] == "o1"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_json_formatter_reads_tenant_from_env(monkeypatch):
|
|
185
|
+
monkeypatch.setenv("TENANT", "env-tenant")
|
|
186
|
+
logger = logging.getLogger("test-schema-4")
|
|
187
|
+
formatter = lu.JsonFormatter("oms")
|
|
188
|
+
payload = _format_one(logger, formatter)
|
|
189
|
+
assert payload["tenant"] == "env-tenant"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_json_formatter_serializes_non_json_native_extra_values():
|
|
193
|
+
class Weird:
|
|
194
|
+
def __str__(self):
|
|
195
|
+
return "weird-repr"
|
|
196
|
+
|
|
197
|
+
logger = logging.getLogger("test-schema-5")
|
|
198
|
+
formatter = lu.JsonFormatter("oms")
|
|
199
|
+
payload = _format_one(logger, formatter, extra={"thing": Weird()})
|
|
200
|
+
assert payload["thing"] == "weird-repr"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_json_formatter_includes_exc_info():
|
|
204
|
+
logger = logging.getLogger("test-schema-6")
|
|
205
|
+
formatter = lu.JsonFormatter("oms")
|
|
206
|
+
try:
|
|
207
|
+
raise RuntimeError("boom")
|
|
208
|
+
except RuntimeError:
|
|
209
|
+
record = logger.makeRecord(logger.name, logging.ERROR, __file__, 1, "failed", (), __import__("sys").exc_info())
|
|
210
|
+
payload = json.loads(formatter.format(record))
|
|
211
|
+
assert "RuntimeError: boom" in payload["exc"]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# --------------------------------------------------------------------------
|
|
215
|
+
# get_logger / handlers
|
|
216
|
+
# --------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
def test_get_logger_is_idempotent_no_duplicate_handlers(tmp_path):
|
|
219
|
+
log_file = str(tmp_path / "svc.jsonl")
|
|
220
|
+
logger1 = lu.get_logger("idempotent-svc", log_file=log_file, stream=False)
|
|
221
|
+
handler_count = len(logger1.handlers)
|
|
222
|
+
logger2 = lu.get_logger("idempotent-svc", log_file=log_file, stream=False)
|
|
223
|
+
assert logger1 is logger2
|
|
224
|
+
assert len(logger2.handlers) == handler_count
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_get_logger_writes_json_lines_to_file(tmp_path):
|
|
228
|
+
log_file = str(tmp_path / "svc2.jsonl")
|
|
229
|
+
logger = lu.get_logger("file-svc", log_file=log_file, stream=False)
|
|
230
|
+
logger.info("order placed", extra={"order_id": "o1"})
|
|
231
|
+
for handler in logger.handlers:
|
|
232
|
+
handler.flush()
|
|
233
|
+
|
|
234
|
+
with open(log_file) as f:
|
|
235
|
+
lines = [line for line in f.read().splitlines() if line]
|
|
236
|
+
assert len(lines) == 1
|
|
237
|
+
payload = json.loads(lines[0])
|
|
238
|
+
assert payload["message"] == "order placed"
|
|
239
|
+
assert payload["order_id"] == "o1"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_get_logger_creates_parent_directories(tmp_path):
|
|
243
|
+
log_file = str(tmp_path / "nested" / "dir" / "svc.jsonl")
|
|
244
|
+
lu.get_logger("nested-dir-svc", log_file=log_file, stream=False)
|
|
245
|
+
assert os.path.isdir(os.path.dirname(log_file))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_get_logger_file_handler_applies_scrub_filter(tmp_path):
|
|
249
|
+
log_file = str(tmp_path / "svc3.jsonl")
|
|
250
|
+
logger = lu.get_logger("scrub-svc", log_file=log_file, stream=False)
|
|
251
|
+
logger.info("resolved", extra={"api_key": "sk-live-123"})
|
|
252
|
+
for handler in logger.handlers:
|
|
253
|
+
handler.flush()
|
|
254
|
+
|
|
255
|
+
with open(log_file) as f:
|
|
256
|
+
content = f.read()
|
|
257
|
+
assert "sk-live-123" not in content
|
|
258
|
+
assert "[REDACTED]" in content
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# --------------------------------------------------------------------------
|
|
262
|
+
# stdout redirection
|
|
263
|
+
# --------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
def test_redirect_stdout_to_logs_print_output(monkeypatch, tmp_path):
|
|
266
|
+
log_file = str(tmp_path / "stdout.jsonl")
|
|
267
|
+
logger = lu.get_logger("stdout-svc", log_file=log_file, stream=False)
|
|
268
|
+
|
|
269
|
+
import sys
|
|
270
|
+
original_stdout = sys.stdout
|
|
271
|
+
try:
|
|
272
|
+
lu.redirect_stdout_to(logger)
|
|
273
|
+
print("hello from print")
|
|
274
|
+
finally:
|
|
275
|
+
sys.stdout = original_stdout
|
|
276
|
+
|
|
277
|
+
for handler in logger.handlers:
|
|
278
|
+
handler.flush()
|
|
279
|
+
with open(log_file) as f:
|
|
280
|
+
lines = [line for line in f.read().splitlines() if line]
|
|
281
|
+
assert any(json.loads(line)["message"] == "hello from print" for line in lines)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_redirect_stdout_to_shim_supports_isatty(tmp_path):
|
|
285
|
+
# Real regression: uvicorn's default log formatter calls
|
|
286
|
+
# sys.stdout.isatty() at Config() construction time to decide whether to
|
|
287
|
+
# color output; a plain object without this method raises AttributeError
|
|
288
|
+
# and uvicorn fails to start under redirect_stdout_to().
|
|
289
|
+
log_file = str(tmp_path / "isatty.jsonl")
|
|
290
|
+
logger = lu.get_logger("isatty-svc", log_file=log_file, stream=False)
|
|
291
|
+
|
|
292
|
+
import sys
|
|
293
|
+
original_stdout = sys.stdout
|
|
294
|
+
try:
|
|
295
|
+
lu.redirect_stdout_to(logger)
|
|
296
|
+
assert sys.stdout.isatty() is False
|
|
297
|
+
finally:
|
|
298
|
+
sys.stdout = original_stdout
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_redirect_stdout_to_drops_whitespace_only_writes(tmp_path):
|
|
302
|
+
log_file = str(tmp_path / "stdout2.jsonl")
|
|
303
|
+
logger = lu.get_logger("stdout-svc-2", log_file=log_file, stream=False)
|
|
304
|
+
|
|
305
|
+
import sys
|
|
306
|
+
original_stdout = sys.stdout
|
|
307
|
+
try:
|
|
308
|
+
lu.redirect_stdout_to(logger)
|
|
309
|
+
print()
|
|
310
|
+
print("real message")
|
|
311
|
+
finally:
|
|
312
|
+
sys.stdout = original_stdout
|
|
313
|
+
|
|
314
|
+
for handler in logger.handlers:
|
|
315
|
+
handler.flush()
|
|
316
|
+
with open(log_file) as f:
|
|
317
|
+
lines = [line for line in f.read().splitlines() if line]
|
|
318
|
+
assert len(lines) == 1
|
|
319
|
+
assert json.loads(lines[0])["message"] == "real message"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
""" Pins the log envelope against tests/fixtures/envelope.golden.json.
|
|
2
|
+
|
|
3
|
+
That fixture is the SAME FILE in project-sysstra's sysstra-logging and
|
|
4
|
+
project-trigerr's trigerr-logging. Both projects push into one Loki, so a dashboard
|
|
5
|
+
or alert written against one has to work against the other — and the two
|
|
6
|
+
copies of logging_utils.py have drifted before, while a comment in CLAUDE.md
|
|
7
|
+
asked politely for them to stay in sync. This test is that comment with
|
|
8
|
+
teeth: change the envelope in one project and its own suite fails until the
|
|
9
|
+
fixture is updated in both.
|
|
10
|
+
|
|
11
|
+
Changing the envelope on purpose = edit the fixture in BOTH repos and bump
|
|
12
|
+
SCHEMA_VERSION so mid-rollout lines stay distinguishable in Loki.
|
|
13
|
+
"""
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
import trigerr_logging as lu
|
|
22
|
+
|
|
23
|
+
GOLDEN = os.path.join(os.path.dirname(__file__), "fixtures", "envelope.golden.json")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture(scope="module")
|
|
27
|
+
def golden():
|
|
28
|
+
with open(GOLDEN) as f:
|
|
29
|
+
return json.load(f)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture(autouse=True)
|
|
33
|
+
def _clean_context():
|
|
34
|
+
lu.clear_context()
|
|
35
|
+
yield
|
|
36
|
+
lu.clear_context()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _render(service="svc", extra=None):
|
|
40
|
+
logger = logging.getLogger("parity")
|
|
41
|
+
logger.handlers.clear()
|
|
42
|
+
logger.setLevel(logging.INFO)
|
|
43
|
+
logger.propagate = False
|
|
44
|
+
buf = io.StringIO()
|
|
45
|
+
handler = logging.StreamHandler(buf)
|
|
46
|
+
handler.setFormatter(lu.JsonFormatter(service))
|
|
47
|
+
logger.addHandler(handler)
|
|
48
|
+
logger.info("hello", extra=extra or {})
|
|
49
|
+
return json.loads(buf.getvalue().strip().splitlines()[-1])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_schema_version_matches_golden(golden):
|
|
53
|
+
assert lu.SCHEMA_VERSION == golden["schema_version"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_identity_fields_match_golden(golden):
|
|
57
|
+
assert list(lu.IDENTITY_FIELDS) == golden["identity"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_intrinsic_fields_match_golden(golden):
|
|
61
|
+
assert sorted(lu._INTRINSIC_FIELDS) == sorted(golden["intrinsic"])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_env_backed_fields_match_golden(golden):
|
|
65
|
+
assert lu._ENV_FIELDS == golden["env_backed"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_domain_field_order_matches_golden(golden):
|
|
69
|
+
assert list(lu._DOMAIN_ORDER) == golden["domain"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_env_default_matches_golden(golden):
|
|
73
|
+
assert lu._DEFAULT_ENV == golden["defaults"]["env"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_rendered_line_starts_with_the_golden_identity_block(golden, monkeypatch):
|
|
77
|
+
""" The fixture lists names; this asserts the formatter actually emits
|
|
78
|
+
them, in order, ahead of everything else. """
|
|
79
|
+
for var in ("SERVICE", "ENVIRONMENT", "TENANT", "MARKET"):
|
|
80
|
+
monkeypatch.delenv(var, raising=False)
|
|
81
|
+
rec = _render()
|
|
82
|
+
assert list(rec)[:len(golden["identity"])] == golden["identity"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_domain_fields_are_never_padded(golden):
|
|
86
|
+
""" The counterpart to the identity block: a repo that has no concept of
|
|
87
|
+
a field must not emit it blank, or a consumer cannot tell that apart
|
|
88
|
+
from a repo that has the concept and failed to populate it. """
|
|
89
|
+
rec = _render()
|
|
90
|
+
for field in golden["domain"]:
|
|
91
|
+
assert field not in rec, f"domain field {field} was padded: {rec}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_domain_fields_render_in_golden_order_when_supplied(golden):
|
|
95
|
+
supplied = {"order_id": "o1", "request_id": "r1", "mode": "lt", "job": "j1"}
|
|
96
|
+
rec = _render(extra=supplied)
|
|
97
|
+
emitted = [k for k in rec if k in supplied]
|
|
98
|
+
expected = [f for f in golden["domain"] if f in supplied]
|
|
99
|
+
assert emitted == expected
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
""" This package must not drag a dependency tree behind it.
|
|
2
|
+
|
|
3
|
+
It is imported by every trigerr-* service and is also shipped inside the
|
|
4
|
+
pip-installable SDK, so anything it depends on becomes a transitive dependency
|
|
5
|
+
of every client of the platform. The module docstring states that as a design
|
|
6
|
+
rule; this is what holds it, because the failure mode otherwise is silent -- a
|
|
7
|
+
convenient import gets added, the declared dependency list stays empty, and the
|
|
8
|
+
package is broken only for whoever installs it somewhere clean.
|
|
9
|
+
"""
|
|
10
|
+
import ast
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
PACKAGE = Path(__file__).resolve().parent.parent / "trigerr_logging"
|
|
17
|
+
|
|
18
|
+
# Everything the package is allowed to import: the standard library, and itself.
|
|
19
|
+
LOCAL = {"trigerr_logging"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _top_level_imports(path):
|
|
23
|
+
tree = ast.parse(path.read_text())
|
|
24
|
+
for node in ast.walk(tree):
|
|
25
|
+
if isinstance(node, ast.Import):
|
|
26
|
+
for alias in node.names:
|
|
27
|
+
yield alias.name.split(".")[0]
|
|
28
|
+
elif isinstance(node, ast.ImportFrom):
|
|
29
|
+
if node.level: # relative import, inside the package
|
|
30
|
+
continue
|
|
31
|
+
if node.module:
|
|
32
|
+
yield node.module.split(".")[0]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.mark.skipif(not hasattr(sys, "stdlib_module_names"),
|
|
36
|
+
reason="sys.stdlib_module_names needs Python 3.10+")
|
|
37
|
+
@pytest.mark.parametrize("source", sorted(PACKAGE.rglob("*.py")), ids=lambda p: p.name)
|
|
38
|
+
def test_module_imports_only_stdlib(source):
|
|
39
|
+
allowed = set(sys.stdlib_module_names) | LOCAL
|
|
40
|
+
offenders = sorted({name for name in _top_level_imports(source) if name not in allowed})
|
|
41
|
+
|
|
42
|
+
assert not offenders, (
|
|
43
|
+
f"{source.name} imports {offenders}, which are not in the standard library. "
|
|
44
|
+
f"Anything imported here becomes a transitive dependency of every service "
|
|
45
|
+
f"and of the SDK -- declare it in pyproject.toml deliberately, or drop it.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_declared_dependencies_stay_empty():
|
|
49
|
+
""" The other half: an empty import list is only meaningful if the metadata
|
|
50
|
+
agrees, since pip installs from the metadata rather than from the imports. """
|
|
51
|
+
pyproject = (PACKAGE.parent / "pyproject.toml").read_text()
|
|
52
|
+
block = pyproject.split("dependencies = [", 1)[1].split("]", 1)[0]
|
|
53
|
+
|
|
54
|
+
assert block.strip() == "", f"dependencies is no longer empty: {block.strip()!r}"
|