Custom payload provider

Overview

LogPayloadProvider lets an object define its own logging representation.

Instead of asking the default payload processor to guess how an object should appear in logs, the object can expose a to_log_payload() method:

from typing import Any


class UserSession:
    def __init__(self, session_id: str, user_id: str, token: bytes) -> None:
        self.session_id = session_id
        self.user_id = user_id
        self.token = token

    def to_log_payload(self) -> dict[str, Any]:
        return {
            "session_id": self.session_id,
            "user_id": self.user_id,
            "token_size": len(self.token),
        }

When this object is normalized by the default payload processor, the returned dictionary is used as the object’s log representation.

This is useful when the object itself knows which fields are safe, stable, and meaningful for diagnostics.

When to use it

Use LogPayloadProvider when a domain object has a clear logging representation.

Good candidates:

request objects
response objects
connection descriptors
session objects
configuration snapshots
operation result objects
small domain DTOs

The object should be able to answer:

What should this object look like in logs?

without exposing sensitive or overly large internal data.

When not to use it

Do not implement to_log_payload() when logging representation depends on external context that the object should not know about.

Avoid it when:

the object would need a logger context to decide its payload
the representation depends on the sink or output format
the representation needs expensive I/O
the object would have to expose secrets conditionally
the object is from a third-party library you should not modify

For third-party or external objects, use a type-based log adapter instead.

For global changes to normalization behavior, use a custom payload processor.

Provider vs adapter vs processor

There are three different extension levels.

LogPayloadProvider
    the object provides its own representation

LogAdapter
    external callable provides representation for objects it recognizes

LogPayloadProcessor
    full normalization strategy is replaced or customized

Use the smallest extension point that solves the problem.

If the object owns its logging representation, use LogPayloadProvider.

If the object cannot or should not implement to_log_payload(), use an adapter.

If the entire normalization strategy must change, use a custom processor.

Precedence in the default processor

The default LogPayloadProcessor checks LogPayloadProvider before type-based adapters.

The order is:

1. object implements LogPayloadProvider
2. configured LogAdapterResolver returns an adapter
3. built-in normalization rules

This means to_log_payload() wins over adapters.

If an object implements LogPayloadProvider, the default processor tries that first.

If the method returns a dictionary, that dictionary is used.

If the method raises or returns a non-dictionary value, the processor falls back to the remaining normalization path.

Log-ready responsibility

The payload returned by to_log_payload() is expected to be log-ready.

The provider is responsible for:

choosing safe fields
avoiding secrets
keeping payload size reasonable
using stable key names
returning a dictionary

The provider should not return raw internal state just because it is available.

A good provider exposes diagnostic identity and useful summaries.

A poor provider dumps the entire object.

Example: safe session payload

from typing import Any


class UserSession:
    def __init__(self, session_id: str, user_id: str, token: bytes) -> None:
        self.session_id = session_id
        self.user_id = user_id
        self.token = token

    def to_log_payload(self) -> dict[str, Any]:
        return {
            "session_id": self.session_id,
            "user_id": self.user_id,
            "token_size": len(self.token),
        }

The token itself is not logged.

Only its size is exposed:

{
    "session_id": "...",
    "user_id": "...",
    "token_size": 128,
}

This makes the payload useful for diagnostics without leaking credentials.

Example: connection descriptor

from typing import Any


class ConnectionInfo:
    def __init__(self, host: str, port: int, secure: bool) -> None:
        self.host = host
        self.port = port
        self.secure = secure

    def to_log_payload(self) -> dict[str, Any]:
        return {
            "host": self.host,
            "port": self.port,
            "secure": self.secure,
        }

This object has a simple stable logging representation.

When it appears in an event payload, the processor can use that representation instead of falling back to a generic object placeholder.

Using provider objects in event payloads

A provider object can be placed directly inside a payload:

ctx.log_info_event(
    event="session.created",
    payload={
        "session": session,
    },
)

The default payload processor will normalize session through to_log_payload() if session implements LogPayloadProvider.

Conceptually, the emitted payload can contain:

{
    "session": {
        "session_id": "...",
        "user_id": "...",
        "token_size": 128,
    }
}

Using provider objects with log_invocation

LogPayloadProvider also works with values selected by log_invocation.

For example, a result object can provide its own representation:

class CreateSessionResult:
    def __init__(self, session: UserSession) -> None:
        self.session = session

    def to_log_payload(self) -> dict[str, Any]:
        return {
            "session": self.session.to_log_payload(),
        }

If log_result_on_success=() is used, the whole result is normalized.

The provider controls the result representation.

@log_invocation(
    "create_session",
    log_result_on_success=(),
)
def create_session(self, user_id: str) -> CreateSessionResult:
    ...

The decorator still owns lifecycle logging. The provider only controls how the selected result value is represented.

Keep provider output stable

Provider payload keys become part of your diagnostic vocabulary.

Prefer stable, boring names:

session_id
user_id
host
port
state
status

Avoid names tied to temporary implementation details:

tmp_flag
internal_obj
raw_data
step_2_value

Changing provider output can affect tests, dashboards, alerts, and downstream consumers.

Treat the payload shape as a small public contract of the object.

Avoid expensive work

to_log_payload() may be called on the logging path.

Keep it fast.

Avoid:

network calls
database queries
large filesystem reads
expensive serialization
lazy loading large data
locking slow external resources

The method should build a small representation from data already available on the object.

If producing the representation is expensive, consider logging only a stable identifier and collecting detailed diagnostics elsewhere.

Thread-safety considerations

The payload processor may normalize values while application code is running concurrently.

If the provider reads mutable object state, it should either:

read only stable immutable fields
protect mutable state with the object's own synchronization
return a best-effort snapshot that is safe for logging

The logger does not make arbitrary domain objects thread-safe.

LogPayloadProvider controls representation, not synchronization.

What happens on provider failure

The default payload processor treats provider failures as normalization failures, not operation failures.

If to_log_payload() raises, the processor falls back to generic normalization.

If to_log_payload() returns a non-dictionary value, the result is ignored and generic normalization continues.

This keeps logging from breaking domain code because one provider representation failed.

A provider should still be tested. Silent fallback can hide a broken representation until logs look less useful than expected.

Design summary

LogPayloadProvider is the object-owned payload representation hook.

Use it when an object knows its own safe logging shape.

Keep the returned payload small, stable, and free of sensitive data.

Prefer provider methods for domain objects you own.

Use adapters for external objects.

Use a custom processor only when the whole normalization strategy needs to change.