#57 — Scope MeteredHandler metrics to top-level requests (tech debt) #57
Labels
No labels
priority/could
priority/must
priority/should
priority/wont
status/blocked
status/claimed
status/done-migrated
type/bug
type/feature
type/infra
type/tech-debt
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/todo#57
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Feature
#57— ScopeMeteredHandlermetrics to top-level requests (tech debt)Requested by: Backend Engineer, identified during
#56code review, 2026-07-20Priority: Could
Blocked by: none
Problem
MeteredHandler<TRequest, TResult>(CqsTodo/Decorators/MeteredHandler.cs) is registered globallyvia
services.Decorate(typeof(IHandler<,>), typeof(MeteredHandler<,>))inProgram.cs, so it wrapsevery closed
IHandler<,>— including internal plumbing handlers that a top-level handler callsdirectly, e.g.
AuthorizeTodoListAccessForCurrentUserQuery,AuthorizeTodoListIsNotArchivedQuery,GetCurrentUserIdQuery.Effect: one user-facing API call (e.g.
CheckTodoCommand) emits 2-4 separatecqstodo.commands.executedincrements and duration-histogram samples — one for the businesscommand, plus one for each internal authorization/lookup query it happens to call through
IHandler<,>. A dashboard built oncqstodo.commands.executedexpecting "one sample per APIoperation" over-counts by 2-4x for anything that goes through the standard authorization chain,
and
cqstodo.commands.durationmixes full-request latency with sub-millisecond internal-lookuplatency under the same instrument name.
Separately: auth-rejected calls still increment
CommandsExecuted/CommandDuration(the decorator'sfinallyruns even whendecoratee.Handlethrows), but — unlikeEmailsSent/PushDispatched— carryno
outcometag, so a rejected/unauthorized call is indistinguishable from a successful one in themetric.
Scope
Authorize*QueryHandler/GetCurrentUserIdQueryHandler-style plumbing handlers from decoration (e.g. an opt-out markerinterface or attribute), or (b) tag metrics with enough context (e.g. a "kind=internal" vs.
"kind=api" tag, or requiring the top-level request type) to make dashboards filterable without a
hardcoded allowlist.
outcometag (e.g.success/unauthorized/error) toCommandsExecuted/CommandDurationso auth rejections are visible and distinguishable from successful invocations, consistent with how
EmailsSent/PushDispatchedalready do this.Out of scope
[LoggerMessage]source generators for hot-path handlers (also flagged in the#56reviewas not yet applied despite the story's own design doc recommending it) — track separately if it
becomes a measured performance concern; not addressed by this story.
Agents involved
Resolution (2026-07-21)
Full design/rationale in the sibling
57_metrics_decorator_scope_tech_debt_design.md. Summary:IInternalRequestmarker interface (Common/Cqs/IInternalRequest.cs)applied directly to each internal-plumbing request type's own declaration.
MeteredHandlerchecksit against
TRequest(not the wrapped handler instance — see design doc for why that distinctionmatters, caught during implementation via
RevokeAllSessionsForCurrentUserCommand, the one type inthe audited set with its own
WithAuthorization). 19 request types marked across both projects —16 in the first pass, plus 3 more (
CreateActivityEventCommand,CreateNotificationCommand,SendEmailCommand) that a grep-based candidate search missed due to their multi-line primaryconstructors, caught by
/code-reviewbefore archiving. See the design doc for the full list andthe new regression test that now pins it down.
outcometag (success/unauthorized/error) added toCommandsExecuted/CommandDuration,matching the
EmailsSent/PushDispatchedpattern.unauthorized=UnauthorizedAccessExceptionor
AuthenticationException(the same two typesExceptionHandler.csmaps to 401/403),error=anything else. Exceptions always rethrown — the decorator only observes.
CqsTodo.Tests/Decorators/MeteredHandlerTests.cs(no prior coverage existed forMeteredHandlerat all) — uses a realSystem.Diagnostics.Metrics.MeterListeneragainst a realCqsTodoMetricsinstance to assert actually-emitted measurements, not mocked call counts. Runs withno Docker dependency.
MeterListener-based tests rather than a manual liveSeq/OTel check (no OTel collector/Seq instance available in this sandbox) — the tests assert the
same thing a manual dashboard check would (zero samples for internal requests, exactly one
counter+histogram sample per top-level call, correct
outcometag per exception type), justrepeatably and in CI.
GetCurrentUserIdQueryis theone public internal-only request type not in
EndpointRouteBuilderExtensions's HTTP exclusionlist — a different, lower-severity instance of the same category of bug fixed in
#59's review.Drafted as
docs/features/new/61_bug_getcurrentuseridquery_missing_from_endpoint_exclusion_list.md.design (
57_metrics_decorator_scope_tech_debt_design.md)Design:
#57— ScopeMeteredHandlermetrics to top-level requestsCurrent state (confirmed by reading the code)
MeteredHandler<TRequest,TResult>(CqsTodo/Decorators/MeteredHandler.cs) is applied globally viabuilder.Services.Decorate(typeof(IHandler<,>), typeof(MeteredHandler<,>))inProgram.cs.Decoratewraps every distinct closed
IHandler<TReq,TRes>registration in the container — not just thehandlers reachable as HTTP endpoints. Two mechanisms cause a single user-facing API call to fan out
into several separately-metered handler invocations:
WithAuthorization'sAuthorizationAndValidationDecorator<TRequest,TResult,TAuthCommand>resolves its
authHandlerdependency asIHandler<TAuthCommand, Unit>— a distinct closed-genericregistration from the outer
IHandler<TRequest,TResult>it decorates, and therefore wrapped by itsown separate
MeteredHandlerinstance. E.g.DeleteCurrentUserAccountCommand(twoWithAuthorizationcalls) emits its own sample plus one sample each forAuthorizeIsCurrentUserAuthenticatedQueryandAuthorizeCurrentPasswordQuery.IHandler<,>dependency frominside another handler's
Handlebody — e.g.GetCurrentUserIdQueryHandler(used by nearly everyhandler to resolve the current user),
GetTodoQueryHandler(re-fetches a DTO after a mutation forchange-broadcast),
HashPasswordQueryHandler/GeneratePasswordSaltQueryHandler— each such callinside a top-level handler's execution also gets its own
MeteredHandlerwrapper.Confirmed not in scope:
CreateDueNotificationsCommandandPurgeStalePushSubscriptionsCommand(
DueNotificationJob, a background job) are each invoked directly by the job's own scheduling loop,not chained from within another CQS handler — each genuinely is its own independent, periodically-run
unit of work, so keeping them separately metered is correct and desired, not part of the over-counting
problem.
Decision — option (a): opt-out marker interface on the request type, checked against
TRequestpublic interface IInternalRequest : IRequest;— an empty marker, added toCommon/Cqs/IInternalRequest.cs(same folder asIHandler<,>/IRequestinIRequest.cs;Common.Cqsis already a solution-wide implicitusingviaDirectory.Build.props, so everyproject picks it up with no new
usingdirectives needed).MeteredHandler<TRequest,TResult>checks aprivate static readonly bool IsInternalRequest = typeof(TRequest).IsAssignableTo(typeof(IInternalRequest))— computed once per closed genericinstantiation, not per call — and if true, calls straight through with zero metrics recorded: no
stopwatch, no counter/histogram writes.
Marked on the request type, not the handler class — this matters, not just style. An earlier
draft of this design put the marker on the handler class instead, checked via
decoratee is IInternalHandlerinsideMeteredHandler.Handle. That breaks for any handler that alsohas its own
WithAuthorization:SetupHandler'sConfigureDistep (which appliesWithAuthorization'sAuthorizationAndValidationDecorator) runs beforeProgram.cs's single globalDecorate(typeof(IHandler<,>), typeof(MeteredHandler<,>))call, so by the timeMeteredHandlerwrapsthat closed
IHandler<,>, thedecorateeit receives is theAuthorizationAndValidationDecoratorinstance, not the marked handler underneath — an instance-based marker on the handler is invisible
through that extra layer. Caught by tracing
RevokeAllSessionsForCurrentUserCommandHandler(the onehandler in the audited set below with its own
WithAuthorization) through the actual decorationorder in
Program.csbefore implementing, not after. CheckingTRequestinstead sidesteps theproblem entirely — the request type is fixed at the generic-instantiation level and is completely
unaffected by how many decorators end up wrapping the handler instance at runtime.
Rejected alternative — attribute (
[InternalHandler]) checked viaGetCustomAttributeatHandle()-time: functionally equivalent once also moved to the request type, but needs its own cacheto avoid repeated reflection; the interface check is a plain static bool field, simpler for the same
outcome.
Rejected alternative — reuse
EndpointRouteBuilderExtensions's existing internal-only exclusion list(also request-type based, used to keep certain commands off HTTP): that list encodes a different,
narrower concern ("must not be a
POSTendpoint") that happens to overlap partially with "is plumbingfor metrics purposes" but isn't the same set — e.g.
GetTodoQueryis alreadyinternal(naturallyexcluded from HTTP mapping by the
IsPublicfilter, was never on that list), while conversely a typecan need public visibility for cross-assembly reasons (as
#59's session commands do) without thatbearing on whether it's a top-level operation. A dedicated marker declared directly on each request
type, at its own declaration site, keeps the "is this plumbing" decision next to the type it
describes instead of accumulating in a second list that must be kept in sync by hand — precisely the
fragility already called out as accepted debt in the
#59review ([[05_security_agent_memory]]).Request types marked
IInternalRequest(audited by tracing each one's only call sites — confirmedeach is dispatched exclusively from inside another handler's
Handlebody, or fromSessionMaintenanceMiddlewareon every request, never as its own top-level/HTTP/scheduled operation):CqsTodoproject:AuthorizeTodoListAccessForCurrentUserQuery,AuthorizeCurrentPasswordQuery,AuthorizeIsUserQuery,AuthorizeTodoListOwnerAccessForCurrentUserQuery,AuthorizeTodoListIsNotArchivedQuery,AuthorizeIsEmailVerifiedQuery,AuthorizeIsCurrentUserAuthenticatedQuery,GetTodoQuery,HashPasswordQuery,GeneratePasswordSaltQuery,GetSessionsRevokedBeforeUtcQuery,RevokeAllSessionsForCurrentUserCommand,SendEmailVerificationCommand,GetCurrentUserIdQuery,SetCurrentUserIdCommand,DestroyCurrentSessionCommand(the last three defined inCqsTodobuthandled in
CqsTodo.WebApi, since their handlers needIHttpContextAccessor/ISession),CreateActivityEventCommand,CreateNotificationCommand,SendEmailCommand.The first audit pass missed the last three. The initial grep-based sweep for candidates
(
internal record.*: IRequeston one line) doesn't match a multi-line primary constructor —CreateActivityEventCommand,CreateNotificationCommand, andSendEmailCommandall declare theirconstructor parameters across several lines with
: IRequest<...>on a later line, so they wereinvisible to that grep and got skipped in the first implementation pass. Caught by a
/code-reviewcorrectness-angle pass (not by the implementing agent's own audit) before this storywas archived. Closed two ways: (1) fixed — all three now carry
IInternalRequest; (2) a newCqsTodo.Tests/Decorators/MeteredHandlerTests.cstest(
KnownInternalRequestType_ImplementsIInternalRequest,[TestCaseSource]over the full 19-type listabove) pins this exact set down, so if one of these 19 ever loses the marker, CI fails — though it
can't catch a brand new internal-only type that never gets added to the list in the first place;
that's still a manual step, same residual risk already accepted for the marker mechanism itself (see
above) and for the
EndpointRouteBuilderExtensionsexclusion list before it.Two
internal-visibility request types were confirmed not internal-plumbing and deliberately leftunmarked:
CreateDueNotificationsCommandandPurgeStalePushSubscriptionsCommand(both dispatcheddirectly by
DueNotificationJob's own scheduling loop, not chained from another handler — each isgenuinely its own independent, periodically-run unit of work, so keeping them separately metered is
correct).
Decision —
outcometag onCommandsExecuted/CommandDurationMatches the existing
EmailsSent/PushDispatchedpattern (outcometag, string values). For the oneremaining metered sample per top-level call (the composed handler, including any chained
WithAuthorizationsteps that now run inside it without their own separate sample), classify:success— no exception.unauthorized—UnauthorizedAccessExceptionorSystem.Security.Authentication.AuthenticationExceptionescapes. These are the two exception types the existing
Authorize*QueryHandlerfamily andpassword-verification handlers consistently throw for auth rejections (confirmed by grep across
CqsTodo/Features/Authorization/*.cs), and are exactly the two typesCqsTodo.WebApi/ExceptionHandler.csmaps to 401/403 today — reusing the same two types keeps themetric's "unauthorized" bucket aligned with what the API actually returns as an auth failure,
without duplicating
ExceptionHandler's full status-code switch (that lives in the WebApi project;MeteredHandlerlives in the core project and has no dependency on it).error— anything else (EntityNotFoundException, validation exceptions, unexpected 500s). Notsplit further — the story asks for a 3-bucket
success/unauthorized/errorscheme, not a fullstatus-code taxonomy; a finer split can be a future story if a real dashboarding need shows up.
The exception is always rethrown — the decorator only observes and tags, never swallows.
Testing approach
New
CqsTodo.Tests/Decorators/MeteredHandlerTests.cs(no existing coverage forMeteredHandleratall — gap predates this story, from
#56).CqsTodoMetricsis a concrete sealed class wrapping a realSystem.Diagnostics.Metrics.Meter; Moq can't interceptCounter<T>.Add/Histogram<T>.Record(notvirtual), so tests use a real
CqsTodoMetricsinstance plus aSystem.Diagnostics.Metrics.MeterListenersubscribed to
"CqsTodo"to capture actually-recorded measurements — verifying real emitted valuesrather than mocked call counts. Covers: an
IInternalRequest-marked request type records zeromeasurements; a non-marked request type records exactly one
CommandsExecuted/CommandDurationpairtagged
outcome=successon success;outcome=unauthorizedforUnauthorizedAccessException/AuthenticationException(exception still propagates);outcome=errorfor any other exception(propagates); no Docker dependency, runs everywhere including this sandbox.
Out of scope (per story)
[LoggerMessage]source generators — tracked separately, not touched here.