Event model

Overview

The logger event model is split into two dataclasses:

  • LogEventMeta;

  • LogEvent.

LogEventMeta is created first. It describes an event before payload processing and before delivery.

LogEvent is created only after the event has been accepted by the event policy and its payload has been normalized.

This split keeps event selection cheap. A disabled event does not require payload normalization, timestamp creation for the final event, or sink interaction.

Why it exists

Structured logging often mixes three different pieces of information in one object:

  • event identity;

  • event payload;

  • delivery-specific data.

MVX Logger keeps these pieces separated.

LogEventMeta answers the question: what event is this?

LogEvent answers the question: what exactly should be delivered to the sink?

The distinction matters because event policies operate before payload normalization. A policy should be able to make a decision based on stable event identity, without inspecting arbitrary payload values and without forcing potentially expensive conversion of complex objects.

LogEventMeta

LogEventMeta is the pre-delivery metadata object.

@dataclass(frozen=True, slots=True)
class LogEventMeta:
    event_namespace: str | None
    event_name: str
    entity_id: str | None
    source_path: str | None
    source_line: int | None
    source_func: str | None

It is used by LogEventPolicyProto:

class LogEventPolicyProto(Protocol):
    def is_event_enabled(self, event: LogEventMeta) -> bool: ...

The policy receives only metadata. It does not receive the raw payload and does not receive the final LogEvent.

This makes the policy boundary intentionally narrow.

Metadata fields

event_namespace

event_namespace identifies the logical logging namespace.

For manual logging through LogContext.log_event(), the namespace is resolved as follows:

explicit event_namespace argument
        |
        v
context namespace

If event_namespace is not passed explicitly, LogContext uses its own namespace property.

Namespaces are useful for grouping events by subsystem, module, component, or logical service area.

event_name

event_name is the stable name of the event.

It should describe the event itself, not the message text. For example:

request.received
request.completed
connection.opened
connection.failed
payload.normalized

A stable event name is important because policies and downstream processors may use it for filtering, aggregation, or routing.

entity_id

entity_id identifies the domain object or runtime entity related to the event.

It is optional because not every event belongs to one entity.

Examples:

connection id
request id
message id
user id
job id

entity_id is metadata, not payload. It is available to event policies before payload normalization.

source_path, source_line, source_func

These fields describe the source location associated with the event.

They are optional because not every logging path provides source information.

Manual logging code may pass these fields explicitly when it has a meaningful source location to attach.

LogEvent

LogEvent is the final structured event delivered to a sink.

@dataclass(frozen=True, slots=True)
class LogEvent:
    level: int
    meta: LogEventMeta
    event_outcome: str | None
    timestamp: float
    payload: Mapping[str, Any]

A sink receives LogEvent through LogSinkProto.log():

class LogSinkProto(Protocol):
    def log(self, event: LogEvent) -> None: ...

At this point the event has already passed the event policy and its payload has already been prepared for logging.

Event fields

level

level is the numeric severity level.

The package provides LogLevel:

class LogLevel(IntEnum):
    DEBUG = 10
    INFO = 20
    WARNING = 30
    ERROR = 40
    CRITICAL = 50

LogContext helper methods map directly to these levels:

log_debug_event()    -> LogLevel.DEBUG
log_info_event()     -> LogLevel.INFO
log_warning_event()  -> LogLevel.WARNING
log_error_event()    -> LogLevel.ERROR

LogEvent.level is typed as int, so code can still interoperate with numeric logging levels.

meta

meta contains the previously created LogEventMeta object.

This keeps the final event connected to the same metadata that was used for policy evaluation.

event_outcome

event_outcome is an optional classifier for events’ outcomes.

It is different from event_name.

event_name identifies the exact event. event_outcome stores the outcome of the event.

For example:

event_name = "connection.open"
event_outcome = "success"

or:

event_name = "request"
event_outcome = "failure"

A project may leave event_outcome unset if event names and namespaces are sufficient.

timestamp

timestamp is created when the final LogEvent is built.

In the current implementation, LogContext.log_event() uses time.time().

This means the timestamp belongs to the accepted event, not to the earlier metadata object.

payload

payload is the structured event data delivered to the sink.

For normal logging calls, LogContext obtains it through:

self.payload_processor.normalize_payload(payload)

If skip_payload_normalization=True is passed, the original mapping is used directly.

That option is intentionally low-level. It is useful when the caller already has a log-ready payload and wants to avoid another normalization pass.

Event creation pipeline

A manual event follows this sequence:

LogContext.log_event(...)
   |
   v
create LogEventMeta
   |
   v
is_event_enabled(meta)
   |
   +--> False: stop
   |
   +--> True
          |
          v
      normalize payload
          |
          v
      create LogEvent
          |
          v
      emit_log_event(event)
          |
          v
      log_sink.log(event)

The important ordering is:

metadata first
policy second
payload normalization third
sink delivery last

The policy never pays the cost of payload normalization for rejected events.

Manual logging example

ctx.log_info_event(
    event="connection.opened",
    event_outcome="lifecycle",
    entity_id="conn-42",
    payload={
        "remote_host": "127.0.0.1",
        "remote_port": 389,
    },
)

This call produces metadata equivalent to:

LogEventMeta(
    event_namespace=ctx.namespace,
    event_name="connection.opened",
    entity_id="conn-42",
    source_path=None,
    source_line=None,
    source_func=None,
)

If the event is enabled, the payload is normalized and a final LogEvent is emitted.

Event policy implications

Because event policies receive LogEventMeta, they should filter by metadata fields:

class NoDebugConnectionsPolicy:
    def is_event_enabled(self, event: LogEventMeta) -> bool:
        return not (
            event.event_namespace == "my_app.connections"
            and event.event_name.startswith("debug.")
        )

A policy should not depend on payload content. Payloads are intentionally outside the policy boundary.

This keeps policies predictable and prevents hidden serialization cost from leaking into the event selection step.

Sink implications

A sink receives only completed LogEvent objects.

By the time a sink sees an event:

  • the event was accepted by policy;

  • the payload was normalized unless explicitly skipped;

  • the timestamp was assigned;

  • metadata and payload are packaged together.

A sink should usually not perform event filtering. Filtering belongs to the event policy. A sink may still reject or fail delivery for backend-specific reasons, but that is an infrastructure concern, not an event selection concern.

Direct usage

The event model is public and can be used directly.

For example, a custom sink can format events without depending on LogContext internals:

class PlainDebugSink:
    def log(self, event: LogEvent) -> None:
        print(
            event.level,
            event.meta.event_namespace,
            event.meta.event_name,
            dict(event.payload),
        )

A custom policy can also be tested directly by constructing LogEventMeta instances without creating a full logger setup.

meta = LogEventMeta(
    event_namespace="my_app.test",
    event_name="request.received",
    entity_id="req-1",
    source_path=None,
    source_line=None,
    source_func=None,
)

assert policy.is_event_enabled(meta)

This is one of the main benefits of the split event model: policies, sinks, and context behavior can be tested independently.

Design summary

LogEventMeta is small, stable, and policy-oriented.

LogEvent is complete, normalized, timestamped, and sink-oriented.

The logger pipeline deliberately moves from cheap metadata to accepted structured event:

LogEventMeta -> policy -> normalized payload -> LogEvent -> sink

This gives the logger a clear boundary between selection, transformation, and delivery.