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 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 -
The
cmdUuidis server-owned — assigned here viaCommandTracingContext(the same fallback covers commands created in code); a client-supplied one was already rejected by the@CommandIdentityGuardinterceptor -
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, 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 + optionalProblem), notifies listeners (onCommandCompleted/onCommandFailed), and publishes aCommandTracingEventviaCommandTracingEventPublisher -
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.
-
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
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.
|
|
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.
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 |
Capture the response in a filter |
|
Publish asynchronously, decoupled |
The filter hands a |
Trade-offs
| Pros | Cons |
|---|---|
|
|
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.