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) 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

    • Generates a UUID and sets it on the command

    • 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

    • Builds a CommandResponse with the HTTP status and optional Problem body

    • Calls onCommandCompleted or onCommandFailed on listeners

    • Publishes a CommandTracingEvent via CommandTracingEventPublisher

    • Removes the command from context

Direct path (non-HTTP)

When a traced method runs outside an HTTP request (e.g., a background job or spawned command), the interceptor handles the full lifecycle:

  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

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.

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.