cloud-dog-logging 0.4.0__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.
- cloud_dog_logging/GAPS.md +38 -0
- cloud_dog_logging/__init__.py +406 -0
- cloud_dog_logging/app_logger.py +143 -0
- cloud_dog_logging/audit_logger.py +333 -0
- cloud_dog_logging/audit_schema.py +237 -0
- cloud_dog_logging/batching.py +86 -0
- cloud_dog_logging/compat.py +94 -0
- cloud_dog_logging/config.py +248 -0
- cloud_dog_logging/correlation.py +100 -0
- cloud_dog_logging/errors.py +35 -0
- cloud_dog_logging/event_catalogue.py +76 -0
- cloud_dog_logging/exceptions.py +43 -0
- cloud_dog_logging/field_providers.py +227 -0
- cloud_dog_logging/formatters/__init__.py +64 -0
- cloud_dog_logging/formatters/json_formatter.py +326 -0
- cloud_dog_logging/formatters/text_formatter.py +88 -0
- cloud_dog_logging/handler_types.py +103 -0
- cloud_dog_logging/handlers/__init__.py +28 -0
- cloud_dog_logging/handlers/dual_handler.py +82 -0
- cloud_dog_logging/handlers/rotating_file.py +210 -0
- cloud_dog_logging/handlers/stdout_handler.py +49 -0
- cloud_dog_logging/health/__init__.py +26 -0
- cloud_dog_logging/health/reporter.py +121 -0
- cloud_dog_logging/integrity.py +223 -0
- cloud_dog_logging/middleware/__init__.py +27 -0
- cloud_dog_logging/middleware/audit.py +261 -0
- cloud_dog_logging/middleware/fastapi.py +163 -0
- cloud_dog_logging/presets.py +80 -0
- cloud_dog_logging/redaction.py +207 -0
- cloud_dog_logging/sampling.py +82 -0
- cloud_dog_logging/signing.py +78 -0
- cloud_dog_logging/sinks/__init__.py +41 -0
- cloud_dog_logging/sinks/base.py +52 -0
- cloud_dog_logging/sinks/db_sink.py +67 -0
- cloud_dog_logging/sinks/fan_out.py +65 -0
- cloud_dog_logging/sinks/file_sink.py +94 -0
- cloud_dog_logging/sinks/stdout_sink.py +56 -0
- cloud_dog_logging/tool_events.py +67 -0
- cloud_dog_logging-0.4.0.dist-info/METADATA +23 -0
- cloud_dog_logging-0.4.0.dist-info/RECORD +44 -0
- cloud_dog_logging-0.4.0.dist-info/WHEEL +4 -0
- cloud_dog_logging-0.4.0.dist-info/licenses/LICENCE +190 -0
- cloud_dog_logging-0.4.0.dist-info/licenses/LICENSE +176 -0
- cloud_dog_logging-0.4.0.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# cloud_dog_logging PS-40 v2 Gaps
|
|
2
|
+
|
|
3
|
+
Source lane: W28A-619
|
|
4
|
+
Status: Documentation-only gap note. No source changes made in this lane.
|
|
5
|
+
|
|
6
|
+
The full package gap matrix is recorded at:
|
|
7
|
+
|
|
8
|
+
```text
|
|
9
|
+
cloud-dog-ai-platform-standards/working/evidence/W28A-619-log-standards-review/02-cloud-dog-log-gap-matrix.md
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Summary
|
|
13
|
+
|
|
14
|
+
- Total reviewed rows: 47
|
|
15
|
+
- Block rows: 5
|
|
16
|
+
- Major rows: 34
|
|
17
|
+
- Minor rows: 4
|
|
18
|
+
- Present/no-gap rows: 4
|
|
19
|
+
|
|
20
|
+
## Blockers Before PS-40 v2 Runtime Adoption
|
|
21
|
+
|
|
22
|
+
| Gap | Evidence | Required fix lane |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| No canonical `span_id` context model/formatter/schema. | `audit_schema.py:138-160`, `json_formatter.py:251-260`, `correlation.py:32-58` | W28A-XYZ |
|
|
25
|
+
| No per-surface API/WebUI/MCP/A2A handler/file configuration. | `config.py:56-93`, `__init__.py:198-223`, `__init__.py:359-382` | W28A-XYZ |
|
|
26
|
+
| No `<service>.<surface>.log` path derivation from `log.dir`. | `config.py:61-63`, `__init__.py:200-223`, `__init__.py:364-375` | W28A-XYZ |
|
|
27
|
+
| No outbound `X-Correlation-Id` / W3C `traceparent` injection helper. | `correlation.py:32-58` plus package source inspection | W28A-XYZ |
|
|
28
|
+
| No job envelope helper for lifecycle correlation inheritance. | `correlation.py:32-58` plus package source inspection | W28A-XYZ |
|
|
29
|
+
|
|
30
|
+
## Follow-on W28A-XYZ Must Cover
|
|
31
|
+
|
|
32
|
+
1. Emit the complete PS-40 v2 canonical field set in both application and audit entries.
|
|
33
|
+
2. Add per-surface file topology and `log.dir`-based path derivation.
|
|
34
|
+
3. Add W3C trace context support including `trace_id`, `span_id`, inbound parsing, outbound forwarding, and job inheritance.
|
|
35
|
+
4. Add explicit redaction presets for JWT, Vault token, OAuth token, API key, password, session cookie, and PII classes.
|
|
36
|
+
5. Add automatic audit sub-channel routing for SECURITY/AUDIT/auth/rbac/config/security/denied/error events.
|
|
37
|
+
6. Add forbidden-pattern lint helper and PS-40 v2 conformance tests.
|
|
38
|
+
7. Update README, REQUIREMENTS, TESTS, and usage docs, then build/test/publish with evidence.
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# cloud_dog_logging — Public API
|
|
16
|
+
#
|
|
17
|
+
# Licence: Apache 2.0 — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Drop-in Python logging library implementing PS-40. Provides
|
|
20
|
+
# two mandatory log streams (audit + application), structured JSON output,
|
|
21
|
+
# correlation ID propagation, secret redaction, configurable rotation, and
|
|
22
|
+
# the public extension surface (LogRecord factory hook, JSON-field hook,
|
|
23
|
+
# declarative HandlerType enum) that lets services delete bespoke
|
|
24
|
+
# ``logger.py`` wrappers without losing functionality.
|
|
25
|
+
# Related requirements: FR1.1, FR1.9, FR1.18, FR1.19, FR1.20, FR1.21, FR1.22, FR1.23, FR1.24
|
|
26
|
+
# Related architecture: SA1, CC1.1, CC1.6, CC1.9
|
|
27
|
+
|
|
28
|
+
"""cloud_dog_logging — PS-40 Logging & Observability for Cloud-Dog services."""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import atexit
|
|
33
|
+
import logging
|
|
34
|
+
from typing import Any, Callable
|
|
35
|
+
|
|
36
|
+
from cloud_dog_logging.app_logger import AppLogger
|
|
37
|
+
from cloud_dog_logging.audit_logger import AuditLogger
|
|
38
|
+
from cloud_dog_logging.audit_schema import Actor, AuditEvent, Target
|
|
39
|
+
from cloud_dog_logging.batching import BatchingSink
|
|
40
|
+
from cloud_dog_logging.compat import setup_logger
|
|
41
|
+
from cloud_dog_logging.config import LogConfig
|
|
42
|
+
from cloud_dog_logging.correlation import (
|
|
43
|
+
clear_correlation_id,
|
|
44
|
+
get_correlation_id,
|
|
45
|
+
get_environment,
|
|
46
|
+
get_service_name,
|
|
47
|
+
get_service_instance,
|
|
48
|
+
set_correlation_id,
|
|
49
|
+
set_environment,
|
|
50
|
+
set_service_name,
|
|
51
|
+
set_service_instance,
|
|
52
|
+
)
|
|
53
|
+
from cloud_dog_logging.exceptions import format_exception
|
|
54
|
+
from cloud_dog_logging.field_providers import (
|
|
55
|
+
FieldProvider,
|
|
56
|
+
clear_log_field_providers,
|
|
57
|
+
get_registered_field_providers,
|
|
58
|
+
register_log_field_provider,
|
|
59
|
+
unregister_log_field_provider,
|
|
60
|
+
)
|
|
61
|
+
from cloud_dog_logging.handler_types import HandlerType
|
|
62
|
+
from cloud_dog_logging.middleware.audit import AuditMiddleware
|
|
63
|
+
from cloud_dog_logging.formatters import (
|
|
64
|
+
JSONFieldProvider,
|
|
65
|
+
JSONFormatter,
|
|
66
|
+
TextFormatter,
|
|
67
|
+
add_json_field,
|
|
68
|
+
clear_json_fields,
|
|
69
|
+
get_registered_json_fields,
|
|
70
|
+
remove_json_field,
|
|
71
|
+
)
|
|
72
|
+
from cloud_dog_logging.handlers.dual_handler import DualHandler
|
|
73
|
+
from cloud_dog_logging.handlers.rotating_file import ConfigurableRotatingHandler
|
|
74
|
+
from cloud_dog_logging.handlers.stdout_handler import StdoutHandler
|
|
75
|
+
from cloud_dog_logging.health.reporter import LogHealthReporter
|
|
76
|
+
from cloud_dog_logging.integrity import AuditIntegrityVerifier
|
|
77
|
+
from cloud_dog_logging.presets import BUILTIN_PRESETS, RedactionPreset, load_presets
|
|
78
|
+
from cloud_dog_logging.redaction import RedactionEngine
|
|
79
|
+
from cloud_dog_logging.sampling import SamplingFilter
|
|
80
|
+
from cloud_dog_logging.signing import HMACSigner
|
|
81
|
+
from cloud_dog_logging.sinks import AuditSink, DatabaseSink, FanOutSink, FileSink, StdoutSink
|
|
82
|
+
from cloud_dog_logging.tool_events import log_tool_event
|
|
83
|
+
|
|
84
|
+
__all__ = [
|
|
85
|
+
"AuditMiddleware",
|
|
86
|
+
"setup_logging",
|
|
87
|
+
"get_logger",
|
|
88
|
+
"get_audit_logger",
|
|
89
|
+
"setup_logger",
|
|
90
|
+
"log_tool_event",
|
|
91
|
+
"AppLogger",
|
|
92
|
+
"AuditLogger",
|
|
93
|
+
"Actor",
|
|
94
|
+
"AuditEvent",
|
|
95
|
+
"Target",
|
|
96
|
+
"LogConfig",
|
|
97
|
+
"RedactionEngine",
|
|
98
|
+
"RedactionPreset",
|
|
99
|
+
"BUILTIN_PRESETS",
|
|
100
|
+
"load_presets",
|
|
101
|
+
"SamplingFilter",
|
|
102
|
+
"BatchingSink",
|
|
103
|
+
"HMACSigner",
|
|
104
|
+
"AuditSink",
|
|
105
|
+
"FileSink",
|
|
106
|
+
"StdoutSink",
|
|
107
|
+
"DatabaseSink",
|
|
108
|
+
"FanOutSink",
|
|
109
|
+
"format_exception",
|
|
110
|
+
"JSONFormatter",
|
|
111
|
+
"TextFormatter",
|
|
112
|
+
"ConfigurableRotatingHandler",
|
|
113
|
+
"StdoutHandler",
|
|
114
|
+
"DualHandler",
|
|
115
|
+
"LogHealthReporter",
|
|
116
|
+
"AuditIntegrityVerifier",
|
|
117
|
+
"get_integrity_verifier",
|
|
118
|
+
"get_correlation_id",
|
|
119
|
+
"set_correlation_id",
|
|
120
|
+
"clear_correlation_id",
|
|
121
|
+
"get_service_name",
|
|
122
|
+
"get_service_instance",
|
|
123
|
+
"get_environment",
|
|
124
|
+
"set_service_name",
|
|
125
|
+
"set_service_instance",
|
|
126
|
+
"set_environment",
|
|
127
|
+
# Public extension surface (0.4.0):
|
|
128
|
+
"FieldProvider",
|
|
129
|
+
"register_log_field_provider",
|
|
130
|
+
"unregister_log_field_provider",
|
|
131
|
+
"clear_log_field_providers",
|
|
132
|
+
"get_registered_field_providers",
|
|
133
|
+
"JSONFieldProvider",
|
|
134
|
+
"add_json_field",
|
|
135
|
+
"remove_json_field",
|
|
136
|
+
"clear_json_fields",
|
|
137
|
+
"get_registered_json_fields",
|
|
138
|
+
"HandlerType",
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
__version__ = "0.4.0"
|
|
142
|
+
|
|
143
|
+
_audit_logger: AuditLogger | None = None
|
|
144
|
+
_redaction_engine: RedactionEngine | None = None
|
|
145
|
+
_log_config: LogConfig | None = None
|
|
146
|
+
_sampling_filter: SamplingFilter | None = None
|
|
147
|
+
_integrity_verifier: AuditIntegrityVerifier | None = None
|
|
148
|
+
_is_configured: bool = False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def setup_logging(config: dict[str, Any] | Any | None = None) -> None:
|
|
152
|
+
"""One-time logging setup from config dict or platform GlobalConfig."""
|
|
153
|
+
global _audit_logger, _redaction_engine, _log_config, _sampling_filter, _integrity_verifier, _is_configured
|
|
154
|
+
|
|
155
|
+
if _audit_logger is not None:
|
|
156
|
+
try:
|
|
157
|
+
_audit_logger.close()
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
if _integrity_verifier is not None:
|
|
161
|
+
try:
|
|
162
|
+
_integrity_verifier.stop()
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
_integrity_verifier = None
|
|
166
|
+
|
|
167
|
+
if config is None:
|
|
168
|
+
_log_config = LogConfig()
|
|
169
|
+
elif isinstance(config, dict):
|
|
170
|
+
_log_config = LogConfig.from_dict(config)
|
|
171
|
+
else:
|
|
172
|
+
_log_config = LogConfig.from_platform_config(config)
|
|
173
|
+
|
|
174
|
+
set_service_name(_log_config.service_name)
|
|
175
|
+
set_service_instance(_log_config.service_instance)
|
|
176
|
+
set_environment(_log_config.environment)
|
|
177
|
+
|
|
178
|
+
presets = _resolve_redaction_presets(config, _log_config)
|
|
179
|
+
_redaction_engine = RedactionEngine(
|
|
180
|
+
additional_patterns=_log_config.redaction_patterns if _log_config.redaction_patterns else None,
|
|
181
|
+
pii_enabled=_log_config.pii_redaction,
|
|
182
|
+
presets=presets,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if _log_config.log_format.lower() == "json":
|
|
186
|
+
formatter: logging.Formatter = JSONFormatter(
|
|
187
|
+
service_name=_log_config.service_name,
|
|
188
|
+
extra_fields=list(_log_config.extra_fields) if _log_config.extra_fields else None,
|
|
189
|
+
)
|
|
190
|
+
else:
|
|
191
|
+
formatter = TextFormatter(service_name=_log_config.service_name)
|
|
192
|
+
|
|
193
|
+
root_level = getattr(logging, _log_config.log_level.upper(), logging.INFO)
|
|
194
|
+
app_root = logging.getLogger()
|
|
195
|
+
app_root.setLevel(root_level)
|
|
196
|
+
app_root.handlers.clear()
|
|
197
|
+
|
|
198
|
+
handler_selection = _resolve_handler_selection(_log_config)
|
|
199
|
+
|
|
200
|
+
if "file" in handler_selection:
|
|
201
|
+
file_handler = ConfigurableRotatingHandler(
|
|
202
|
+
filename=_log_config.app_log_file, # type: ignore[arg-type]
|
|
203
|
+
max_bytes=_log_config.rotation_max_bytes,
|
|
204
|
+
backup_count=_log_config.rotation_backup_count,
|
|
205
|
+
rotation_mode=_log_config.rotation_mode,
|
|
206
|
+
when=_log_config.rotation_when,
|
|
207
|
+
interval=_log_config.rotation_interval,
|
|
208
|
+
compress=_log_config.rotation_compress,
|
|
209
|
+
stream_name="application",
|
|
210
|
+
)
|
|
211
|
+
file_handler.setFormatter(formatter)
|
|
212
|
+
if "console" in handler_selection:
|
|
213
|
+
stdout_handler = StdoutHandler(stream_name="stdout")
|
|
214
|
+
stdout_handler.setFormatter(formatter)
|
|
215
|
+
dual = DualHandler(file_handler=file_handler, stream_handler=stdout_handler)
|
|
216
|
+
dual.setFormatter(formatter)
|
|
217
|
+
app_root.addHandler(dual)
|
|
218
|
+
else:
|
|
219
|
+
app_root.addHandler(file_handler)
|
|
220
|
+
elif "console" in handler_selection:
|
|
221
|
+
stdout_handler = StdoutHandler(stream_name="stdout")
|
|
222
|
+
stdout_handler.setFormatter(formatter)
|
|
223
|
+
app_root.addHandler(stdout_handler)
|
|
224
|
+
|
|
225
|
+
_sampling_filter = None
|
|
226
|
+
if _log_config.sampling_rates:
|
|
227
|
+
_sampling_filter = SamplingFilter(_log_config.sampling_rates)
|
|
228
|
+
for handler in app_root.handlers:
|
|
229
|
+
handler.addFilter(_sampling_filter)
|
|
230
|
+
|
|
231
|
+
audit_sink = _build_audit_sink(_log_config, on_audit_rotate=_on_audit_rotation)
|
|
232
|
+
signer = _build_signer(_log_config)
|
|
233
|
+
audit_py_logger = logging.getLogger("cloud_dog_logging.audit")
|
|
234
|
+
audit_py_logger.setLevel(logging.INFO)
|
|
235
|
+
audit_py_logger.propagate = False
|
|
236
|
+
if not audit_py_logger.handlers:
|
|
237
|
+
audit_py_logger.addHandler(logging.NullHandler())
|
|
238
|
+
|
|
239
|
+
_audit_logger = AuditLogger(
|
|
240
|
+
logger=audit_py_logger,
|
|
241
|
+
redaction_engine=_redaction_engine,
|
|
242
|
+
service_name=_log_config.service_name,
|
|
243
|
+
sink=audit_sink,
|
|
244
|
+
signer=signer,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
if _log_config.integrity_enabled:
|
|
248
|
+
audit_path = _log_config.audit_log_file or "logs/audit.log.jsonl"
|
|
249
|
+
_integrity_verifier = AuditIntegrityVerifier(
|
|
250
|
+
audit_log_path=audit_path,
|
|
251
|
+
integrity_log_path=_log_config.integrity_log_file,
|
|
252
|
+
interval_seconds=_log_config.integrity_interval_seconds,
|
|
253
|
+
hash_algorithm=_log_config.integrity_hash_algorithm,
|
|
254
|
+
service_name=_log_config.service_name,
|
|
255
|
+
service_instance=_log_config.service_instance,
|
|
256
|
+
environment=_log_config.environment,
|
|
257
|
+
)
|
|
258
|
+
_integrity_verifier.start()
|
|
259
|
+
|
|
260
|
+
for logger_name, level_str in _log_config.level_overrides.items():
|
|
261
|
+
override_level = getattr(logging, level_str.upper(), None)
|
|
262
|
+
if override_level is not None:
|
|
263
|
+
logging.getLogger(logger_name).setLevel(override_level)
|
|
264
|
+
|
|
265
|
+
_is_configured = True
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _resolve_handler_selection(log_config: LogConfig) -> set[str]:
|
|
269
|
+
"""Resolve the set of active handler kinds from declarative + legacy knobs.
|
|
270
|
+
|
|
271
|
+
Behaviour matrix:
|
|
272
|
+
|
|
273
|
+
- If ``log_config.handlers`` is ``None`` (the legacy default) the return
|
|
274
|
+
preserves bit-for-bit the prior behaviour: ``{"file"}`` if
|
|
275
|
+
``app_log_file`` is set, ``{"console"}`` if only ``console_output`` is
|
|
276
|
+
true, ``{"file", "console"}`` for the dual case, ``set()`` otherwise.
|
|
277
|
+
- If ``log_config.handlers`` is supplied the listed :class:`HandlerType`
|
|
278
|
+
members drive the selection. ``DUAL`` expands to ``{"file", "console"}``
|
|
279
|
+
and ``ROTATING`` is treated as an alias of ``FILE`` (the platform's
|
|
280
|
+
only file handler is already rotating). The legacy knobs still narrow
|
|
281
|
+
the selection so callers cannot ask for ``FILE`` without
|
|
282
|
+
``app_log_file`` set or ``CONSOLE`` with ``console_output=False``.
|
|
283
|
+
"""
|
|
284
|
+
legacy: set[str] = set()
|
|
285
|
+
if log_config.app_log_file:
|
|
286
|
+
legacy.add("file")
|
|
287
|
+
if log_config.console_output:
|
|
288
|
+
legacy.add("console")
|
|
289
|
+
|
|
290
|
+
if not log_config.handlers:
|
|
291
|
+
return legacy
|
|
292
|
+
|
|
293
|
+
declarative: set[str] = set()
|
|
294
|
+
for raw in log_config.handlers:
|
|
295
|
+
try:
|
|
296
|
+
kind = HandlerType.coerce(raw)
|
|
297
|
+
except ValueError:
|
|
298
|
+
continue
|
|
299
|
+
if kind in (HandlerType.FILE, HandlerType.ROTATING):
|
|
300
|
+
declarative.add("file")
|
|
301
|
+
elif kind is HandlerType.CONSOLE:
|
|
302
|
+
declarative.add("console")
|
|
303
|
+
elif kind is HandlerType.DUAL:
|
|
304
|
+
declarative.add("file")
|
|
305
|
+
declarative.add("console")
|
|
306
|
+
|
|
307
|
+
# Honour legacy guard: cannot emit to file without app_log_file configured.
|
|
308
|
+
if "file" in declarative and not log_config.app_log_file:
|
|
309
|
+
declarative.discard("file")
|
|
310
|
+
if "console" in declarative and not log_config.console_output:
|
|
311
|
+
# The declarative request wins for console — when the caller asked
|
|
312
|
+
# for CONSOLE explicitly, force-enable console output.
|
|
313
|
+
log_config.console_output = True
|
|
314
|
+
|
|
315
|
+
return declarative
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def get_logger(name: str, pii_redaction: bool = True) -> AppLogger:
|
|
319
|
+
"""Get a configured application logger for the given module name."""
|
|
320
|
+
py_logger = logging.getLogger(name)
|
|
321
|
+
|
|
322
|
+
redaction = _redaction_engine
|
|
323
|
+
if redaction is None:
|
|
324
|
+
redaction = RedactionEngine(pii_enabled=pii_redaction)
|
|
325
|
+
|
|
326
|
+
return AppLogger(logger=py_logger, redaction_engine=redaction)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def get_audit_logger() -> AuditLogger:
|
|
330
|
+
"""Get the singleton audit logger for security events.
|
|
331
|
+
|
|
332
|
+
If ``setup_logging()`` has not been called yet, this performs a default
|
|
333
|
+
initialisation so that a ``FileSink`` writing to ``logs/audit.log.jsonl``
|
|
334
|
+
is available instead of the legacy NullHandler fallback.
|
|
335
|
+
"""
|
|
336
|
+
global _audit_logger
|
|
337
|
+
if _audit_logger is None:
|
|
338
|
+
if not _is_configured:
|
|
339
|
+
setup_logging(None)
|
|
340
|
+
else:
|
|
341
|
+
py_logger = logging.getLogger("cloud_dog_logging.audit")
|
|
342
|
+
if not py_logger.handlers:
|
|
343
|
+
py_logger.addHandler(logging.NullHandler())
|
|
344
|
+
_audit_logger = AuditLogger(logger=py_logger)
|
|
345
|
+
return _audit_logger
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def get_integrity_verifier() -> AuditIntegrityVerifier | None:
|
|
349
|
+
"""Get the audit integrity verifier when enabled."""
|
|
350
|
+
return _integrity_verifier
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _on_audit_rotation(_meta: dict[str, object]) -> None:
|
|
354
|
+
verifier = get_integrity_verifier()
|
|
355
|
+
if verifier is not None:
|
|
356
|
+
verifier.compute_now(trigger="rotation")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _build_audit_sink(
|
|
360
|
+
log_config: LogConfig,
|
|
361
|
+
on_audit_rotate: Callable[[dict[str, object]], None] | None = None,
|
|
362
|
+
) -> AuditSink:
|
|
363
|
+
sinks: list[AuditSink] = []
|
|
364
|
+
audit_path = log_config.audit_log_file or "logs/audit.log.jsonl"
|
|
365
|
+
sinks.append(
|
|
366
|
+
FileSink(
|
|
367
|
+
audit_path,
|
|
368
|
+
max_bytes=log_config.rotation_max_bytes,
|
|
369
|
+
backup_count=log_config.rotation_backup_count,
|
|
370
|
+
rotation_mode=log_config.rotation_mode,
|
|
371
|
+
when=log_config.rotation_when,
|
|
372
|
+
interval=log_config.rotation_interval,
|
|
373
|
+
compress=log_config.rotation_compress,
|
|
374
|
+
on_rotate=on_audit_rotate,
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
if log_config.console_output:
|
|
378
|
+
sinks.append(StdoutSink())
|
|
379
|
+
|
|
380
|
+
if len(sinks) == 1:
|
|
381
|
+
return sinks[0]
|
|
382
|
+
return FanOutSink(sinks)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _build_signer(log_config: LogConfig) -> HMACSigner | None:
|
|
386
|
+
if not log_config.audit_signing_enabled:
|
|
387
|
+
return None
|
|
388
|
+
return HMACSigner(log_config.audit_signing_key or "")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _resolve_redaction_presets(config: dict[str, Any] | Any | None, log_config: LogConfig) -> list[RedactionPreset]:
|
|
392
|
+
if isinstance(config, dict):
|
|
393
|
+
return load_presets(config)
|
|
394
|
+
return load_presets({"log": {"redaction": {"presets": log_config.redaction_presets}}})
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _shutdown_integrity_verifier() -> None:
|
|
398
|
+
verifier = get_integrity_verifier()
|
|
399
|
+
if verifier is not None:
|
|
400
|
+
try:
|
|
401
|
+
verifier.stop()
|
|
402
|
+
except Exception:
|
|
403
|
+
pass
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
atexit.register(_shutdown_integrity_verifier)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# cloud_dog_logging — Application logger (structured JSON, levels)
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Structured application logger with automatic correlation ID
|
|
20
|
+
# injection, secret redaction on extra fields, and configurable formatting.
|
|
21
|
+
# Related requirements: FR1.4, FR1.6, FR1.9, FR1.13
|
|
22
|
+
# Related architecture: CC1.1
|
|
23
|
+
|
|
24
|
+
"""Structured application logger for cloud_dog_logging."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
import sys
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from cloud_dog_logging.exceptions import format_exception
|
|
33
|
+
from cloud_dog_logging.redaction import RedactionEngine
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AppLogger:
|
|
37
|
+
"""Structured application logger with redaction support.
|
|
38
|
+
|
|
39
|
+
Wraps a standard Python logger and applies secret redaction to all
|
|
40
|
+
extra fields before logging. Automatically includes correlation ID
|
|
41
|
+
via the configured formatter.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
logger: The underlying Python logger.
|
|
45
|
+
redaction_engine: Redaction engine for extra fields. If None,
|
|
46
|
+
a default engine is created.
|
|
47
|
+
|
|
48
|
+
Related tests: UT1.7_AppLogger
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
logger: logging.Logger,
|
|
54
|
+
redaction_engine: RedactionEngine | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._logger = logger
|
|
57
|
+
self._redaction = redaction_engine or RedactionEngine()
|
|
58
|
+
|
|
59
|
+
def _redact_extra(self, extra: dict[str, Any] | None) -> dict[str, Any]:
|
|
60
|
+
"""Redact sensitive values from extra fields.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
extra: The extra fields dictionary.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
A redacted copy of the extra fields.
|
|
67
|
+
"""
|
|
68
|
+
if not extra:
|
|
69
|
+
return {}
|
|
70
|
+
return self._redaction.redact(extra)
|
|
71
|
+
|
|
72
|
+
def debug(self, msg: str, **extra: Any) -> None:
|
|
73
|
+
"""Log a DEBUG-level message.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
msg: The log message.
|
|
77
|
+
**extra: Additional context fields (will be redacted).
|
|
78
|
+
"""
|
|
79
|
+
self._logger.debug(msg, extra=self._redact_extra(extra))
|
|
80
|
+
|
|
81
|
+
def info(self, msg: str, **extra: Any) -> None:
|
|
82
|
+
"""Log an INFO-level message.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
msg: The log message.
|
|
86
|
+
**extra: Additional context fields (will be redacted).
|
|
87
|
+
"""
|
|
88
|
+
self._logger.info(msg, extra=self._redact_extra(extra))
|
|
89
|
+
|
|
90
|
+
def warning(self, msg: str, **extra: Any) -> None:
|
|
91
|
+
"""Log a WARNING-level message.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
msg: The log message.
|
|
95
|
+
**extra: Additional context fields (will be redacted).
|
|
96
|
+
"""
|
|
97
|
+
self._logger.warning(msg, extra=self._redact_extra(extra))
|
|
98
|
+
|
|
99
|
+
def error(self, msg: str, **extra: Any) -> None:
|
|
100
|
+
"""Log an ERROR-level message.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
msg: The log message.
|
|
104
|
+
**extra: Additional context fields (will be redacted).
|
|
105
|
+
"""
|
|
106
|
+
self._logger.error(msg, extra=self._redact_extra(extra))
|
|
107
|
+
|
|
108
|
+
def critical(self, msg: str, **extra: Any) -> None:
|
|
109
|
+
"""Log a CRITICAL-level message.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
msg: The log message.
|
|
113
|
+
**extra: Additional context fields (will be redacted).
|
|
114
|
+
"""
|
|
115
|
+
self._logger.critical(msg, extra=self._redact_extra(extra))
|
|
116
|
+
|
|
117
|
+
def exception(self, msg: str, **extra: Any) -> None:
|
|
118
|
+
"""Log an ERROR-level message with exception information.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
msg: The log message.
|
|
122
|
+
**extra: Additional context fields (will be redacted).
|
|
123
|
+
"""
|
|
124
|
+
_, exc_value, _ = sys.exc_info()
|
|
125
|
+
payload = dict(extra)
|
|
126
|
+
if isinstance(exc_value, BaseException):
|
|
127
|
+
payload["exception"] = format_exception(exc_value)
|
|
128
|
+
self._logger.error(msg, exc_info=True, extra=self._redact_extra(payload))
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def level(self) -> int:
|
|
132
|
+
"""Return the effective log level."""
|
|
133
|
+
return self._logger.getEffectiveLevel()
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def name(self) -> str:
|
|
137
|
+
"""Return the logger name."""
|
|
138
|
+
return self._logger.name
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def underlying_logger(self) -> logging.Logger:
|
|
142
|
+
"""Return the underlying stdlib logger for advanced use."""
|
|
143
|
+
return self._logger
|