How QueryGuard works
Five concepts, in the order a command travels through them.
EF Core executes a command
│
▼
Interceptor ─────► asks: is a scope open? ── no ──► do nothing
│ yes
▼
Fingerprint normalize → redact → hash
│
▼
Session append a record (that is all, per command)
│
▼ scope closes
Analyzer group by fingerprint, evaluate the policy
│
▼
Result findings, ordered, already redacted
1. The session is the unit of measurement
A session is one HTTP request or one test. It is the thing a query count is a count of, and QueryGuard has nothing useful to say without one.
Sessions come from two places:
app.UseQueryGuard()opens one per request.QueryGuardScope.Start(...)opens one explicitly, for a test or a background job.
Both nest, and the innermost wins. No open session means no capture: QueryGuard stays silent rather than guessing which scope a command belongs to.
A session is mutable while open and frozen when it completes. CompletedQueryGuardSession is a separate
type precisely so "a completed session cannot change" is a compile-time guarantee rather than a
convention.
2. The interceptor is stateless
EF Core registers a DbCommandInterceptor as a singleton. One instance sees commands from every
concurrent request, every parallel test, and every fan-out inside a single request, so it cannot hold
per-scope state. It asks IQueryGuardSessionAccessor which session the command it is looking at belongs
to.
The default accessor is backed by AsyncLocal<T>, which flows with ExecutionContext. That is what makes
await boundaries and Task.Run fan-out land in the right session without your code passing anything
around.
The limitation is the same mechanism: work that suppresses context flow is not captured. In practice the
one place this bites is TestServer, which does not flow context into requests unless asked. See
troubleshooting.
The interceptor observes. It never modifies the generated SQL, suppresses a command, changes a result, or replaces an exception. See ADR-0002 and ADR-0006.
3. A fingerprint decides what "the same query" means
To say "this query ran 51 times", QueryGuard has to decide when two command texts are the same query. Raw text will not do: provider-generated parameter names differ between executions, and formatting differs between providers and EF versions.
The command text is normalized by collapsing whitespace, removing non-directive comments, and
mapping every parameter syntax to one placeholder. It is then redacted and hashed into a short
stable identifier like QG-FP-1A2B3C4D.
Normalization is deliberately conservative. It never reorders tokens, sorts clauses, canonicalizes aliases, or rewrites quoted identifiers, because the two failure modes are not symmetric:
- Over-normalizing merges genuinely different statements, so a report points at SQL your application never ran. Actively misleading.
- Under-normalizing splits one logical query into several groups, so a real pattern goes unreported. The tool is merely quieter.
When in doubt, it does less. See ADR-0005.
4. Redaction happens once, before anything can read the data
Everything QueryGuard retains passes through one policy. Parameter values and connection strings have no field anywhere in the model, string and numeric literals surviving in SQL are replaced, retained samples are bounded, and stack traces are off unless asked for.
Centralizing this is the point: a reporter that had to remember to redact would eventually forget, and adding a reporter would be a way to introduce a leak. Because redaction happens before a result exists, no reporter, including one you write, can emit what was never captured.
See ADR-0004.
5. Analysis happens after the work, not during it
Capture is one append per command. Grouping, budget evaluation, and finding creation happen once when the scope closes.
That split is why being installed costs about a nanosecond per command (benchmarks). It also means analysis can afford to sort and allocate, which is what makes deterministic ordering affordable: two runs over the same data produce byte-identical reports, so a snapshot test on a report is meaningful.
A finding is evidence, not a verdict on your design. It carries the numbers that justify it: occurrence counts, expected against actual, timing, redacted SQL, so you can disagree with it on the facts. A repeated-query candidate is a warning, because repeated SQL is strong evidence and not proof. Making it a failure requires configuring a budget, deliberately.
See ADR-0003.
Where to go next
| Configure budgets and policies | configuration |
| A finding looks wrong | false positives |
| Provider support | providers |
| Why any of this is the way it is | decision records |