mobius-logging-py 1.0.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.
- mobius_logging/__init__.py +80 -0
- mobius_logging/bootstrap.py +120 -0
- mobius_logging/constants.py +32 -0
- mobius_logging/context.py +140 -0
- mobius_logging/fields.py +83 -0
- mobius_logging/kafka_context.py +57 -0
- mobius_logging/kafka_producer.py +127 -0
- mobius_logging/log_type.py +20 -0
- mobius_logging/logging_config.py +122 -0
- mobius_logging/masking.py +78 -0
- mobius_logging/middleware.py +65 -0
- mobius_logging/observed.py +115 -0
- mobius_logging/py.typed +0 -0
- mobius_logging_py-1.0.0.dist-info/METADATA +211 -0
- mobius_logging_py-1.0.0.dist-info/RECORD +17 -0
- mobius_logging_py-1.0.0.dist-info/WHEEL +5 -0
- mobius_logging_py-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Mobius Logging for Python.
|
|
2
|
+
|
|
3
|
+
Python port of the Java Mobius logging library: standardized logging context,
|
|
4
|
+
method observation, sensitive-data masking, OpenTelemetry trace sync, JSON
|
|
5
|
+
logging and a Kafka schema-event producer for FastAPI / Starlette services.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from . import context, fields
|
|
10
|
+
from .context import (
|
|
11
|
+
clear,
|
|
12
|
+
correlation_id,
|
|
13
|
+
ensure_correlation_id,
|
|
14
|
+
field,
|
|
15
|
+
get,
|
|
16
|
+
init,
|
|
17
|
+
log_type,
|
|
18
|
+
put,
|
|
19
|
+
requester_type,
|
|
20
|
+
resource_id,
|
|
21
|
+
resource_type,
|
|
22
|
+
restore,
|
|
23
|
+
snapshot,
|
|
24
|
+
sync_trace_from_opentelemetry,
|
|
25
|
+
tenant_id,
|
|
26
|
+
transaction_id,
|
|
27
|
+
)
|
|
28
|
+
from .kafka_context import apply_consumer_record, producer_headers
|
|
29
|
+
from .kafka_producer import (
|
|
30
|
+
KafkaLogProducer,
|
|
31
|
+
LogEventSender,
|
|
32
|
+
PyKafkaProducerClientSender,
|
|
33
|
+
get_log_producer,
|
|
34
|
+
set_log_producer,
|
|
35
|
+
)
|
|
36
|
+
from .log_type import LogType
|
|
37
|
+
from .logging_config import configure_platform_logging
|
|
38
|
+
from .masking import init_custom_keys, mask, mask_map
|
|
39
|
+
from .middleware import PlatformLoggingMiddleware
|
|
40
|
+
from .observed import observed
|
|
41
|
+
from .bootstrap import setup_logging
|
|
42
|
+
|
|
43
|
+
__version__ = "1.0.0"
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"context",
|
|
47
|
+
"fields",
|
|
48
|
+
"LogType",
|
|
49
|
+
"PlatformLoggingMiddleware",
|
|
50
|
+
"observed",
|
|
51
|
+
"setup_logging",
|
|
52
|
+
"configure_platform_logging",
|
|
53
|
+
"KafkaLogProducer",
|
|
54
|
+
"LogEventSender",
|
|
55
|
+
"PyKafkaProducerClientSender",
|
|
56
|
+
"set_log_producer",
|
|
57
|
+
"get_log_producer",
|
|
58
|
+
"producer_headers",
|
|
59
|
+
"apply_consumer_record",
|
|
60
|
+
"init_custom_keys",
|
|
61
|
+
"mask",
|
|
62
|
+
"mask_map",
|
|
63
|
+
# context functions
|
|
64
|
+
"put",
|
|
65
|
+
"get",
|
|
66
|
+
"field",
|
|
67
|
+
"init",
|
|
68
|
+
"clear",
|
|
69
|
+
"snapshot",
|
|
70
|
+
"restore",
|
|
71
|
+
"log_type",
|
|
72
|
+
"tenant_id",
|
|
73
|
+
"resource_id",
|
|
74
|
+
"resource_type",
|
|
75
|
+
"correlation_id",
|
|
76
|
+
"transaction_id",
|
|
77
|
+
"requester_type",
|
|
78
|
+
"ensure_correlation_id",
|
|
79
|
+
"sync_trace_from_opentelemetry",
|
|
80
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""One-call bootstrap for FastAPI services.
|
|
2
|
+
|
|
3
|
+
Wires logging configuration, sensitive-key registration, the schema producer
|
|
4
|
+
and the request middleware in a single call - the rough equivalent of the
|
|
5
|
+
Java auto-configuration classes (which Spring applies automatically; Python
|
|
6
|
+
has no auto-config, so this is explicit).
|
|
7
|
+
|
|
8
|
+
Example::
|
|
9
|
+
|
|
10
|
+
from kafka_producer_client import KafkaProducerConfig
|
|
11
|
+
from mobius_logging import setup_logging
|
|
12
|
+
|
|
13
|
+
setup_logging(
|
|
14
|
+
app,
|
|
15
|
+
service_name="orders-service",
|
|
16
|
+
profile="prod",
|
|
17
|
+
sensitive_keys=["session_id", "refresh_token"],
|
|
18
|
+
kafka_config=KafkaProducerConfig(
|
|
19
|
+
bootstrap_servers="broker:9092",
|
|
20
|
+
default_topic="construct-data-1",
|
|
21
|
+
),
|
|
22
|
+
)
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
from typing import Any, Iterable, Optional
|
|
28
|
+
|
|
29
|
+
from . import masking
|
|
30
|
+
from .kafka_producer import (
|
|
31
|
+
KafkaLogProducer,
|
|
32
|
+
LogEventSender,
|
|
33
|
+
PyKafkaProducerClientSender,
|
|
34
|
+
set_log_producer,
|
|
35
|
+
)
|
|
36
|
+
from .logging_config import configure_platform_logging
|
|
37
|
+
from .middleware import PlatformLoggingMiddleware
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def setup_logging(
|
|
43
|
+
app: Optional[Any] = None,
|
|
44
|
+
*,
|
|
45
|
+
service_name: str = "unknown-service",
|
|
46
|
+
profile: Optional[str] = None,
|
|
47
|
+
sensitive_keys: Optional[Iterable[str]] = None,
|
|
48
|
+
kafka_config: Optional[Any] = None,
|
|
49
|
+
kafka_producer: Optional[Any] = None,
|
|
50
|
+
kafka_sender: Optional[LogEventSender] = None,
|
|
51
|
+
enable_file: bool = True,
|
|
52
|
+
) -> Optional[KafkaLogProducer]:
|
|
53
|
+
"""Configure the library and (optionally) register FastAPI middleware.
|
|
54
|
+
|
|
55
|
+
The schema producer is resolved in this order:
|
|
56
|
+
|
|
57
|
+
1. ``kafka_sender`` - any object with ``send(value, *, topic)``;
|
|
58
|
+
2. ``kafka_producer`` - a ``py-kafka-producer-client`` ``KafkaProducerClient``
|
|
59
|
+
instance, wrapped automatically;
|
|
60
|
+
3. ``kafka_config`` - a ``KafkaProducerConfig``; the client's singleton is
|
|
61
|
+
configured and used (the default integration);
|
|
62
|
+
4. the already-configured ``py-kafka-producer-client`` singleton, if the app
|
|
63
|
+
called ``configure_kafka_producer(...)`` itself.
|
|
64
|
+
|
|
65
|
+
If none resolve, ``@observed`` simply skips schema publishing.
|
|
66
|
+
|
|
67
|
+
Returns the registered :class:`KafkaLogProducer`, if any.
|
|
68
|
+
"""
|
|
69
|
+
configure_platform_logging(service_name, profile, enable_file=enable_file)
|
|
70
|
+
masking.init_custom_keys(sensitive_keys)
|
|
71
|
+
|
|
72
|
+
sender = _resolve_sender(kafka_sender, kafka_producer, kafka_config)
|
|
73
|
+
|
|
74
|
+
producer: Optional[KafkaLogProducer] = None
|
|
75
|
+
if sender is not None:
|
|
76
|
+
producer = KafkaLogProducer(sender, service_name)
|
|
77
|
+
set_log_producer(producer)
|
|
78
|
+
else:
|
|
79
|
+
log.warning(
|
|
80
|
+
"mobius-logging: no Kafka producer resolved; @observed will not "
|
|
81
|
+
"publish schema events. Pass kafka_config/kafka_producer/kafka_sender."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if app is not None:
|
|
85
|
+
app.add_middleware(PlatformLoggingMiddleware, service_name=service_name)
|
|
86
|
+
|
|
87
|
+
return producer
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_sender(
|
|
91
|
+
kafka_sender: Optional[LogEventSender],
|
|
92
|
+
kafka_producer: Optional[Any],
|
|
93
|
+
kafka_config: Optional[Any],
|
|
94
|
+
) -> Optional[LogEventSender]:
|
|
95
|
+
if kafka_sender is not None:
|
|
96
|
+
return kafka_sender
|
|
97
|
+
if kafka_producer is not None:
|
|
98
|
+
return PyKafkaProducerClientSender(kafka_producer)
|
|
99
|
+
|
|
100
|
+
# Default: integrate with the internal py-kafka-producer-client singleton.
|
|
101
|
+
try:
|
|
102
|
+
from kafka_producer_client.action_logger import (
|
|
103
|
+
configure_kafka_producer,
|
|
104
|
+
get_kafka_producer,
|
|
105
|
+
)
|
|
106
|
+
except ImportError:
|
|
107
|
+
log.warning("mobius-logging: py-kafka-producer-client not installed.")
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
if kafka_config is not None:
|
|
112
|
+
configure_kafka_producer(kafka_config)
|
|
113
|
+
return PyKafkaProducerClientSender(get_kafka_producer())
|
|
114
|
+
except Exception: # noqa: BLE001 - producer is optional, never crash setup
|
|
115
|
+
log.warning(
|
|
116
|
+
"mobius-logging: could not initialize py-kafka-producer-client "
|
|
117
|
+
"(was configure_kafka_producer/kafka_config provided?).",
|
|
118
|
+
exc_info=True,
|
|
119
|
+
)
|
|
120
|
+
return None
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Platform constants. Mirrors the Java ``PlatformConstant``.
|
|
2
|
+
|
|
3
|
+
The Kafka topic, schema id and tenant id are kept identical to the Java
|
|
4
|
+
library on purpose: Python services emit into the same schema pipeline.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
SCHEMA_VERSION = "mobius.log.v1"
|
|
11
|
+
|
|
12
|
+
# Masking
|
|
13
|
+
EMAIL_RE = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$")
|
|
14
|
+
# Mask every word char that is followed by at least 4 more word chars,
|
|
15
|
+
# i.e. keep the last 4 characters visible. Matches the Java MASKING_REGEX.
|
|
16
|
+
MASKING_RE = re.compile(r"\w(?=\w{4})")
|
|
17
|
+
MASKING_PATTERN = "*****"
|
|
18
|
+
AT_SYMBOL = "@"
|
|
19
|
+
STAR = "*"
|
|
20
|
+
|
|
21
|
+
# Kafka schema pipeline (must match the Java library)
|
|
22
|
+
TOPIC = "construct-data-1"
|
|
23
|
+
TENANT_ID = "2cf76e5f-26ad-4f2c-bccc-f4bc1e7bfb64"
|
|
24
|
+
SCHEMA_ID = "6a2f9b55f7827435c6ff87c4"
|
|
25
|
+
|
|
26
|
+
# Legacy -> canonical key remapping applied before shipping to the schema.
|
|
27
|
+
SANITIZE_KEYS = {
|
|
28
|
+
"agentId": "agent_id",
|
|
29
|
+
"userId": "user_id",
|
|
30
|
+
"tenantId": "tenant_id",
|
|
31
|
+
"txnId": "txn_id",
|
|
32
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Logging context store - the Python analog of SLF4J's MDC.
|
|
2
|
+
|
|
3
|
+
Java uses ``org.slf4j.MDC`` (a thread-local). Python's stdlib ``logging`` has
|
|
4
|
+
no MDC, so we back the context with a :class:`contextvars.ContextVar`. That
|
|
5
|
+
gives correct propagation for both threads and ``asyncio`` tasks (each request
|
|
6
|
+
handled by FastAPI runs in its own context).
|
|
7
|
+
|
|
8
|
+
All mutations are copy-on-write: we never mutate the dict stored in the
|
|
9
|
+
ContextVar in place, because child tasks inherit the *same* dict reference and
|
|
10
|
+
in-place mutation would leak across tasks.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextvars
|
|
15
|
+
import uuid
|
|
16
|
+
from typing import Dict, Optional
|
|
17
|
+
|
|
18
|
+
from . import fields
|
|
19
|
+
from .constants import SCHEMA_VERSION
|
|
20
|
+
|
|
21
|
+
_context: contextvars.ContextVar[Optional[Dict[str, str]]] = contextvars.ContextVar(
|
|
22
|
+
"mobius_log_context", default=None
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _current() -> Dict[str, str]:
|
|
27
|
+
return _context.get() or {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def put(key: str, value: Optional[str]) -> None:
|
|
31
|
+
"""Store a field. No-op when the value is ``None`` or blank (matches Java)."""
|
|
32
|
+
if value is None:
|
|
33
|
+
return
|
|
34
|
+
value = str(value)
|
|
35
|
+
if not value.strip():
|
|
36
|
+
return
|
|
37
|
+
updated = dict(_current())
|
|
38
|
+
updated[key] = value
|
|
39
|
+
_context.set(updated)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get(key: str) -> Optional[str]:
|
|
43
|
+
return _current().get(key)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def copy() -> Dict[str, str]:
|
|
47
|
+
"""Return a shallow copy of the current context (never the live dict)."""
|
|
48
|
+
return dict(_current())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ``snapshot`` is an alias kept for parity with the Java naming.
|
|
52
|
+
snapshot = copy
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def restore(context: Optional[Dict[str, str]]) -> None:
|
|
56
|
+
_context.set(dict(context) if context else {})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def remove(*keys: str) -> None:
|
|
60
|
+
current = _current()
|
|
61
|
+
if not any(k in current for k in keys):
|
|
62
|
+
return
|
|
63
|
+
updated = dict(current)
|
|
64
|
+
for key in keys:
|
|
65
|
+
updated.pop(key, None)
|
|
66
|
+
_context.set(updated)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def clear() -> None:
|
|
70
|
+
_context.set({})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def init() -> None:
|
|
74
|
+
put(fields.SCHEMA_VERSION, SCHEMA_VERSION)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --- convenience setters (mirror PlatformLogContext) ---
|
|
78
|
+
|
|
79
|
+
def log_type(value) -> None:
|
|
80
|
+
if value is None:
|
|
81
|
+
return
|
|
82
|
+
# Accept LogType or raw string.
|
|
83
|
+
put(fields.LOG_TYPE, getattr(value, "value", value))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def resource_id(value: Optional[str]) -> None:
|
|
87
|
+
put(fields.RESOURCE_ID, value)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resource_type(value: Optional[str]) -> None:
|
|
91
|
+
put(fields.RESOURCE_TYPE, value)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def correlation_id(value: Optional[str]) -> None:
|
|
95
|
+
put(fields.CORRELATION_ID, value)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def transaction_id(value: Optional[str]) -> None:
|
|
99
|
+
put(fields.TRANSACTION_ID, value)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def requester_type(value: Optional[str]) -> None:
|
|
103
|
+
put(fields.REQUESTER_TYPE, value)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def tenant_id(value: Optional[str]) -> None:
|
|
107
|
+
put(fields.TENANT_ID, value)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def field(key: str, value: Optional[str]) -> None:
|
|
111
|
+
put(key, value)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ensure_correlation_id() -> str:
|
|
115
|
+
"""Guarantee a ``correlation_id`` exists, generating one when missing.
|
|
116
|
+
|
|
117
|
+
NOTE: the Java implementation has an inverted condition and only
|
|
118
|
+
regenerates when one *already* exists; this is the corrected behaviour.
|
|
119
|
+
Returns the effective correlation id.
|
|
120
|
+
"""
|
|
121
|
+
existing = get(fields.CORRELATION_ID)
|
|
122
|
+
if existing and existing.strip():
|
|
123
|
+
return existing
|
|
124
|
+
generated = str(uuid.uuid4())
|
|
125
|
+
put(fields.CORRELATION_ID, generated)
|
|
126
|
+
return generated
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def sync_trace_from_opentelemetry() -> None:
|
|
130
|
+
"""Copy the current valid OpenTelemetry span context into the log context."""
|
|
131
|
+
try:
|
|
132
|
+
from opentelemetry import trace # type: ignore
|
|
133
|
+
except ImportError:
|
|
134
|
+
return
|
|
135
|
+
span = trace.get_current_span()
|
|
136
|
+
ctx = span.get_span_context()
|
|
137
|
+
if ctx is None or not ctx.is_valid:
|
|
138
|
+
return
|
|
139
|
+
put(fields.TRACE_ID, format(ctx.trace_id, "032x"))
|
|
140
|
+
put(fields.SPAN_ID, format(ctx.span_id, "016x"))
|
mobius_logging/fields.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Centralized MDC-style field names.
|
|
2
|
+
|
|
3
|
+
Mirrors ``PlatformLogFields`` from the Java Mobius logging library so events
|
|
4
|
+
emitted by Python services share the exact same field vocabulary.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
# --- envelope ---
|
|
9
|
+
SCHEMA_VERSION = "schema_version"
|
|
10
|
+
TIMESTAMP = "timestamp"
|
|
11
|
+
ID = "id"
|
|
12
|
+
SERVICE_NAME = "service_name"
|
|
13
|
+
LOG_TYPE = "log_type"
|
|
14
|
+
EVENT_TYPE = "event_type"
|
|
15
|
+
RESOURCE_TYPE = "resource_type"
|
|
16
|
+
RESOURCE_ID = "resource_id"
|
|
17
|
+
OUTCOME = "outcome"
|
|
18
|
+
METHOD_DURATION_MS = "method_duration_ms"
|
|
19
|
+
EXCEPTION_TYPE = "exception_type"
|
|
20
|
+
EXCEPTION_MESSAGE = "exception_message"
|
|
21
|
+
|
|
22
|
+
# --- trace / correlation ---
|
|
23
|
+
TRACE_ID = "trace_id"
|
|
24
|
+
SPAN_ID = "span_id"
|
|
25
|
+
CORRELATION_ID = "correlation_id"
|
|
26
|
+
CAUSATION_ID = "causation_id"
|
|
27
|
+
TRANSACTION_ID = "txn_id"
|
|
28
|
+
|
|
29
|
+
# --- identity ---
|
|
30
|
+
PARENT_TENANT_ID = "parent_tenant_id"
|
|
31
|
+
TENANT_ID = "tenant_id"
|
|
32
|
+
TENANT_USER_ID = "tenant_user_id"
|
|
33
|
+
CONSUMER_ID = "consumer_id"
|
|
34
|
+
AGENT_ID = "agent_id"
|
|
35
|
+
AGENT_USER_ID = "agent_user_id"
|
|
36
|
+
REQUESTER_TYPE = "requester_type"
|
|
37
|
+
|
|
38
|
+
# --- http ---
|
|
39
|
+
HTTP_DURATION_MS = "http_duration_ms"
|
|
40
|
+
HTTP_METHOD = "http_method"
|
|
41
|
+
HTTP_PATH = "http_path"
|
|
42
|
+
HTTP_STATUS = "http_status"
|
|
43
|
+
APP_ID = "app_id"
|
|
44
|
+
PLATFORM_ID = "platform_id"
|
|
45
|
+
|
|
46
|
+
# --- code location ---
|
|
47
|
+
CLASS_NAME = "class_name"
|
|
48
|
+
METHOD_NAME = "method_name"
|
|
49
|
+
|
|
50
|
+
# --- workflow ---
|
|
51
|
+
WORKFLOW_ENGINE = "workflow_engine"
|
|
52
|
+
PROCESS_INSTANCE_ID = "process_instance_id"
|
|
53
|
+
PROCESS_DEFINITION_ID = "process_definition_id"
|
|
54
|
+
ACTIVITY_ID = "activity_id"
|
|
55
|
+
BUSINESS_KEY = "business_key"
|
|
56
|
+
|
|
57
|
+
# --- kafka ---
|
|
58
|
+
KAFKA_TOPIC = "kafka_topic"
|
|
59
|
+
KAFKA_PARTITION = "kafka_partition"
|
|
60
|
+
KAFKA_OFFSET = "kafka_offset"
|
|
61
|
+
|
|
62
|
+
# --- database ---
|
|
63
|
+
DB_DURATION_MS = "db_duration_ms"
|
|
64
|
+
DB_SYSTEM = "db_system"
|
|
65
|
+
DB_NAME = "db_name"
|
|
66
|
+
DB_DATASOURCE = "db_datasource"
|
|
67
|
+
DB_OPERATION = "db_operation"
|
|
68
|
+
DB_STATEMENT_TYPE = "db_statement_type"
|
|
69
|
+
DB_SQL_TEMPLATE = "db_sql_template"
|
|
70
|
+
DB_SQL_HASH = "db_sql_hash"
|
|
71
|
+
DB_TABLE = "db_table"
|
|
72
|
+
DB_ROW_COUNT = "db_row_count"
|
|
73
|
+
DB_BATCH_SIZE = "db_batch_size"
|
|
74
|
+
DB_OUTCOME = "db_outcome"
|
|
75
|
+
|
|
76
|
+
# Fields copied into Kafka headers by the producer/consumer helpers.
|
|
77
|
+
KAFKA_PROPAGATED_FIELDS = (
|
|
78
|
+
TRACE_ID,
|
|
79
|
+
SPAN_ID,
|
|
80
|
+
CORRELATION_ID,
|
|
81
|
+
CAUSATION_ID,
|
|
82
|
+
TENANT_ID,
|
|
83
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Kafka context propagation helpers - analog of the Java
|
|
2
|
+
``PlatformKafkaProducerContext`` / ``PlatformKafkaConsumerContext``.
|
|
3
|
+
|
|
4
|
+
Python Kafka clients represent headers as a list of ``(key, bytes)`` tuples
|
|
5
|
+
(confluent-kafka, aiokafka, kafka-python all accept this shape), so these
|
|
6
|
+
helpers work with that representation rather than a specific client type.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Dict, Iterable, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from . import context, fields
|
|
13
|
+
|
|
14
|
+
Header = Tuple[str, bytes]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def producer_headers(existing: Optional[Iterable[Header]] = None) -> List[Header]:
|
|
18
|
+
"""Return headers augmented with the propagated context fields.
|
|
19
|
+
|
|
20
|
+
Pass the result as the ``headers=`` argument when producing a record::
|
|
21
|
+
|
|
22
|
+
producer.send(topic, value, headers=producer_headers())
|
|
23
|
+
"""
|
|
24
|
+
headers: List[Header] = list(existing or [])
|
|
25
|
+
present = {k for k, _ in headers}
|
|
26
|
+
for field in fields.KAFKA_PROPAGATED_FIELDS:
|
|
27
|
+
if field in present:
|
|
28
|
+
continue
|
|
29
|
+
value = context.get(field)
|
|
30
|
+
if value:
|
|
31
|
+
headers.append((field, value.encode("utf-8")))
|
|
32
|
+
return headers
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def apply_consumer_record(
|
|
36
|
+
headers: Optional[Iterable[Header]],
|
|
37
|
+
topic: Optional[str] = None,
|
|
38
|
+
partition: Optional[int] = None,
|
|
39
|
+
offset: Optional[int] = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Populate the log context from an incoming record's headers + metadata."""
|
|
42
|
+
header_map: Dict[str, bytes] = {}
|
|
43
|
+
for key, value in headers or []:
|
|
44
|
+
header_map[key] = value # last write wins, mirrors lastHeader()
|
|
45
|
+
|
|
46
|
+
for field in fields.KAFKA_PROPAGATED_FIELDS:
|
|
47
|
+
raw = header_map.get(field)
|
|
48
|
+
if raw is not None:
|
|
49
|
+
decoded = raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw)
|
|
50
|
+
context.put(field, decoded)
|
|
51
|
+
|
|
52
|
+
if topic is not None:
|
|
53
|
+
context.put(fields.KAFKA_TOPIC, topic)
|
|
54
|
+
if partition is not None:
|
|
55
|
+
context.put(fields.KAFKA_PARTITION, str(partition))
|
|
56
|
+
if offset is not None:
|
|
57
|
+
context.put(fields.KAFKA_OFFSET, str(offset))
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Schema event producer. Mirrors the Java ``KafkaLogProducer``.
|
|
2
|
+
|
|
3
|
+
The Java version depends directly on ``kafka-wrapper-lib`` and a Spring
|
|
4
|
+
``KafkaTemplate``. To keep this library framework- and client-agnostic, the
|
|
5
|
+
producer here depends only on a small :class:`LogEventSender` protocol. Mobius
|
|
6
|
+
FastAPI services use ``py-kafka-producer-client``; wrap it with
|
|
7
|
+
:class:`PyKafkaProducerClientSender` (or any object exposing ``send``).
|
|
8
|
+
|
|
9
|
+
Sending is best-effort and runs on a background thread pool so it never blocks
|
|
10
|
+
the request path (the Java method is ``@Async``). Failures are logged, never
|
|
11
|
+
raised.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import uuid
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
18
|
+
from dataclasses import asdict, dataclass
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import Any, Dict, Optional, Protocol, runtime_checkable
|
|
21
|
+
|
|
22
|
+
from . import constants, context, fields, masking
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="mobius-log-producer")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class LogEventSender(Protocol):
|
|
31
|
+
"""Anything able to publish a dict event to a topic.
|
|
32
|
+
|
|
33
|
+
Matches the ``py-kafka-producer-client`` shape: ``value`` is a dict and
|
|
34
|
+
``topic`` is keyword-only.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def send(self, value: Dict[str, Any], *, topic: str) -> Any: # pragma: no cover
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PyKafkaProducerClientSender:
|
|
42
|
+
"""Adapter for the internal ``py-kafka-producer-client`` (>=0.1.7).
|
|
43
|
+
|
|
44
|
+
Targets its signature::
|
|
45
|
+
|
|
46
|
+
producer.send(value: dict, *, topic: str | None = None,
|
|
47
|
+
key=None, headers=None, on_delivery=None) -> None
|
|
48
|
+
|
|
49
|
+
so ``value`` is passed as a dict and ``topic`` as a keyword argument.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, producer: Any) -> None:
|
|
53
|
+
self._producer = producer
|
|
54
|
+
|
|
55
|
+
def send(self, value: Dict[str, Any], *, topic: str) -> Any:
|
|
56
|
+
return self._producer.send(value, topic=topic)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class DataIngestionOperation:
|
|
61
|
+
"""Mirrors the Java ``DataIngestionOperation`` payload envelope."""
|
|
62
|
+
|
|
63
|
+
actionType: str
|
|
64
|
+
object: Dict[str, str]
|
|
65
|
+
id: str
|
|
66
|
+
schemaId: str
|
|
67
|
+
tenantId: str
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
70
|
+
return asdict(self)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class KafkaLogProducer:
|
|
74
|
+
def __init__(self, sender: LogEventSender, service_name: str) -> None:
|
|
75
|
+
self._sender = sender
|
|
76
|
+
self._service_name = service_name
|
|
77
|
+
|
|
78
|
+
def send_to_schema(self) -> None:
|
|
79
|
+
"""Snapshot the current context and ship it to the schema topic."""
|
|
80
|
+
context_map = masking.mask_map(context.copy())
|
|
81
|
+
if not context_map:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
event_id = str(uuid.uuid4())
|
|
85
|
+
context_map[fields.SERVICE_NAME] = self._service_name
|
|
86
|
+
context_map[fields.ID] = event_id
|
|
87
|
+
self._sanitize(context_map)
|
|
88
|
+
|
|
89
|
+
payload = DataIngestionOperation(
|
|
90
|
+
actionType="CREATE",
|
|
91
|
+
object=context_map,
|
|
92
|
+
id=event_id,
|
|
93
|
+
schemaId=constants.SCHEMA_ID,
|
|
94
|
+
tenantId=constants.TENANT_ID,
|
|
95
|
+
)
|
|
96
|
+
# Fire and forget; capture the already-built payload by value.
|
|
97
|
+
_executor.submit(self._send, payload.to_dict())
|
|
98
|
+
|
|
99
|
+
def _send(self, value: Dict[str, Any]) -> None:
|
|
100
|
+
try:
|
|
101
|
+
self._sender.send(value, topic=constants.TOPIC)
|
|
102
|
+
except Exception: # noqa: BLE001 - never break the caller
|
|
103
|
+
log.warning("Failed to publish log event to schema topic", exc_info=True)
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _sanitize(context_map: Dict[str, str]) -> None:
|
|
107
|
+
context_map[fields.TIMESTAMP] = datetime.now(timezone.utc).isoformat()
|
|
108
|
+
for legacy in ("uri", "method", "bagentId", "requestId"):
|
|
109
|
+
context_map.pop(legacy, None)
|
|
110
|
+
for legacy_key, canonical_key in constants.SANITIZE_KEYS.items():
|
|
111
|
+
if legacy_key in context_map:
|
|
112
|
+
context_map[canonical_key] = context_map.pop(legacy_key)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# --- module-level default producer (set at app startup) ---
|
|
116
|
+
|
|
117
|
+
_default_producer: Optional[KafkaLogProducer] = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def set_log_producer(producer: Optional[KafkaLogProducer]) -> None:
|
|
121
|
+
"""Register the producer used by the ``@observed`` decorator."""
|
|
122
|
+
global _default_producer
|
|
123
|
+
_default_producer = producer
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_log_producer() -> Optional[KafkaLogProducer]:
|
|
127
|
+
return _default_producer
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Log categories. Mirrors the Java ``LogType`` enum."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LogType(str, Enum):
|
|
8
|
+
APPLICATION = "application_log"
|
|
9
|
+
AUDIT = "audit_log"
|
|
10
|
+
SECURITY = "security_log"
|
|
11
|
+
WORKFLOW = "workflow_log"
|
|
12
|
+
DEPLOYMENT = "deployment_log"
|
|
13
|
+
POLICY = "policy_log"
|
|
14
|
+
INFRASTRUCTURE = "infrastructure_log"
|
|
15
|
+
INTEGRATION = "integration_log"
|
|
16
|
+
DATABASE = "database_log"
|
|
17
|
+
|
|
18
|
+
def value_(self) -> str:
|
|
19
|
+
"""Explicit accessor to parallel the Java ``value()`` method."""
|
|
20
|
+
return self.value
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Logging bootstrap - the Python analog of the Java ``LogbackConfigurator``.
|
|
2
|
+
|
|
3
|
+
Configures the root logger with:
|
|
4
|
+
- a context-injecting filter so every record carries the current log context;
|
|
5
|
+
- JSON console output for ``staging`` / ``prod`` / ``production`` profiles, and
|
|
6
|
+
a human-readable pattern otherwise;
|
|
7
|
+
- a daily rotating file handler under ``logs/<service>.log``;
|
|
8
|
+
- reduced noise levels for chatty third-party loggers.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from logging.handlers import TimedRotatingFileHandler
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from . import context
|
|
21
|
+
|
|
22
|
+
_PROD_PROFILES = {"staging", "prod", "production"}
|
|
23
|
+
|
|
24
|
+
# Standard LogRecord attributes we never treat as "context" fields.
|
|
25
|
+
_RESERVED = set(
|
|
26
|
+
vars(logging.makeLogRecord({})).keys()
|
|
27
|
+
) | {"message", "asctime", "taskName"}
|
|
28
|
+
|
|
29
|
+
_TEXT_FORMAT = (
|
|
30
|
+
"%(asctime)s [%(threadName)s] "
|
|
31
|
+
"[txn:%(txn_id)s] [tenant:%(tenant_id)s] [trace:%(trace_id)s] "
|
|
32
|
+
"%(levelname)-5s %(name)s - %(message)s"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ContextInjectingFilter(logging.Filter):
|
|
37
|
+
"""Copies the current log context onto every record.
|
|
38
|
+
|
|
39
|
+
Mirrors how Logback reads MDC: makes context fields available to the
|
|
40
|
+
formatter (e.g. ``%(trace_id)s``) and to the JSON encoder.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
# Fields surfaced in the text pattern; default to NA when absent.
|
|
44
|
+
_PATTERN_DEFAULTS = {"txn_id": "NA", "tenant_id": "NA", "trace_id": "NA"}
|
|
45
|
+
|
|
46
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
47
|
+
ctx = context.copy()
|
|
48
|
+
record._mobius_context = ctx # used by JsonFormatter
|
|
49
|
+
for key, default in self._PATTERN_DEFAULTS.items():
|
|
50
|
+
if not hasattr(record, key):
|
|
51
|
+
setattr(record, key, ctx.get(key, default))
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class JsonFormatter(logging.Formatter):
|
|
56
|
+
def __init__(self, service_name: str) -> None:
|
|
57
|
+
super().__init__()
|
|
58
|
+
self.service_name = service_name
|
|
59
|
+
|
|
60
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
61
|
+
payload = {
|
|
62
|
+
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
|
|
63
|
+
"level": record.levelname,
|
|
64
|
+
"service": self.service_name,
|
|
65
|
+
"logger": record.name,
|
|
66
|
+
"thread": record.threadName,
|
|
67
|
+
"msg": record.getMessage(),
|
|
68
|
+
}
|
|
69
|
+
payload.update(getattr(record, "_mobius_context", {}))
|
|
70
|
+
if record.exc_info:
|
|
71
|
+
payload["exception"] = self.formatException(record.exc_info)
|
|
72
|
+
return json.dumps(payload, default=str)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def configure_platform_logging(
|
|
76
|
+
service_name: Optional[str] = None,
|
|
77
|
+
profile: Optional[str] = None,
|
|
78
|
+
*,
|
|
79
|
+
log_dir: str = "logs",
|
|
80
|
+
enable_file: bool = True,
|
|
81
|
+
) -> None:
|
|
82
|
+
service_name = service_name or os.getenv("SPRING_APPLICATION_NAME", "unknown-service")
|
|
83
|
+
profile = profile or os.getenv("APP_PROFILE", "")
|
|
84
|
+
is_prod = profile.lower() in _PROD_PROFILES
|
|
85
|
+
|
|
86
|
+
root = logging.getLogger()
|
|
87
|
+
root.setLevel(logging.INFO)
|
|
88
|
+
for handler in list(root.handlers):
|
|
89
|
+
root.removeHandler(handler)
|
|
90
|
+
|
|
91
|
+
context_filter = ContextInjectingFilter()
|
|
92
|
+
|
|
93
|
+
console = logging.StreamHandler()
|
|
94
|
+
console.addFilter(context_filter)
|
|
95
|
+
console.setFormatter(
|
|
96
|
+
JsonFormatter(service_name) if is_prod else logging.Formatter(_TEXT_FORMAT)
|
|
97
|
+
)
|
|
98
|
+
root.addHandler(console)
|
|
99
|
+
|
|
100
|
+
if enable_file:
|
|
101
|
+
Path(log_dir).mkdir(parents=True, exist_ok=True)
|
|
102
|
+
file_handler = TimedRotatingFileHandler(
|
|
103
|
+
filename=os.path.join(log_dir, f"{service_name}.log"),
|
|
104
|
+
when="midnight",
|
|
105
|
+
backupCount=30,
|
|
106
|
+
encoding="utf-8",
|
|
107
|
+
)
|
|
108
|
+
file_handler.addFilter(context_filter)
|
|
109
|
+
file_handler.setFormatter(
|
|
110
|
+
JsonFormatter(service_name) if is_prod else logging.Formatter(_TEXT_FORMAT)
|
|
111
|
+
)
|
|
112
|
+
root.addHandler(file_handler)
|
|
113
|
+
|
|
114
|
+
for noisy in (
|
|
115
|
+
"kafka",
|
|
116
|
+
"pymongo",
|
|
117
|
+
"neo4j",
|
|
118
|
+
"uvicorn.access",
|
|
119
|
+
"asyncio",
|
|
120
|
+
):
|
|
121
|
+
logging.getLogger(noisy).setLevel(logging.WARNING)
|
|
122
|
+
logging.getLogger("mobius_logging").setLevel(logging.INFO)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Sensitive-data masking. Mirrors the Java ``SensitiveDataMasker``.
|
|
2
|
+
|
|
3
|
+
Keys are matched case-insensitively against a default set plus any custom
|
|
4
|
+
keys registered via :func:`init_custom_keys`.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Dict, Iterable, Optional
|
|
9
|
+
|
|
10
|
+
from . import constants
|
|
11
|
+
|
|
12
|
+
_DEFAULT_SENSITIVE = frozenset(
|
|
13
|
+
{
|
|
14
|
+
"password",
|
|
15
|
+
"token",
|
|
16
|
+
"secret",
|
|
17
|
+
"authorization",
|
|
18
|
+
"cookie",
|
|
19
|
+
"apikey",
|
|
20
|
+
"privatekey",
|
|
21
|
+
"kubeconfig",
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_custom_sensitive: set[str] = set()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def init_custom_keys(custom_keys: Optional[Iterable[str]]) -> None:
|
|
29
|
+
if not custom_keys:
|
|
30
|
+
return
|
|
31
|
+
for key in custom_keys:
|
|
32
|
+
if key and key.strip():
|
|
33
|
+
_custom_sensitive.add(key.strip().lower())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def mask_map(input_map: Optional[Dict[str, str]]) -> Dict[str, str]:
|
|
37
|
+
if not input_map:
|
|
38
|
+
return {}
|
|
39
|
+
return {key: mask(key, value) for key, value in input_map.items()}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def mask(key: Optional[str], value: Optional[str]) -> Optional[str]:
|
|
43
|
+
if key is None or value is None:
|
|
44
|
+
return value
|
|
45
|
+
lower = key.lower()
|
|
46
|
+
if lower in _DEFAULT_SENSITIVE or lower in _custom_sensitive:
|
|
47
|
+
return _attribute_masking(value)
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _attribute_masking(value) -> str:
|
|
52
|
+
text = str(value)
|
|
53
|
+
if is_email(text):
|
|
54
|
+
return _mask_email(text)
|
|
55
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
56
|
+
return constants.MASKING_PATTERN
|
|
57
|
+
return _mask_generic(text)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _mask_generic(value: str) -> str:
|
|
61
|
+
if len(value) <= 4:
|
|
62
|
+
return constants.MASKING_PATTERN
|
|
63
|
+
return constants.MASKING_RE.sub(constants.STAR, value)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_email(value: str) -> bool:
|
|
67
|
+
return bool(constants.EMAIL_RE.match(value))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _mask_email(email: str) -> str:
|
|
71
|
+
at_index = email.find(constants.AT_SYMBOL)
|
|
72
|
+
if at_index <= 1:
|
|
73
|
+
return email
|
|
74
|
+
local = email[:at_index]
|
|
75
|
+
domain = email[at_index:]
|
|
76
|
+
if len(local) <= 4:
|
|
77
|
+
return local[0] + constants.MASKING_PATTERN + domain
|
|
78
|
+
return local[:2] + constants.MASKING_PATTERN + local[-2:] + domain
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""FastAPI / Starlette ASGI middleware - the Python analog of the Java
|
|
2
|
+
``PlatformLoggingFilter`` servlet filter.
|
|
3
|
+
|
|
4
|
+
Implemented as a pure ASGI middleware (not Starlette's ``BaseHTTPMiddleware``)
|
|
5
|
+
so the context it seeds shares the same ``contextvars`` context as the route
|
|
6
|
+
handler. ``BaseHTTPMiddleware`` runs the downstream app in a separate task and
|
|
7
|
+
would not propagate the context reliably.
|
|
8
|
+
|
|
9
|
+
Add it as early as possible in the stack to mirror the Java filter's
|
|
10
|
+
``HIGHEST_PRECEDENCE + 10`` ordering::
|
|
11
|
+
|
|
12
|
+
app.add_middleware(PlatformLoggingMiddleware, service_name="orders-service")
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
from . import context, fields
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PlatformLoggingMiddleware:
|
|
25
|
+
def __init__(self, app, service_name: str = "unknown-service") -> None:
|
|
26
|
+
self.app = app
|
|
27
|
+
self.service_name = service_name
|
|
28
|
+
|
|
29
|
+
async def __call__(self, scope, receive, send):
|
|
30
|
+
if scope.get("type") != "http":
|
|
31
|
+
await self.app(scope, receive, send)
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
start = time.monotonic()
|
|
35
|
+
method = scope.get("method", "")
|
|
36
|
+
path = scope.get("path", "")
|
|
37
|
+
|
|
38
|
+
context.clear()
|
|
39
|
+
context.init()
|
|
40
|
+
context.put(fields.SERVICE_NAME, self.service_name)
|
|
41
|
+
context.put(fields.HTTP_METHOD, method)
|
|
42
|
+
context.put(fields.HTTP_PATH, path)
|
|
43
|
+
context.sync_trace_from_opentelemetry()
|
|
44
|
+
context.ensure_correlation_id()
|
|
45
|
+
|
|
46
|
+
status_code = {"value": 0}
|
|
47
|
+
|
|
48
|
+
async def send_wrapper(message):
|
|
49
|
+
if message["type"] == "http.response.start":
|
|
50
|
+
status_code["value"] = message["status"]
|
|
51
|
+
await send(message)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
await self.app(scope, receive, send_wrapper)
|
|
55
|
+
finally:
|
|
56
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
57
|
+
log.info(
|
|
58
|
+
"HTTP request completed, Http Method=%s, Http Path=%s, "
|
|
59
|
+
"Http Status=%s, Http Duration(ms)=%s",
|
|
60
|
+
method,
|
|
61
|
+
path,
|
|
62
|
+
status_code["value"],
|
|
63
|
+
duration_ms,
|
|
64
|
+
)
|
|
65
|
+
context.clear()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""``@observed`` decorator - the Python analog of the Java ``@PlatformObserved``
|
|
2
|
+
AOP aspect.
|
|
3
|
+
|
|
4
|
+
Wraps a function (sync or async) and records:
|
|
5
|
+
``log_type``, ``event_type``, ``resource_type``, ``class_name``,
|
|
6
|
+
``method_name``, ``outcome``, ``method_duration_ms`` and, on failure,
|
|
7
|
+
``exception_type`` / ``exception_message``. After completion it ships the
|
|
8
|
+
context to the schema producer.
|
|
9
|
+
|
|
10
|
+
Unlike the Java aspect, which calls ``MDC.clear()`` afterwards, this restores
|
|
11
|
+
the context snapshot taken before the call - so fields seeded by the request
|
|
12
|
+
middleware survive for later logging in the same request.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import functools
|
|
17
|
+
import inspect
|
|
18
|
+
import logging
|
|
19
|
+
import time
|
|
20
|
+
from typing import Callable, Optional, TypeVar
|
|
21
|
+
|
|
22
|
+
from . import context, fields
|
|
23
|
+
from .kafka_producer import get_log_producer
|
|
24
|
+
from .log_type import LogType
|
|
25
|
+
from .masking import mask_map
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
F = TypeVar("F", bound=Callable)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def observed(
|
|
33
|
+
event_type: str,
|
|
34
|
+
log_type: LogType = LogType.APPLICATION,
|
|
35
|
+
resource_type: str = "",
|
|
36
|
+
log_success: bool = True,
|
|
37
|
+
log_failure: bool = True,
|
|
38
|
+
) -> Callable[[F], F]:
|
|
39
|
+
def decorator(func: F) -> F:
|
|
40
|
+
class_name = getattr(func, "__qualname__", func.__name__)
|
|
41
|
+
# Drop the trailing ".<method>" to approximate the declaring type name.
|
|
42
|
+
declaring = class_name.rsplit(".", 1)[0] if "." in class_name else func.__module__
|
|
43
|
+
method_name = func.__name__
|
|
44
|
+
|
|
45
|
+
def _enter(start: float) -> None:
|
|
46
|
+
context.log_type(log_type)
|
|
47
|
+
context.put(fields.EVENT_TYPE, event_type)
|
|
48
|
+
if resource_type:
|
|
49
|
+
context.put(fields.RESOURCE_TYPE, resource_type)
|
|
50
|
+
context.put(fields.CLASS_NAME, declaring)
|
|
51
|
+
context.put(fields.METHOD_NAME, method_name)
|
|
52
|
+
|
|
53
|
+
def _success(start: float) -> None:
|
|
54
|
+
context.put(fields.OUTCOME, "success")
|
|
55
|
+
context.put(fields.METHOD_DURATION_MS, str(_elapsed_ms(start)))
|
|
56
|
+
if log_success:
|
|
57
|
+
log.info("%s, succeeded, context=%s", event_type, mask_map(context.copy()))
|
|
58
|
+
|
|
59
|
+
def _failure(start: float, exc: BaseException) -> None:
|
|
60
|
+
context.put(fields.OUTCOME, "failure")
|
|
61
|
+
context.put(fields.METHOD_DURATION_MS, str(_elapsed_ms(start)))
|
|
62
|
+
context.put(fields.EXCEPTION_TYPE, type(exc).__name__)
|
|
63
|
+
context.put(fields.EXCEPTION_MESSAGE, str(exc))
|
|
64
|
+
if log_failure:
|
|
65
|
+
log.error(
|
|
66
|
+
"%s, failed, context=%s", event_type, mask_map(context.copy()), exc_info=exc
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def _finally(snapshot) -> None:
|
|
70
|
+
producer = get_log_producer()
|
|
71
|
+
if producer is not None:
|
|
72
|
+
producer.send_to_schema()
|
|
73
|
+
context.restore(snapshot)
|
|
74
|
+
|
|
75
|
+
if inspect.iscoroutinefunction(func):
|
|
76
|
+
|
|
77
|
+
@functools.wraps(func)
|
|
78
|
+
async def async_wrapper(*args, **kwargs):
|
|
79
|
+
snapshot = context.copy()
|
|
80
|
+
start = time.monotonic()
|
|
81
|
+
_enter(start)
|
|
82
|
+
try:
|
|
83
|
+
result = await func(*args, **kwargs)
|
|
84
|
+
_success(start)
|
|
85
|
+
return result
|
|
86
|
+
except BaseException as exc: # noqa: BLE001 - re-raised below
|
|
87
|
+
_failure(start, exc)
|
|
88
|
+
raise
|
|
89
|
+
finally:
|
|
90
|
+
_finally(snapshot)
|
|
91
|
+
|
|
92
|
+
return async_wrapper # type: ignore[return-value]
|
|
93
|
+
|
|
94
|
+
@functools.wraps(func)
|
|
95
|
+
def sync_wrapper(*args, **kwargs):
|
|
96
|
+
snapshot = context.copy()
|
|
97
|
+
start = time.monotonic()
|
|
98
|
+
_enter(start)
|
|
99
|
+
try:
|
|
100
|
+
result = func(*args, **kwargs)
|
|
101
|
+
_success(start)
|
|
102
|
+
return result
|
|
103
|
+
except BaseException as exc: # noqa: BLE001 - re-raised below
|
|
104
|
+
_failure(start, exc)
|
|
105
|
+
raise
|
|
106
|
+
finally:
|
|
107
|
+
_finally(snapshot)
|
|
108
|
+
|
|
109
|
+
return sync_wrapper # type: ignore[return-value]
|
|
110
|
+
|
|
111
|
+
return decorator
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _elapsed_ms(start: float) -> int:
|
|
115
|
+
return int((time.monotonic() - start) * 1000)
|
mobius_logging/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mobius-logging-py
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Standardized logging context, observation, masking and Kafka schema events for Mobius Python (FastAPI) services.
|
|
5
|
+
Author: Mobius Platform
|
|
6
|
+
Keywords: logging,observability,fastapi,mobius,kafka
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: py-kafka-producer-client>=0.1.7
|
|
10
|
+
Provides-Extra: otel
|
|
11
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == "otel"
|
|
12
|
+
Provides-Extra: fastapi
|
|
13
|
+
Requires-Dist: starlette>=0.27; extra == "fastapi"
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
16
|
+
Requires-Dist: starlette>=0.27; extra == "dev"
|
|
17
|
+
Requires-Dist: httpx>=0.24; extra == "dev"
|
|
18
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# Mobius Logging for Python
|
|
21
|
+
|
|
22
|
+
Python port of the Java Mobius logging library. It standardizes logging context
|
|
23
|
+
for Mobius **FastAPI / Starlette** services: a contextvars-based context store
|
|
24
|
+
(the analog of SLF4J MDC), a request middleware, an `@observed` method
|
|
25
|
+
decorator, sensitive-data masking, OpenTelemetry trace sync, JSON logging, and
|
|
26
|
+
a Kafka schema-event producer.
|
|
27
|
+
|
|
28
|
+
Schema constants (Kafka topic `construct-data-1`, schema id, tenant id, and
|
|
29
|
+
`schema_version=mobius.log.v1`) are **identical to the Java library**, so events
|
|
30
|
+
from Python services land in the same pipeline.
|
|
31
|
+
|
|
32
|
+
Requires Python 3.10+.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install mobius-logging-py
|
|
38
|
+
|
|
39
|
+
# or directly from git
|
|
40
|
+
pip install "mobius-logging-py @ git+https://<your-git-host>/mobius-logging-py.git@v1.0.0"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The import package is `mobius_logging` (e.g. `from mobius_logging import setup_logging`).
|
|
44
|
+
|
|
45
|
+
Optional extras: `mobius-logging-py[otel]` (OpenTelemetry trace sync),
|
|
46
|
+
`mobius-logging-py[fastapi]` (Starlette middleware).
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from fastapi import FastAPI
|
|
52
|
+
from mobius_logging import setup_logging, observed, LogType
|
|
53
|
+
from py_kafka_producer_client import Producer # your internal client
|
|
54
|
+
|
|
55
|
+
app = FastAPI()
|
|
56
|
+
|
|
57
|
+
setup_logging(
|
|
58
|
+
app,
|
|
59
|
+
service_name="orders-service",
|
|
60
|
+
profile="prod", # json logs for staging/prod/production
|
|
61
|
+
sensitive_keys=["session_id", "refresh_token"],
|
|
62
|
+
kafka_producer=Producer(...), # wrapped in PyKafkaProducerClientSender
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@app.post("/orders")
|
|
66
|
+
@observed(event_type="order.create", log_type=LogType.AUDIT, resource_type="order")
|
|
67
|
+
async def create_order(order_id: str):
|
|
68
|
+
...
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`setup_logging` is the one-call equivalent of Java's Spring auto-configuration
|
|
72
|
+
(Python has no auto-config, so wiring is explicit). It configures logging,
|
|
73
|
+
registers custom sensitive keys, builds the schema producer, and adds the
|
|
74
|
+
request middleware.
|
|
75
|
+
|
|
76
|
+
## Java → Python mapping
|
|
77
|
+
|
|
78
|
+
| Java Mobius logging library | Python (`mobius_logging`) |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| SLF4J `MDC` | `contextvars.ContextVar` (`context.py`) |
|
|
81
|
+
| `PlatformLogContext` | `mobius_logging.context` functions |
|
|
82
|
+
| `PlatformLogFields` | `mobius_logging.fields` |
|
|
83
|
+
| `LogType` enum | `LogType` enum |
|
|
84
|
+
| `PlatformLoggingFilter` (servlet) | `PlatformLoggingMiddleware` (ASGI) |
|
|
85
|
+
| `@PlatformObserved` + AOP aspect | `@observed` decorator |
|
|
86
|
+
| `KafkaLogProducer` (`KafkaTemplate`) | `KafkaLogProducer` + `LogEventSender` |
|
|
87
|
+
| `PlatformKafka*Context` | `producer_headers` / `apply_consumer_record` |
|
|
88
|
+
| `SensitiveDataMasker` | `mobius_logging.masking` |
|
|
89
|
+
| `LogbackConfigurator` | `configure_platform_logging` |
|
|
90
|
+
| Spring auto-config classes | `setup_logging` (explicit) |
|
|
91
|
+
|
|
92
|
+
Out of scope for v1 (present in Java): JDBC/JPA, MongoDB, and Camunda logging.
|
|
93
|
+
|
|
94
|
+
## Context API
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from mobius_logging import context, LogType
|
|
98
|
+
|
|
99
|
+
context.init() # sets schema_version
|
|
100
|
+
context.tenant_id("tenant-123")
|
|
101
|
+
context.log_type(LogType.AUDIT)
|
|
102
|
+
context.resource_type("order")
|
|
103
|
+
context.resource_id("order-789")
|
|
104
|
+
context.field("custom_key", "value")
|
|
105
|
+
context.ensure_correlation_id() # generates one if missing
|
|
106
|
+
context.sync_trace_from_opentelemetry()
|
|
107
|
+
|
|
108
|
+
snap = context.snapshot()
|
|
109
|
+
try:
|
|
110
|
+
context.put("event_type", "order.approved")
|
|
111
|
+
finally:
|
|
112
|
+
context.restore(snap)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
> Note: Java's `ensureCorrelationId()` has an inverted condition and only
|
|
116
|
+
> regenerates when one already exists. This port fixes that — it generates a
|
|
117
|
+
> correlation id when none is present.
|
|
118
|
+
|
|
119
|
+
## `@observed`
|
|
120
|
+
|
|
121
|
+
Works on sync and async functions. Adds `log_type`, `event_type`,
|
|
122
|
+
`resource_type`, `class_name`, `method_name`, `outcome`, `method_duration_ms`,
|
|
123
|
+
and `exception_type`/`exception_message` on failure. After completion it ships
|
|
124
|
+
the context to the schema producer (if one is registered), then restores the
|
|
125
|
+
pre-call context snapshot (Java clears MDC instead).
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
@observed(event_type="order.create", resource_type="order")
|
|
129
|
+
def create_order(order_id: str): ...
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Kafka
|
|
133
|
+
|
|
134
|
+
### Schema event producer
|
|
135
|
+
|
|
136
|
+
The producer depends only on a small protocol, so the library has no hard Kafka
|
|
137
|
+
dependency. It matches the `py-kafka-producer-client` (>=0.1.7) signature — the
|
|
138
|
+
event is a dict and `topic` is keyword-only:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
class LogEventSender(Protocol):
|
|
142
|
+
def send(self, value: dict, *, topic: str) -> Any: ...
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
For the internal `py-kafka-producer-client`, pass the client to
|
|
146
|
+
`setup_logging(kafka_producer=...)` or wrap it directly:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from mobius_logging import KafkaLogProducer, PyKafkaProducerClientSender, set_log_producer
|
|
150
|
+
|
|
151
|
+
producer = KafkaLogProducer(PyKafkaProducerClientSender(client), "orders-service")
|
|
152
|
+
set_log_producer(producer)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`PyKafkaProducerClientSender` calls `client.send(value, topic=...)`, passing the
|
|
156
|
+
`DataIngestionOperation` envelope as a dict (the client serializes it). To use a
|
|
157
|
+
different client, pass any object exposing `send(value, *, topic)` as
|
|
158
|
+
`kafka_sender=`.
|
|
159
|
+
|
|
160
|
+
Sending runs on a background thread pool (best-effort, never raises, never
|
|
161
|
+
blocks the request) — the analog of the Java `@Async` method.
|
|
162
|
+
|
|
163
|
+
### Context propagation
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from mobius_logging import producer_headers, apply_consumer_record
|
|
167
|
+
|
|
168
|
+
# producer side
|
|
169
|
+
client.send(topic, value, headers=producer_headers())
|
|
170
|
+
|
|
171
|
+
# consumer side
|
|
172
|
+
apply_consumer_record(record.headers(), topic=record.topic(),
|
|
173
|
+
partition=record.partition(), offset=record.offset())
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Propagated header fields: `trace_id`, `span_id`, `correlation_id`,
|
|
177
|
+
`causation_id`, `tenant_id`. Consumer also sets `kafka_topic`,
|
|
178
|
+
`kafka_partition`, `kafka_offset`.
|
|
179
|
+
|
|
180
|
+
## Logging output
|
|
181
|
+
|
|
182
|
+
`configure_platform_logging(service_name, profile)` installs a root handler that
|
|
183
|
+
injects the current context onto every record. Profiles `staging`/`prod`/
|
|
184
|
+
`production` emit JSON; others use a readable text pattern. A daily-rotating
|
|
185
|
+
file handler writes to `logs/<service>.log` (30 days retained).
|
|
186
|
+
|
|
187
|
+
In JSON mode every context field is included automatically. For the text
|
|
188
|
+
pattern, `txn_id`, `tenant_id`, and `trace_id` are surfaced inline.
|
|
189
|
+
|
|
190
|
+
## Sensitive data masking
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from mobius_logging import mask, mask_map, init_custom_keys
|
|
194
|
+
|
|
195
|
+
init_custom_keys(["session_id", "refresh_token"])
|
|
196
|
+
mask("authorization", token) # masked
|
|
197
|
+
mask_map(context.copy()) # masks all sensitive keys in a dict
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Default sensitive keys: `password`, `token`, `secret`, `authorization`,
|
|
201
|
+
`cookie`, `apikey`, `privatekey`, `kubeconfig`. Matching is case-insensitive.
|
|
202
|
+
Masking keeps the last 4 characters of generic values and partially masks
|
|
203
|
+
emails — identical behaviour to the Java masker.
|
|
204
|
+
|
|
205
|
+
## Development
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
python -m venv .venv && source .venv/bin/activate
|
|
209
|
+
pip install -e ".[dev]"
|
|
210
|
+
pytest
|
|
211
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
mobius_logging/__init__.py,sha256=Nzeo7kcPYZGjCIPITS83ocVr4hUFPhsrbr_6WOM1nFU,1834
|
|
2
|
+
mobius_logging/bootstrap.py,sha256=FVyZfY588AVMfS0PM1aRWc1wYUiNU7IZs2Zej54g8is,4071
|
|
3
|
+
mobius_logging/constants.py,sha256=GnWlAex64LZFY6b8wp2kpNifLlXo5JZRJdVhID5dChw,989
|
|
4
|
+
mobius_logging/context.py,sha256=uv9Xdqfvo538JYyaBayo2jz91AbJtU9YE_9xWujUEXs,3739
|
|
5
|
+
mobius_logging/fields.py,sha256=qABpjvdBnEy5dqJyzQJP99ARSkR5hKP9xfW_ufwZmuM,2108
|
|
6
|
+
mobius_logging/kafka_context.py,sha256=xSGcFcXA9FZKtL2Tvff_oSjWo6mq474ZLlU88xOyQeg,2035
|
|
7
|
+
mobius_logging/kafka_producer.py,sha256=GdJTv7EwZSS_YQrAHVjn3NLheKAjvL6wwHx6HEdKoTA,4257
|
|
8
|
+
mobius_logging/log_type.py,sha256=TVER4tnwn2eL9iq0ZXI8jVM1ltYy4gSokuSqxD7QXTQ,560
|
|
9
|
+
mobius_logging/logging_config.py,sha256=INbV3QgwutaGE_gA2kIDAPux0Ybvq5w90zDjQGzFVYw,4076
|
|
10
|
+
mobius_logging/masking.py,sha256=1IlMrAiHvpr5tj0ybKZvRjcSL0Hjnqj2LJrYRavhk4w,2080
|
|
11
|
+
mobius_logging/middleware.py,sha256=E58IczBr2IBfgEm5BTfdxPLzDUGsRvW8_4bCmAGGPME,2134
|
|
12
|
+
mobius_logging/observed.py,sha256=3JoP-SGpcdbrYWeu77I2-0msGlVwVReNihM9n4J0JT4,4105
|
|
13
|
+
mobius_logging/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
mobius_logging_py-1.0.0.dist-info/METADATA,sha256=qSkVnyMaD8O3nSdjbzYpYv4i9O0ARUHjUgBeuflJReo,7340
|
|
15
|
+
mobius_logging_py-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
16
|
+
mobius_logging_py-1.0.0.dist-info/top_level.txt,sha256=Eyjhd5Fjq0KaedkHPPqaWgFPqSpHnx7Ks6lSA6Pc5VA,15
|
|
17
|
+
mobius_logging_py-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mobius_logging
|