Skip to content

ADR: What the database is actually doing

ADR: What the database is actually doing

Status
accepted
Date
2026-09-03

The Admin tab now shows every SQL statement this application sends, its own log, and how long both take. The interesting decision is not that it shows them. It is that the SQL table has nowhere to put a parameter value.

In plain words

This page adds a live view of every database query the site sends, and how long each takes, to the public Admin tab. Because anyone can open that page, the record that holds each query has no field for the values sent with it (parameter values), so an email address has nowhere to appear.

What that is worth: a developer can see which request caused each query and read the slowest endpoint as a number, and the organization can show its data access in public without exposing anyone's data.

Context

The Admin tab already showed health, Azure's view of the container, an hour of Application Insights, and a ring of recent errors. What it could not show is the one thing a reviewer actually asks about a data-access layer: what queries does this thing send, and how long do they take.

Entity Framework knows. It writes a line like this for every command, at Information, on the Microsoft.EntityFrameworkCore.Database.Command category:

Executed DbCommand (6ms) [Parameters=[@normalizedEmail='?' (Size = 18)],
CommandType='Text', CommandTimeout='30']
SELECT "a"."Id", "a"."Email", ... FROM "AspNetUsers" AS "a"
WHERE "a"."NormalizedEmail" = @normalizedEmail LIMIT 2

So the data exists. The question is how to get at it, and what to keep.

The constraint that decided the design

This page is public. That is not an oversight, it is ADR-010: the site is a portfolio and the Admin tab is part of what it is showing. Anyone can open it.

And the parameters of a registration carry an email address. So do the parameters of a sign-in. There is no version of "show the raw SQL" that is safe by default here.

The obvious answer is a redaction rule: show values, mask anything that looks like an address. The obvious answer is wrong in a specific and boring way. A rule that lists what to hide is correct only for the columns that existed when somebody wrote it. Add a phone number, a postal address, a note field, and the rule is silently wrong, and it is wrong on a public page, and nothing fails.

Decision

Read the command, not the log line, and never read Value.

The type the table is built from has no field for a parameter value:

/// <summary>One parameter of a SQL statement, described but never valued.</summary>
public sealed record SqlParameterShape(string Name, string Type, int? Size);

That is the whole safety argument. Not a rule about which values to hide: no place to put one. A column added next year cannot leak through this, because there is nothing for it to leak into.

Getting there means an interceptor rather than a log filter:

public sealed class SqlLogInterceptor(ISqlLog log, ICurrentRequest request) : DbCommandInterceptor
{
    private void Record(DbCommand command, TimeSpan duration, string outcome)
    {
        var parameters = new List<SqlParameterShape>(command.Parameters.Count);
        foreach (DbParameter parameter in command.Parameters)
        {
            // Name, type, size. Never Value.
            parameters.Add(new SqlParameterShape(
                parameter.ParameterName,
                parameter.DbType.ToString(),
                parameter.Size == 0 ? null : parameter.Size));
        }
        ...
    }
}

Reading EF's own message and taking the values back out of it would mean the values had already been formatted into a string, already been in memory as a message, and already been handed to every other logging provider in the process, Application Insights included. Removing something after it has been broadcast is not removing it.

A statement carries the request that caused it. A list of SELECTs with no cause is a screensaver. The same list beside GET /api/vehicles?make=Ford is an explanation, and it is the thing an interviewer would actually ask about. The interceptor gets it through a port:

/// <summary>
/// Port: what request is in flight on this call path, as one short string like
/// "GET /api/vehicles". Infrastructure asks; the API answers from the ambient
/// HttpContext. Outside a request, such as at startup, the answer is null.
/// </summary>
public interface ICurrentRequest
{
    string? Describe();
}

Infrastructure does not learn what an HttpContext is, which is the same reason IVehicleSource exists.

Percentiles are computed on read. A running percentile needs a sketch, and a sketch needs a reason. These rings hold a few hundred entries; sorting a few hundred longs costs microseconds and the answer is exact for the window rather than approximate forever. Nearest rank, no interpolation, and the empty sample answers zero rather than throwing.

Everything is in this process's memory and the page says so. Three rings: 200 statements, 300 log lines, 500 requests. They empty on every container roll. A log store is exactly the kind of thing that turns a free tier into a bill, and this is a demo.

What the tests hold

The test that matters registers an account and then reads the endpoint:

string email = $"leak-canary-{Guid.NewGuid():N}@example.com";
await _client.PostAsJsonAsync("/api/auth/register", new { email, password = "correct horse battery" });

string body = await _client.GetStringAsync("/api/admin/sql");

Assert.Contains("AspNetUsers", body, StringComparison.Ordinal);
Assert.DoesNotContain(email, body, StringComparison.OrdinalIgnoreCase);

It asserts both halves. That the statements are there, so the test cannot pass by the section being broken, and that the address is not. A second test walks every parameter in the response and asserts the serialized object has exactly three fields, so a future Value property fails here rather than on the live site. The browser suite repeats the same check against the rendered page.

Consequences

  • A reviewer can watch the queries this application runs, see which request caused each one, and see how long the database took, without a login and without a trace tool.
  • The slowest endpoint on the site is now a number on a page rather than a guess.
  • A parameter value cannot reach the page through this path, and the reason is structural rather than a rule somebody has to maintain.
  • Three more fixed-size buffers in memory, about a megabyte at capacity.
  • Timing is measured outside the error middleware, so a request that fails is still timed. An endpoint that fails slowly is the one worth seeing.

Addendum, 2026-09-08: the card has a sibling

This record describes a world with one store in it, and as of 1.0.0.89 there are two. The SQL card is unchanged and is what the first container shows. The second container shows the document store's counterpart, with the request charge and the partition beside every operation, fed by a parallel port rather than a widening of ISqlLog, for reasons given where the sibling is decided (ADR: What the store is actually doing). The no-values rule carries over exactly, and the canary test in this record now reads whichever card the container it is running on has.

The port the statements are written into, read from this build. The entry type has no field for a parameter's value, which is why no redaction rule is needed for a column that does not exist yet:

// The Admin tab shows the raw SQL this application runs (ADR: What the database
// is actually doing). Two things about the shape of these types are deliberate.
//
// First, a statement carries the request that caused it. A list of SELECTs with
// no cause is a screensaver; the same list beside "GET /api/vehicles?make=Ford"
// is an explanation.
//
// Second, there is no place to put a parameter value. Not a redacted one, not a
// masked one: the type has no field for it. The Admin tab is public and the
// parameters of a registration carry an email address. A rule that says "redact
// this column" is one new column away from being wrong; a type with nowhere to
// write the value cannot leak it whatever anyone adds later.

/// <summary>One parameter of a SQL statement, described but never valued.</summary>
/// <param name="Name">The parameter name, such as <c>@p0</c>.</param>
/// <param name="Type">The parameter's database type.</param>
/// <param name="Size">The declared size of the parameter, or null when it has none.</param>
public sealed record SqlParameterShape(string Name, string Type, int? Size);

/// <summary>
/// One SQL statement the application ran: its text, the shape of its
/// parameters, how long the database took, and the HTTP request that caused it.
/// </summary>
/// <param name="At">When the statement ran.</param>
/// <param name="Text">The SQL text as sent to the database.</param>
/// <param name="Parameters">The shape of each parameter: name, type and size, never the value.</param>
/// <param name="DurationMs">How long the database took, in milliseconds.</param>
/// <param name="Outcome">How the statement ended, such as success or the failure it met.</param>
/// <param name="Request">The HTTP request that caused the statement, like "GET /api/vehicles", or null when none did.</param>
public sealed record SqlStatement(
    DateTimeOffset At,
    string Text,
    IReadOnlyList<SqlParameterShape> Parameters,
    long DurationMs,
    string Outcome,
    string? Request);

/// <summary>Port: where recent SQL statements are kept for the Admin tab.</summary>
public interface ISqlLog
{
    void Record(SqlStatement statement);
}

/// <summary>
/// Port: what request is in flight on this call path, as one short string like
/// "GET /api/vehicles". Infrastructure asks; the API answers from the ambient
/// HttpContext. Outside a request, such as at startup, the answer is null.
/// </summary>
public interface ICurrentRequest
{
    string? Describe();

    /// <summary>
    /// Something that tells this request apart from the next one to the same
    /// path, for the store log's per-request arithmetic. The trace identifier,
    /// on the API; null outside a request. Never shown on a page.
    /// </summary>
    string? Identify();
}

/// <summary>The port wired to nothing, for the tests and the design-time tools.</summary>
public sealed class NullSqlLog : ISqlLog
{
    public static readonly NullSqlLog Instance = new();

    private NullSqlLog() { }

    public void Record(SqlStatement statement) { }
}

/// <summary>The port wired to nothing: no request, ever.</summary>
public sealed class NoCurrentRequest : ICurrentRequest
{
    public static readonly NoCurrentRequest Instance = new();

    private NoCurrentRequest() { }

    public string? Describe() => null;

    public string? Identify() => null;
}

Live from api/TheYard.Application/Shared/Stores/SqlLog.cs, region sql-log-port, read from this build at 8003063.

Where it sits

Each ring holds one piece: Application/SqlLog.cs declares ISqlLog, ICurrentRequest and SqlParameterShape, Infrastructure's SqlLogInterceptor records each command, and the host Api answers ICurrentRequest from HttpContext in HttpCurrentRequest.cs and keeps the rings in AdminObservability.cs. Dependency inversion is plain to see here, because Infrastructure needs the request in flight and gets it by asking a port Application defines, so it never sees an HttpContext. ICurrentRequest has a single method, which is interface segregation at its smallest. The cost is about a megabyte of memory and a history that empties on every roll; several instances or a need to look back past a deploy would justify a real log store.

Files

Store

The Yard, home

Welcome to The Yard

Steven Stout’s showcase: a used-vehicle auction on .NET and React, built and run by one engineer, live on Azure, with its decisions written down.

Answering in

unread

0255075100

server time at p95, the usual five minutes of the last day.

Azure this month

unread

the bill, by resource, read from Azure. The Admin tab shows every line.

Since the last roll

unread

 

rolled blue and green, with no downtime.

People who have visited

unread

30 days agotoday

Scanners, my own tools' and my own reads left out.

  •    
  •    
  •    

Tech stack

Read from the build, never typed by hand

What TheYard runs on

What we use, where it runs, the version as built, and the file that proves it.

  • .NET 1014 projects
  • React 19.3TypeScript 7.0
  • Azure6 Bicep files
  • 9GitHub Actions workflows
LayerWhat we useWhere it runsVersionProof
Backendthe API
Backendthe API.NET and ASP.NET CoreApp Service, the API container10.0TheYard.Api.csproj
Entity Framework Core (SQL Server)App Service, talking to Azure SQL10.0.12TheYard.Infrastructure.csproj
Identity with JWT bearer authApp Service, the API container10.0.12TheYard.Api.csproj
OpenAPI with Scalar API docsApp Service, /api docs2.17.13TheYard.Api.csproj
Frontendthe page
Frontendthe pageReactYour browser19.3package.json
TypeScriptYour browser7.0package.json
ViteBuild step, in the container image8.3package.json
Server-side rendering on NodeApp Service, the renderer container24render/Dockerfile
Datatwo stores, one port
Datatwo stores, one portAzure SQL DatabaseAzure, managed SQLEF Core 10.0.12TheYard.Infrastructure.csproj
Azure Cosmos DBAzure, NoSQLSDK 3.63.2TheYard.Infrastructure.Cosmos.csproj
Azureinfrastructure as code
Azureinfrastructure as codeApp Service and Container Registry (Linux container)Azure App Service and ACRlive resourceappservice.bicep
Front Door (written, switched off)Off: the subscription refuses Front Door, so the Netlify edge stands in front todayoffmain.bicep
Application Insights with OpenTelemetryAzure Monitor1.6.0TheYard.Api.csproj
Communication Services emailAzure Communication Services1.1.0TheYard.Api.csproj
Azure Functions, Flex Consumption (clock, snapshot, tracker merger, Sheet mirror, ATS sweep, daily card)func-jobhunt-ss, Flex Consumption.NET 10 isolatedJobHunt.Functions.csproj
Bicep for every resource it deploysDeployed from the repository6 Bicep filesinfra/
Edge
EdgeNetlify edge in front of the siteNetlify edge networklive resourcenetlify.toml
Quality gatesevery push
Quality gatesevery pushxUnitGitHub Actions runners2.9.3TheYard.Tests.csproj
NetArchTest architecture rulesGitHub Actions runners1.4.5TheYard.Tests.csproj
VitestGitHub Actions runners5.0package.json
Playwright with axe accessibility checksGitHub Actions runners, against the built site1.63package.json
oxlint and PrettierGitHub Actions runners1.86 and 3.9package.json
Delivery and AIhow it ships
Delivery and AIhow it shipsGitHub Actions (azure-read, blog-post, ci, deploy-function, deploy-render, deploy, edge-branch, gate, nightly)GitHub9 workflowsworkflows/
Built with Claude Code under the test gateDeveloper machine, then the gateevery pushgate.yml
Claude Code on GitHub Actions runs the job hunt's nightly lanesGitHub Actions runners, on the Azure share4 AM and every 4 hnightly.yml

Generated, not typed. A build step reads the project files, package.json, the Dockerfiles, the Bicep and the workflows; a test fails CI when this table drifts from the repository.

How it is built

How it is run

Who and why