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.
@@ -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"))
@@ -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)
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ mobius_logging