Architecture

Overview

log_invocation is implemented as a small operation-logging runtime around a decorated callable.

It is not a sink, not a context, not an event policy, and not a payload processor.

Its job is narrower:

wrap a public API operation
   |
   v
observe its lifecycle
   |
   v
build operation-specific LogEvent records
   |
   v
emit them through the resolved LogContext

Internally, the component has three main phases:

decoration time
    validate static configuration and build the wrapper

call time
    resolve arguments and context; when logging is enabled, resolve entity id and metadata

outcome time
    build outcome payloads and emit LogEvent records

This article describes how those phases are organized inside the component.

High-level structure

The public entry point is the decorator factory:

log_invocation(...)

It returns a decorator:

log_invocation(...) -> decorate(func)

The decorator returns a wrapper around the original callable:

decorate(func) -> wrapped_async or wrapped_sync

The full shape is:

log_invocation(...)
   |
   v
decorate(func)
   |
   +-- validate event name
   |
   +-- capture inspect.Signature
   |
   +-- choose async wrapper if func is coroutine function
   |
   +-- choose sync wrapper otherwise
   |
   v
wrapped callable

The wrapper is the runtime part. It resolves the logging context, calls the original function, and preserves the original return/exception/cancellation semantics. When decorator-driven logging is enabled for the call, it also builds metadata and emits lifecycle outcomes.

Static configuration captured by the decorator

The outer log_invocation(...) call captures configuration that is reused by every call to the decorated function.

Important captured values include:

event
invoke_level
success_level
error_level
error_level_suppressed
cancel_level
log_closures_on_invoke
context_fields
context_formatter
log_kwargs_on_invoke
log_result_on_success
log_error_policy
ctx
entity_id_getter

These values are not recomputed for every emitted outcome.

They define the logging behavior of the decorated operation.

For example:

@log_invocation(
    "send_request",
    log_kwargs_on_invoke=("request_id",),
    log_result_on_success=("status",),
)
def send_request(...):
    ...

At decoration time, the component captures:

event name: send_request
invoke kwargs specs: request_id
result specs: status

At call time, it applies those captured specs to the actual call arguments and actual result.

Event name validation

During decoration, the event name is validated with a regular expression.

The event name must match:

^[A-Za-z_.]+$

If the event name is invalid, decoration fails immediately.

This means invalid event names are detected when the function is decorated, not later when the operation is called.

This is an integration-time failure, not an operation failure.

Signature capture

During decoration, the component captures the function signature:

sig = inspect.signature(func)

The signature is later used at call time to build effective_kwargs from args and kwargs.

This allows field specs to refer to arguments by their parameter names.

For example:

@log_invocation(
    "send_request",
    log_kwargs_on_invoke=("request_id",),
)
def send_request(self, request_id: str) -> None:
    ...

The runtime call may be positional:

client.send_request("req-1")

but the decorator can still expose the argument as:

request_id

because it bound the call through the function signature.

Wrapper selection

The decorator builds one of two wrapper shapes.

If the decorated function is an async def, it builds an async wrapper.

inspect.iscoroutinefunction(func) == True
   |
   v
wrapped_async

Otherwise, it builds a sync wrapper.

inspect.iscoroutinefunction(func) == False
   |
   v
wrapped_sync

The sync wrapper has an additional branch for functions that return an awaitable.

So the runtime supports three execution shapes:

async function
synchronous function
synchronous function returning an awaitable

Runtime setup flow

Both wrappers start with the same setup flow.

extract effective kwargs
   |
   v
resolve context
   |
   +--> context is None
   |       |
   |       v
   |   call original operation without decorator-driven logging
   |
   v
resolve entity id
   |
   v
build LogEventMeta
   |
   v
check event policy
   |
   v
optionally emit invoke

If setup fails, the operation body is not called.

Returning None from get_log_context() is not a setup failure. It disables decorator-driven logging for the current call, and the original operation is still called.

That is intentional. Setup failures indicate incorrect integration of the decorator, not failure of the decorated operation itself.

Argument extraction

At call time, the wrapper extracts bound arguments.

Conceptually:

func signature + args + kwargs -> effective_kwargs

The implementation tries to bind the call using the captured signature and then applies defaults.

If binding fails, it falls back to a dictionary made from kwargs.

The resulting dictionary is used by:

context_fields
log_kwargs_on_invoke

log_result_on_success does not use effective_kwargs. It works from the actual returned result.

Context resolution

After arguments are extracted, the wrapper resolves the effective logging context.

There are two paths.

If ctx was supplied to the decorator, that context is used directly:

ctx argument -> effective_ctx

Otherwise, the wrapper looks at the first positional argument:

args[0] -> get_log_context() -> effective_ctx

This is the method-based path, where args[0] is normally self.

If ctx is not supplied and the first positional argument does not provide LogContextProviderProto, setup fails.

If the method-based provider returns None, setup stops without failure and the original operation is called without decorator-driven logging.

The context is required because the decorator delegates important work to it:

event policy check
value normalization
error payload building
error marker helpers
LogEvent delivery

log_invocation does not create or configure a context.

Entity id resolution

After the context is resolved, the wrapper resolves the optional entity id.

There are two paths.

If entity_id_getter was supplied, it is called:

entity_id_getter() -> entity_id

Otherwise, the wrapper looks at the first positional argument:

args[0].identity -> entity_id

If neither path is available, the entity id is None.

If entity_id_getter or identity access raises, setup fails and the operation body is not called.

This is intentional. Entity id resolution is part of decorator integration. A broken entity provider should surface immediately instead of being logged as an operation failure.

Metadata construction

Once context and entity id are available, the wrapper builds LogEventMeta.

The metadata is built once per decorated call:

LogEventMeta(
    event_namespace=effective_ctx.namespace,
    event_name=event,
    entity_id=entity_id,
    source_path=None,
    source_line=None,
    source_func=None,
)

The event namespace comes from the resolved context.

The event name comes from the decorator argument.

The entity id comes from the entity resolution step.

Source fields are intentionally set to None. log_invocation does not collect source file, source line, or source function metadata.

Event policy check

After metadata is created, the wrapper asks the context whether the event is enabled.

event_enabled = effective_ctx.is_event_enabled(event_meta)

This flag is used for normal tracing outcomes:

invoke
success

If event_enabled is false, invoke is not emitted and success is not emitted.

Failure and cancellation branches still emit their dedicated outcomes when the operation body raises.

This makes the policy boundary explicit:

event policy controls normal operation tracing
failure/cancellation paths still report exceptional outcomes

Outcome emitters

The component has four internal outcome emitters:

_emit_invoke
_emit_success
_emit_failed
_emit_cancelled

Each emitter builds a payload for one outcome and emits a LogEvent through the resolved context.

They share the same metadata object created during setup.

They differ in payload sources and level selection.

_emit_invoke
    closures
    context fields
    invoke kwargs
    invoke_level

_emit_success
    context fields
    result payload if enabled
    success_level

_emit_failed
    context fields
    error payload or suppressed error
    error_level or error_level_suppressed

_emit_cancelled
    cancelled flag
    error payload
    context fields
    cancel_level

The outcome emitters are the main internal boundary between lifecycle observation and LogEvent construction.

LogEvent construction

Each outcome emitter creates a LogEvent.

The shape is:

LogEvent(
    level=...,
    meta=event_meta,
    event_outcome=..., 
    timestamp=time.time(),
    payload=payload,
)

Then it emits the event through the context:

effective_ctx.emit_log_event(log_event)

This is important: the decorator does not call the sink directly.

It always goes through LogContext.

That keeps sink delivery, logging infrastructure error handling, and downstream behavior owned by the core logger infrastructure.

Invoke emitter

_emit_invoke builds the invoke payload before the operation body runs.

It can add three groups of data:

closures
context fields
selected invoke kwargs

The order is:

start with empty payload
   |
   v
add normalized closure values under closures
   |
   v
inject context payload
   |
   v
add selected kwargs under kwargs
   |
   v
emit LogEvent(event_outcome="invoke")

Closure values are normalized one by one through the context.

If closure value normalization fails, the value for that closure key becomes "<unknown>".

Selected invoke kwargs are resolved from effective_kwargs and normalized through the context.

Success emitter

_emit_success builds the success payload after the operation completes successfully.

It can add:

context fields
result payload

The order is:

start with empty payload
   |
   v
inject context payload
   |
   v
if result logging is enabled, add payload["result"]
   |
   v
emit LogEvent(event_outcome="success")

If log_result_on_success is None, result logging is skipped.

If it is an empty tuple, the whole result is normalized.

If it contains field specs, the result payload builder tries to extract selected result values.

Failed emitter

_emit_failed builds the failed payload when the operation raises an ordinary exception.

It first injects context payload.

Then it applies error logging logic.

The order is:

start with empty payload
   |
   v
inject context payload
   |
   v
if log_error_policy has matching rule:
       apply rule
   else:
       use repeated-error marker behavior
   |
   v
emit LogEvent(event_outcome="failed")

If full error logging is used, the payload receives:

payload["error"] = effective_ctx.build_error_payload(err)

If suppressed logging is used, no detailed error payload is added.

The original exception is re-raised by the wrapper after _emit_failed returns.

Cancelled emitter

_emit_cancelled handles asyncio.CancelledError.

It first checks whether the same cancellation exception instance is already marked as logged.

If it is already logged, the emitter returns without emitting another cancelled outcome.

Otherwise, it builds a payload with:

{
    "cancelled": True,
    "error": effective_ctx.build_error_payload(err),
}

Then it injects context payload and emits:

event_outcome = "cancelled"

After successful emission, it marks the cancellation exception as logged.

The wrapper then re-raises the same CancelledError.

Context payload injection

Context payload injection is handled by a shared helper.

Conceptually:

field specs + effective kwargs
   |
   v
resolve raw fields
   |
   v
optional formatter
   |
   v
inject into target payload

This helper is used by all four outcome emitters.

It is why context_fields behave consistently across invoke, success, failed, and cancelled outcomes.

Field resolution pipeline

Field resolution turns field spec strings into resolved raw values.

For each field spec, the resolver performs these steps:

strip whitespace
   |
   v
apply verbosity gate
   |
   v
parse ! marker
   |
   v
parse alias
   |
   v
split path by dots
   |
   v
find first segment in effective kwargs
   |
   v
walk remaining path with getattr() or len()
   |
   v
return alias, value, unbounded flag

Failures are skipped.

Examples of skipped fields:

empty spec
verbosity gate does not match
missing top-level argument
missing attribute
attribute access raises
len() raises

A failed field does not fail the decorated operation.

This is a diagnostic extraction failure, not an operation failure.

Verbosity gate pipeline

Verbosity filtering is applied before the rest of field parsing.

A field spec without : is used as-is.

A field spec with : is split into:

left side   -> allowed verbosity names
right side  -> effective field spec

The resolver asks the context for plain verbosity level.

If the level is not available or does not match the left side, the field is skipped.

If the left side is empty, the right side is treated as an ungated spec.

The decorator does not validate verbosity names against a fixed enum at this stage. It compares strings.

Context formatter pipeline

After raw context fields are resolved, the context formatter may run.

The formatter receives:

ctx
event_outcome
event name
raw resolved fields

It is called whenever context_formatter is provided, even if no fields were resolved.

If the formatter returns a dictionary, that dictionary is injected into the target payload.

If the formatter raises or returns a non-dictionary value, the helper falls back to normal context field handling for resolved fields.

Formatter output is also checked for system-key collisions.

System keys are:

error
kwargs
result
cancelled
closures

If formatter output contains any system key, it is placed under payload["context"] instead of being merged into the top-level payload.

This prevents formatter output from overwriting lifecycle-owned payload sections.

Default context field injection

If no formatter output is used, resolved context fields are normalized one by one.

For each resolved field:

ctx.normalize_value_for_log(value, unbounded=field.unbounded_items)

The normalized values are injected into the target payload.

If the normalized payload contains no fields, nothing is added.

This is the simple path used by ordinary context_fields.

Invoke kwargs pipeline

Invoke kwargs use the same field resolver as context fields.

The difference is the target location and lifecycle timing.

log_kwargs_on_invoke
   |
   v
resolve fields from effective kwargs
   |
   v
normalize selected values
   |
   v
payload["kwargs"] = normalized mapping

This pipeline runs only in _emit_invoke.

The selected kwargs are not repeated on success, failed, or cancelled outcomes.

Result payload pipeline

Result logging uses a separate helper because it works from the returned result object instead of effective_kwargs.

The result helper branches by result type.

primitive
    normalize whole result

list / tuple
    select items by index if specs are provided
    otherwise normalize whole result

dict
    normalize whole dict

composite object
    select attributes if specs are provided
    otherwise normalize whole object

For list and tuple results, the first path segment must be an integer index.

For composite objects, path segments are resolved with attribute access.

For dictionaries, field specs are ignored and the whole dictionary is normalized.

If selected fields cannot be resolved for a list, tuple, or composite object, the helper falls back to whole-result normalization.

Error policy pipeline

The failed emitter applies error policy before the default repeated-error behavior.

The pipeline is:

if log_error_policy is not empty:
    for each (exception_type, force_log):
        if isinstance(err, exception_type):
            apply this rule
            stop

if no rule matched:
    use repeated-error marker behavior

If force_log is true:

add full error payload
emit with error_level
mark error as logged

If force_log is false:

emit without detailed error payload
emit with error_level_suppressed
mark error as logged

If no policy rule applies, the emitter checks whether the exception instance was already logged.

If not logged, it emits a full failed outcome and marks the exception.

If already logged, it emits a suppressed failed outcome.

Wrapper control flow

The flows below describe calls where decorator-driven logging is enabled.

The async wrapper has this control flow:

setup
   |
   v
if enabled: emit invoke
   |
   v
try:
    result = await func(...)
except CancelledError:
    emit cancelled
    raise
except Exception:
    emit failed
    raise
else:
    if enabled: emit success
    return result

The sync wrapper has a similar shape, but first calls the function directly.

If the direct result is awaitable, the wrapper returns a new awaitable that observes the final awaited outcome.

setup
   |
   v
if enabled: emit invoke
   |
   v
try:
    result = func(...)
except CancelledError:
    emit cancelled
    raise
except Exception:
    emit failed
    raise

if result is awaitable:
    return await-and-log wrapper

if enabled: emit success
return result

The await-and-log wrapper then handles success, failure, and cancellation around the original awaitable.

What the component owns

log_invocation owns the operation observation logic.

It owns:

decorator factory
function wrapping
setup ordering
context and entity id resolution
metadata construction
outcome selection
payload assembly
result extraction
error/cancellation observation
LogEvent construction for operation outcomes

It does not own:

context hierarchy
sink configuration
sink delivery implementation
event policy implementation
payload processor implementation
logger package bootstrap
logging infrastructure error policy

That separation is why the component belongs under log_components.

It builds on the logger infrastructure instead of becoming part of the infrastructure itself.

Internal design summary

At decoration time, log_invocation validates and captures static configuration.

At call time, the wrapper resolves bound arguments and the effective context. When decorator-driven logging is enabled for the call, it also resolves entity id, metadata, and the event-policy decision.

At outcome time, internal emitters build payloads, construct LogEvent records, and emit them through the resolved context.

The component is deliberately organized around operation outcomes:

invoke
success
failed
cancelled

Each outcome has its own emitter, payload sources, level, and control-flow position.

The result is a focused operation-logging runtime layered above LogContext, not a replacement for the core logger pipeline.