Command Tracing

The @CommandTracing annotation

The @CommandTracing annotation enables command tracing on a controller class or individual method.

Class-level vs method-level

Apply at class level to trace all endpoint methods:

@CommandTracing
@Controller("/orders")
public class OrderController {
    // all methods are traced
}

Apply at method level to trace specific endpoints, or to override class-level defaults:

@CommandTracing
@Controller("/reports")
public class ReportController {

    @Post
    @CommandTracing(importance = CommandImportance.High)
    public Report createReport(@Valid @Body CreateReportCommand cmd) {
        // traced with High importance (overrides class-level Normal)
    }
}

Annotation attributes

@Documented @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.TYPE, ElementType.METHOD}) @Around() @Type(CommandTracingInterceptor.class) @CommandIdentityGuard public @interface CommandTracing {

String description() default "";
boolean enabled() default true;
CommandImportance importance() default CommandImportance.Normal;
CommandStateCategory includeStates() default CommandStateCategory.All;
CommandTracingOption[] options() default {};
/**
 * Allows resolving command tracing params based on invocation parameters.
 */
Class<? extends ParamsTransformer> paramsTransformer() default ParamsTransformer.class;
@FunctionalInterface
interface ParamsTransformer {
default Optional<CommandTracingParams> transform(CommandTracingParams initial, MethodInvocationContext<Object, Object> context) {
    return transform(initial, context.getParameterValues());
}
    Optional<CommandTracingParams> transform(CommandTracingParams initial, Object[] parameters);
}

}

description

Optional documentation string. Does not affect tracing behavior.

enabled

Default: true. Set to false to disable tracing for a specific method while keeping the annotation for documentation.

importance

Default: CommandImportance.Normal. Controls the importance field stored in the command log. Values: Low, Normal, High.

includeStates

Default: CommandStateCategory.All. Controls which command outcomes are persisted. Values: All, NotSuccessful, Failure, None.

options

Default: {}. Optional behaviors: ExcludeCmdBody (skip storing the command payload), IncludeResultBody, IncludeRemarks.

paramsTransformer

Default: ParamsTransformer.class (identity). Allows dynamic adjustment of tracing parameters based on the command instance.

ParamsTransformer

Implement ParamsTransformer to adjust tracing parameters at runtime, when the decision depends on the command instance itself. The interceptor invokes the transformer for every traced call and uses whatever it returns. Returning Optional.empty() falls back to the annotation defaults — always returning Optional.of(…​) is the safe pattern.

Lower importance for dry-run

Dry-run executions are frequent (preview modals, validation checks) and shouldn’t clutter the audit log alongside real operations:

@Introspected
public class DryRunImportanceTransformer implements ParamsTransformer {

    @Override
    public Optional<CommandTracingParams> transform(CommandTracingParams initial,
                                                    Object[] parameters) {

        if (parameters[0] instanceof DryRunEnabled cmd && cmd.dryRun()) {
            return Optional.of(initial.withImportance(CommandImportance.Low));
        }

        return Optional.of(initial);
    }
}

Reference it from the annotation, and keep the default importance for real executions:

@Post
@CommandTracing(
    importance = CommandImportance.High,
    paramsTransformer = DryRunImportanceTransformer.class
)
public Report createReport(@Valid @Body CreateReportCommand cmd) { }

Lower importance for partial edits

Some endpoints serve double duty: a high-volume autosave path (e.g. typing in a free-text field) and a low-volume confirm path on the same command. Inspect the payload to decide which one is happening:

@Introspected
public class DraftImportanceTransformer implements ParamsTransformer {

    @Override
    public Optional<CommandTracingParams> transform(CommandTracingParams initial,
                                                    Object[] parameters) {

        if (parameters[0] instanceof UpdateNoteCommand cmd && cmd.onlyContainsCustomDraft()) {
            return Optional.of(initial.withImportance(CommandImportance.Low));
        }

        return Optional.of(initial);
    }
}

When the user is editing the draft text, the entry is Low. When the same endpoint is used to update structured fields, it stays at the default Normal.

Skipping noisy autosaves

Some endpoints are so high-volume — every keystroke, every cell edit — that even Low importance still floods the log. For those, combine importance with includeStates to skip successful executions entirely and keep only the rest:

@Put("/items")
@CommandTracing(
    importance = CommandImportance.Low,
    includeStates = CommandStateCategory.NotSuccessful
)
public LineItem updateItem(@Valid @Body UpdateLineItemCommand cmd) {
    return service.updateItem(cmd);
}

Two things happen:

  • Successful saves are not persisted — the persister filters them out before they reach the database.

  • Non-successful executions (anything with a problem set — Rejected, Failed, Conflict, Cancelled) are still recorded at Low importance, so they remain debuggable but are eligible for aggressive retention.

Pair this with a separate confirm endpoint kept at the default Normal: noisy edits stay out of the way, and the deliberate "I’m done" action is preserved in full.

How tracing works

Command tracing has two execution paths depending on whether the command runs inside an HTTP request.

HTTP path (interceptor + filter)

Diagram

This is the most common path — a controller method annotated with @CommandTracing handles an HTTP request.

  1. The interceptor fires before the method executes:

    • Finds the Command parameter in the method signature

    • The cmdUuid is server-owned — assigned here via CommandTracingContext (the same fallback covers commands created in code); a client-supplied one was already rejected by the @CommandIdentityGuard interceptor

    • Resolves CommandTracingParams from the annotation (and optional ParamsTransformer)

    • Stores the command and params in CommandTracingContext (request-scoped)

    • Calls onCommandStarted on all registered CommandTracingListeners

    • Proceeds with method execution

  2. The filter fires after the response is produced:

    • Retrieves the command from CommandTracingContext, resolved from the request it is processing (via the Reactor subscription context, so it stays correct across thread hops)

    • On a response: builds a CommandResponse (HTTP status + optional Problem), notifies listeners (onCommandCompleted/onCommandFailed), and publishes a CommandTracingEvent via CommandTracingEventPublisher

    • Removes the command from its request on every terminal signal — success, error, or cancel — so it never outlives the request

Direct path (non-HTTP and spawned commands)

The interceptor handles the full lifecycle itself in two cases: a traced method that runs outside any HTTP request (e.g. a background job, Pub/Sub or @Async listener), and a spawned command — a @CommandTracing method invoked from within another command that already occupies the request’s tracing context.

  1. Calls onCommandStarted

  2. Executes the method in a try/catch

  3. On success: calls onCommandCompleted, publishes event

  4. On failure: calls onCommandFailed(command, throwable), publishes event with error

These commands are published with http_method == null: they did not themselves arrive over HTTP. What ties a spawned command back to its originator is unaffected by this: its cmdSourceRef (the lineage chain) and the clientRef it inherits from the root command are still resolved here — see ClientRefAware for how those two refs differ. The interceptor deliberately does not read the thread-bound request to fill in a method here — that read is ambient (correct for synchronous spawns, but unsafe off the request thread), so null is the honest, hop-safe value.

CommandTracingContext

The CommandTracingContext utility stores the active command in the current HTTP request’s attributes. This allows the filter to pick up what the interceptor started.

You can also set context manually when tracing needs to be configured outside the annotation:

CommandTracingContext.set(cmd,
    CommandTracingParams.withOptions(CommandTracingOption.ExcludeCmdBody));

This is useful when the command is constructed in the controller body rather than received as a parameter.

set(…​) and the no-arg getCommand() / remove(Command) are thread-bound — fine from a controller body or other synchronous code. If you read or remove the command yourself from reactive response-phase code (e.g. a custom server filter), use the request-passing overloads getCommand(HttpRequest) / remove(Command, HttpRequest) with a request resolved from the Reactor subscription context (ServerRequestContext.currentRequest(ContextView)). The no-arg variants read the thread-bound request, which can be stale on a reused worker and bleed a command across requests (see the hop-safety note).

CommandTracingEventPublisher

The CommandTracingEventPublisher interface is the integration point between the tracing framework and your persistence layer. You must provide an implementation as a Micronaut bean.

public interface CommandTracingEventPublisher {

    void publishEvent(CommandTracingParams params,
                      CommandRequest cmdRequest,
                      CommandResponse cmdResponse);
}

Responsibilities

Your implementation should:

  • Enrich the CommandRequest with tenant ID, user ID, and optionally the command body

  • Persist the command log (typically to the command_log table)

  • Handle errors gracefully — a failure in the publisher must not block the HTTP response

Typical implementation

@Singleton
public class MyCommandTracingEventPublisher implements CommandTracingEventPublisher {

    private final ApplicationEventPublisher<CommandTracingEvent> eventPublisher;
    private final TenantResolver tenantResolver;
    private final ObjectMapper objectMapper;

    @Override
    public void publishEvent(CommandTracingParams params,
                             CommandRequest minimal,
                             CommandResponse cmdResponse) {

        var cmdRequest = CommandRequest.of(
            minimal,
            resolveTenantId(minimal.command()),
            resolveUserId(),
            params.options().contains(ExcludeCmdBody)
                ? null
                : cmdToMap(minimal.command())
        );

        eventPublisher.publishEventAsync(
            new CommandTracingEvent(
                MyCommandLogEntity.from(params, cmdRequest, cmdResponse),
                params
            )
        );
    }
}

The publishEventAsync call ensures the HTTP response is not delayed by persistence. A Micronaut @EventListener picks up the event and writes to the database.

Implementation notes and trade-offs

This describes the current implementation and its trade-offs — best current knowledge, not a fixed contract. The mechanisms below are deliberate but replaceable; what is fixed is the outcome (see Fixed vs open).

Current mechanism (HTTP path)

The HTTP path is assembled from three independent choices.

Choice How it works today

Hold the in-flight command in request scope

The interceptor stores the command via ServerRequestContext (in Micronaut 4, a view over PropagatedContext), keyed to the current request.

Capture the response in a filter

CommandTracingFilter wraps the reactive response to record the HTTP status and Problem body once the response is produced. It resolves the request from the Reactor subscription context (ServerRequestContext.currentRequest(ContextView)) and reads the command off that request, so tracing stays bound to the originating request across thread hops.

Publish asynchronously, decoupled

The filter hands a CommandTracingEvent to a CommandTracingEventPublisher; listeners consume it out of band, so tracing never blocks the request.

Trade-offs

Pros Cons
  • Annotation-driven — controllers opt in with @CommandTracing, no per-handler boilerplate.

  • Response-aware — status and error body are captured without the handler knowing about tracing.

  • Async and pluggable — independent listeners consume each event off the request path.

  • Reuses the request as a natural per-command scope.

  • The lifecycle is split across two pieces — the interceptor sets, the filter reads.

  • Coupled to the HTTP filter model; non-HTTP commands need a separate direct-publish path.

  • Relies on Micronaut propagating the request into the Reactor context; the filter must resolve the request via ServerRequestContext.currentRequest(ContextView) (not the thread-bound currentRequest()) and read the command off that request to stay hop-safe.

Hop safety

The command is resolved from the Reactor subscription context, so it stays bound to the request that carried it even when the response completes on a reused/hopped worker thread. An earlier version read the thread-bound currentRequest() in the response phase, which could resolve a stale request and publish a command under an unrelated request — wrong HTTP method, duplicate row. See issue #11.

If you ran an affected version, past bleed shows up in command_log as duplicate cmd_uuid rows (same cmd_body, a few milliseconds apart) or as commands logged under a method their endpoint never serves (e.g. a mutation under GET).

Fixed vs open

Fixed (the contract): each command submitted on an HTTP request is published exactly once, attributed to the method of the request that carried it, and never another request’s command; non-HTTP commands are still traced; publication stays async and decoupled.

Open (current mechanism, may change): using ServerRequestContext as the holder, the filter as the response-capture point, and an in-process application event as the transport.