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 tofalseto disable tracing for a specific method while keeping the annotation for documentation. - importance
-
Default:
CommandImportance.Normal. Controls theimportancefield 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
problemset —Rejected,Failed,Conflict,Cancelled) are still recorded atLowimportance, 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)
This is the most common path — a controller method annotated with @CommandTracing handles an HTTP request.
-
The interceptor fires before the method executes:
-
Finds the
Commandparameter in the method signature -
Generates a UUID and sets it on the command
-
Resolves
CommandTracingParamsfrom the annotation (and optionalParamsTransformer) -
Stores the command and params in
CommandTracingContext(request-scoped) -
Calls
onCommandStartedon all registeredCommandTracingListeners -
Proceeds with method execution
-
-
The filter fires after the response is produced:
-
Retrieves the command from
CommandTracingContext -
Builds a
CommandResponsewith the HTTP status and optionalProblembody -
Calls
onCommandCompletedoronCommandFailedon listeners -
Publishes a
CommandTracingEventviaCommandTracingEventPublisher -
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:
-
Calls
onCommandStarted -
Executes the method in a try/catch
-
On success: calls
onCommandCompleted, publishes event -
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
CommandRequestwith tenant ID, user ID, and optionally the command body -
Persist the command log (typically to the
command_logtable) -
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.