# ADR: Logs that outlive the container

Status: accepted, 2026-09-13, shipped as 1.0.0.118. Steve's ask, the same
afternoon the visitor table went behind a key: "I want to make sure we have
long logs, any way to capture those in SQL?", then "do whatever you think is
best for the logs, SQL or Cosmos DB, is Cosmos better since it's
unstructured?"

## In plain words

This page decides where the site keeps its logs for the long term: one record per event in the document database (Azure Cosmos DB), set to expire on its own after a fixed time (time-to-live). Events are written in batches once a minute so no request waits, and the operator reads them on the Admin tab behind a key.

What that is worth: a developer can see what the site served last month even after the container has restarted many times, and the organization keeps a record of its errors for years at no extra cost on the free tier.

## Context

Three things this site calls a log, and how long each lasted before today.
The Admin tab's rings (the last five hundred requests, the last three hundred
log lines, the last fifty errors) are this process's memory and empty on
every roll, which the page says out loud (ADR: Observability). Application
Insights keeps every request and exception for thirty days on the free tier
behind a sign-in. It does not know which store served a request (ADR:
Telemetry). The activity counters from this morning last thirty-five days on
the document store and indefinitely on the relational one, but they are
counters: they can say how many, never which (ADR: Site activity, and the
line an address does not cross).

So the honest answer to "do we have long logs" was no. After a roll the
operator could not say what the site had served an hour earlier, and after a
month nobody could say anything.

## Decision

**One document per event, in the document store, for a year.** A container
named `logs`, partitioned on the UTC day, holding three kinds of event under
one spine (when, kind, store, level, method, path, status, duration, visitor
token, network, message, detail, trace id): a `request`, an `error` (an
unhandled exception or a browser report, with its type, message and a
bounded stack in the detail) and an `app` line (a warning this application
wrote). The container's time-to-live is the retention policy: a document
expires a year after it is written and nothing runs to delete it.

**Why the document store, and what "unstructured" actually buys.** The word is
close and the reason is more specific. A log is append-only: written all day and
read now and then. Its three kinds of line have three shapes. A document takes
each shape as it arrives, so a fourth kind next year is a new value in one
field, with no migration behind it. A write is a create and never a merge, so a
hundred of them go in one transactional batch for a few request units. Retention
is a number on the container. And a writer that runs once a minute never keeps a
database awake, which matters on the relational side: Azure SQL Database here is
the serverless free offer metered in vCore seconds, and a log that wrote every
minute would spend that allowance keeping the database from pausing. The
relational store keeps the counters the graph is drawn from, which is the query
that wants a table and an index; this is the other kind of data, and it goes to
the other store. Both containers write to the same container with a `store`
field saying which engine served the request, so the split the activity graph
shows is in the log too.

**Nothing waits on the store.** The same shape as the activity collector and
for the same reasons: a request offers its event to a bounded channel and
leaves; a hosted service drains the channel once a minute, or sooner when
five hundred are waiting, and writes one batch per day partition. A full
channel drops the oldest event rather than blocking a request, and the card
says how many were written and how many batches failed. A failed batch is
counted and never logged, because a log line about the log failing would
arrive back at the same collector.

**Three sources, one rule.** The request hook beside the request ring offers
one event per request, with the same exclusions as the activity feature (the
page's own files, the photos, this tab's reads). A logging provider offers
this application's warnings and errors, on the same allow-list of categories
as the Admin tab's ring plus the one framework category that reports an
unhandled exception; Information lines are the request log's job and are not
kept twice. The browser's error reports are logged on arrival and so reach
the provider like any other error. Every string field passes through one
function on the way in: bounded, and with every at sign turned into `%40`, so
an address in a path, a query string, an exception message or a stack cannot
be kept as one. The endpoint cleans once more on the way out, and a test
holds the absence of the at sign on the wire after an address was written
into a path and posted as a browser error.

**What is kept about a person, and what is not.** The visitor token and the
network are the activity feature's, and nothing more: a daily keyed hash and
three octets. The exception message is kept here where the public ring keeps
only the type, and the difference is the key: the ring is on a public page
and this endpoint answers only to the operator (ADR: The code is public and
the secrets are not). One thing changed on the public side while this was
built: a browser error report's message and stack are now cleaned on arrival
too, because the same text was going to the public errors ring unchanged.

**Read back from the site, behind the key.** `GET /api/admin/logs/kept`
answers to the operator's key like the visitor rows do, and is a 404 without
it. It takes a window (24h, 7d, 30d) and optionally a kind and a status. A
fragment of the path can narrow it further. The filters travel to the store
as query parameters and never as syntax, and the endpoint returns the newest
two hundred with the window's counts by kind and what the feature has cost.
The Admin tab's Kept log card sits under the activity card: a window and
three filters, then the lines grouped by day.

## Alternatives

**Tables in Azure SQL Database.** The first reading of the ask, and rejected
for the reasons above: the free offer's meter and a purge job instead of a
time-to-live. It would also mean three shapes in one table or three tables
for one log. Nothing here needs a join or a cross-cutting query that the
counters do not already answer. If a query ever does, the document store's
own SQL answers it, and a table is one adapter away behind the same port.

**Application Insights, longer.** Retention can be raised to two years for a
charge per gigabyte per month, and the query language is better than any
table this project would write. Rejected for the same reasons the activity
feature gave: the store split is not in the telemetry, nothing about it runs
in the gate, and the operator reads it in the Azure portal rather than on the
site, which is the surface this project is about.

**The rings, made bigger.** A larger ring is still this process's memory and
still empties on a roll. Rejected without much thought.

**Keep everything, forever.** At today's rate a year is a few hundred
thousand documents and well under a gigabyte, so forever would be affordable.
A year is chosen because an old log is a liability with no reader: nobody
diagnoses a fault from fourteen months ago, and a network beside a timestamp
is still a network beside a timestamp. Reversible in one number on the
container.

## Consequences

- A third home for a log, and the first that is both durable and readable
  from the site: the rings for the last few minutes, Application Insights for
  thirty days with a query language, the kept log for a year with the store
  split and the operator's key.
- The `logs` and `tests-logs` containers exist on the account; the definition
  is `infra/cosmos/logs.json` and a test holds the code to it.
- A container with no document store configured (a developer's machine, the
  SQLite gate pass) keeps nothing and its card says so; the endpoint's shape
  is the same either way.
- The drain interval is configuration (`Logs:DrainSeconds`, a minute by
  default) so the browser suite can run it at two seconds and wait for a line
  instead of a clock.

## Where it sits

The kept log copies the activity layout: LogEvent, LogText and the ILogStore port live in Application, CosmosLogStore in Infrastructure.Cosmos implements the port, and LogCollector with its logging provider sits in the host Api beside the rings it copies. Dependency inversion, meaning the inner ring defines the interface and an outer ring supplies it, is what lets a host with no document store receive NullLogStore, and that null object standing in without surprises is Liskov substitution. The cost is a write path that drops events when the channel fills or a batch fails; the card counts them and nothing retries. A need to join log lines to bids or accounts would argue for a relational table, which the record says is one adapter away behind the same port.

## Files

- [`api/TheYard.Application/Logs.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/Logs.cs): the event, the cleaning rule, the query and the port.
- [`api/TheYard.Api/Logs.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Logs.cs): how a request or a log line becomes an event, the collector, the logging provider and the report.
- [`api/TheYard.Api/Composition/ObservabilityRegistration.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/ObservabilityRegistration.cs): the wiring beside the rings and the hook beside the request ring (regions kept-logs-wiring and kept-logs-hook).
- [`api/TheYard.Api/Endpoints/AdminEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Endpoints/AdminEndpoints.cs): the keyed endpoint (region kept-logs-endpoints).
- [`api/TheYard.Infrastructure.Cosmos/CosmosLogStore.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure.Cosmos/CosmosLogStore.cs) and [`infra/cosmos/logs.json`](https://github.com/SteveStout/TheYard/blob/main/infra/cosmos/logs.json): the adapter and the container it needs.
- [`src/lib/logs.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/logs.ts) and [`src/components/admin/KeptLogsCard/KeptLogsCard.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/admin/KeptLogsCard/KeptLogsCard.tsx): the card.
- [`api/TheYard.Tests/LogTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/LogTests.cs) and [`tests/e2e/admin.spec.ts`](https://github.com/SteveStout/TheYard/blob/main/tests/e2e/admin.spec.ts): the cleaning, the collector, the provider, the endpoint, and the at sign that is never there.

```csharp
/// <summary>
/// Events off the request path. The same shape as the activity collector and
/// for the same reasons: a request offers its event to a bounded channel and
/// leaves, this service drains the channel on its own clock, and a full
/// channel drops the oldest event rather than blocking anybody. The clock is
/// a minute rather than five seconds because a log write is a create and
/// never a merge, so nothing is gained by hurrying, and a document store
/// that is written once a minute is not being kept busy by its own log.
/// </summary>
public sealed class LogCollector : BackgroundService
{
    /// <summary>How many events the channel holds before the oldest is dropped.</summary>
    public const int Capacity = 10_000;

    /// <summary>Seconds between drains when configuration does not say otherwise: a minute.</summary>
    public const int DefaultIntervalSeconds = 60;

    /// <summary>How many waiting events wake the drain before its interval is up.</summary>
    private const int DrainAt = 500;

    /// <summary>The bounded queue between a request and the store; when it is full the oldest event goes.</summary>
    private readonly Channel<LogEvent> _channel = Channel.CreateBounded<LogEvent>(
        new BoundedChannelOptions(Capacity) { FullMode = BoundedChannelFullMode.DropOldest });

    /// <summary>Where drained events are written, and where the readers query them back from.</summary>
    private readonly ILogStore _store;

    /// <summary>How long the drain waits between passes.</summary>
    private readonly TimeSpan _interval;

    /// <summary>Events offered since the last drain, which is what wakes it early.</summary>
    private int _pending;

    /// <summary>Events taken into the channel since the process started.</summary>
    private long _offered;

    /// <summary>Events the store accepted since the process started.</summary>
    private long _written;

    /// <summary>Drains whose write to the store threw.</summary>
    private long _failedBatches;

    /// <summary>When the store last accepted a batch, or null before the first.</summary>
    private DateTimeOffset? _lastWrite;

    /// <summary>Completed to wake the drain early; replaced with a fresh one after every wake.</summary>
    private volatile TaskCompletionSource _wake = new(TaskCreationOptions.RunContinuationsAsynchronously);

    /// <summary>The interval is configuration (Logs:DrainSeconds) so the browser suite can shorten it; a minute is the default and the deployed value.</summary>
    public LogCollector(ILogStore store, int intervalSeconds = DefaultIntervalSeconds)
    {
        _store = store;
        _interval = TimeSpan.FromSeconds(Math.Clamp(intervalSeconds, 1, 3_600));
    }

    /// <summary>The store this collector writes to, which the keyed endpoint and the card readers query.</summary>
    public ILogStore Store => _store;

    /// <summary>The time between drains, held between one second and one hour.</summary>
    public TimeSpan Interval => _interval;

    /// <summary>What has passed through: offered, written, batches that failed, and the last time anything was written.</summary>
    public (long Offered, long Written, long FailedBatches, DateTimeOffset? LastWrite) Counters =>
        (Interlocked.Read(ref _offered), Interlocked.Read(ref _written), Interlocked.Read(ref _failedBatches), _lastWrite);

    /// <summary>Called on the request thread, or inside a logger, and returns at once.</summary>
    public void Offer(LogEvent e)
    {
        if (_channel.Writer.TryWrite(e))
        {
            Interlocked.Increment(ref _offered);
            if (Interlocked.Increment(ref _pending) >= DrainAt)
            {
                _wake.TrySetResult();
            }
        }
    }

    /// <summary>
    /// The drain loop: wait for the interval or an early wake, drain, and
    /// repeat until the host stops, then drain once more on the way out.
    /// </summary>
    protected override async Task ExecuteAsync(CancellationToken stopping)
    {
        while (!stopping.IsCancellationRequested)
        {
            try
            {
                await Task.WhenAny(Task.Delay(_interval, stopping), _wake.Task);
            }
            catch (OperationCanceledException)
            {
                break;
            }

            _wake = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
            await DrainAsync(CancellationToken.None);
        }

        // Whatever is left goes out with the process.
        await DrainAsync(CancellationToken.None);
    }

    /// <summary>One pass: everything queued right now, in one call to the store.</summary>
    public async Task DrainAsync(CancellationToken cancellation)
    {
        var batch = new List<LogEvent>();
        while (_channel.Reader.TryRead(out var e))
        {
            batch.Add(e);
        }

        Interlocked.Exchange(ref _pending, 0);
        if (batch.Count == 0)
        {
            return;
        }

        try
        {
            await _store.AppendAsync(batch, cancellation);
            Interlocked.Add(ref _written, batch.Count);
            _lastWrite = DateTimeOffset.UtcNow;
        }
        catch (Exception)
        {
            // Counted, never logged: a log line about the log failing would
            // arrive back here.
            Interlocked.Increment(ref _failedBatches);
        }
    }
}
```
*Live from [`api/TheYard.Api/Logs.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Logs.cs#L77-L208), region `collector`, read from this build at 1d6b50b.*

```csharp
// The kept log (ADR: Logs that outlive the container), behind the same key
// as the visitor rows and for the same reason: a request log names a network
// beside a path and a time. A window, and optionally a kind, a status and a
// fragment of the path, which travel to the store as parameters and never as
// syntax. The answer says whether anything is kept at all, so a container
// with no document store shows an honest card rather than an empty table.
app.MapGet("/api/admin/logs/kept", KeptLogs);
```
*Live from [`api/TheYard.Api/Endpoints/AdminEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Endpoints/AdminEndpoints.cs#L84-L90), region `kept-logs-endpoints`, read from this build at 1d6b50b.*

## Addendum, 2026-09-13: three years

An hour after this shipped, Steve asked for long-term logs, kept in Cosmos
DB. The year above was chosen for the reason given, an old log with no
reader; his reading is that the log is the record and the record is worth
keeping, and it is his site. The `logs` container's default time-to-live is
now three years (94,608,000 seconds), applied to the live account and read
back, and the card says the number it reads. At today's rate that is under
a million documents and a few hundred megabytes on the free tier's
twenty-five gigabytes. The activity counters went to no expiry at the same
time (ADR: Site activity, and the line an address does not cross, third
addendum). Reversible in one number on each definition.

## Addendum, 2026-09-13: the self review

Steve's standing instruction, repeated the same evening, is that the author
reviews and tests the code before it ships. What was read again, line by line, after the
day's four versions had shipped, and what came of it.

**The store.** The batch write is one transactional batch per day partition
per hundred events with random ids, so a batch cannot fail on a conflict;
a failed batch is counted and the events are gone, which the card says.
The query parameterises every value including `TOP`, and the path filter is
a `CONTAINS` on a value, never text in the query. The count is a `GROUP BY`
over the same window. All three ran green on the gate's Cosmos DB pass
against `tests-logs`.

**The collector.** Bounded channel, drop-oldest, one drain in flight at a
time on its own clock, a manual drain for the tests, no logger inside it.
The provider takes Warning and above on the ring's allow-list plus the
framework's unhandled-exception category. It reads the store and the path
off the current request when there is one, and it cannot recurse: nothing
it writes is logged.

**The hook.** One change came out of the review and shipped with this
addendum: the request hook computed the visitor token twice per request:
once for the activity hit and once for the kept event. It computes it once
now and hands both the same token, network and store, which is one keyed
hash per request instead of two and one place for the address to be read.

**The page.** Forgetting the key sets it to null on both cards at once; the
rows each card fetched before that stay in state and are not rendered,
because both cards render their keyed section only while the key is
present. The filters travel as one object so applying the same filter twice
refetches, which is the cheaper of the two mistakes.

**The tests.** Eight xUnit tests on the cleaning, the collector, the
provider and the endpoint; the Cosmos-only query test skips itself on a
host with no document store rather than passing vacuously; four Vitest
tests on the key resolution and seven on the card's arithmetic; one browser
test each for the kept log, the remembered key and the proof button. Every
one ran in the gate that shipped it, on both stores where a store is
involved.

## Addendum, 2026-09-20 (1.0.0.164): the public cards, kept for a month

Looking at the Admin tab the day it got its tiles, Steve asked for every log to reach back at least
30 days, kept on the site's side if need be, and asked whether SQL or Cosmos DB suits logs better.
Since logs need no foreign keys, he leaned toward Cosmos DB for all of them, and he asked for a
choice of 24 hours, 7 days and 30 days.

**The question had been answered once, and the answer holds.** The decision above put the log in the
document store for the reasons he gives: nothing joins to a log line; its kinds have different
shapes; a write is a create; and retention is a number on the container. What the question found is
what that decision left out. Requests and errors were kept behind the operator's key, and so were
warnings. The four lists a visitor can see, recent errors, the log as the console got it, the SQL
the application ran and what the document store ran, were still rings in the process's memory, and
the tab's own words for them were "empties on every deploy". On the day the site rolled nine times,
that is what they did.

**Decision: every entry a public ring takes is also kept, in the same container, as the entry the
ring serves.** Four more kinds under the same spine, `ring-errors`, `ring-log`, `ring-sql` and
`ring-store`. Each document carries the entry as the JSON the ring's own endpoint answers with, and
nothing else: the spine's private fields, the visitor, the network, the message and the detail, are
empty on these kinds. The same collector takes them, so there is still one channel and one drain a
minute. Each day partition still gets one transactional batch, and no request waits on the store.

| Option | What it buys | What it costs |
| --- | --- | --- |
| Tables in Azure SQL Database | One store for everything | The reasons above, unchanged, and a Basic database is 2 GB and metered; a log is the one kind of data here that only grows |
| A new container for the public entries | A clean line between the keyed log and the public one | A second definition, a second pair of containers to create, a second writer, for documents with the same partition, the same index and the same life cycle |
| **The same container, new kinds, the entry kept whole** | No new resource, the writer and its tests as they are, and a card draws a month with the table it already has because the rows are the same shape | The keyed log has to say which kinds are its own, which it now does by naming them |
| Widening the keyed events to carry a statement or an operation | One shape | A statement has parameters and an operation has a partition and a charge; the spine would grow a nullable column a kind, which is the table the first decision turned down |

**Why this can be public when the kept log is keyed.** Because it is the same thing that was already
public. An error entry is a type and its frames and never a message. A statement has parameter names
and types and no field a value could be put in. An operation describes its partition and does not
name it. Those rules live in the types the rings are made of, and a kept entry is one of those types
written out. The keyed log keeps more (the message, the token, the network) and stays behind the key.
The at-sign rule is the keyed log's and does not apply here on purpose: a parameter is called `@p0`,
and cleaning it would make a kept statement different from the one the ring showed.

**Thirty-five days, by the document.** The container keeps the keyed log for three years. A ring
entry carries its own `ttl` of thirty-five days, the figure the activity counters use, because the
widest window a card offers is thirty and a document has to outlive the window that reads it. It is
one constant, `KeptRings.RetentionSeconds`, and a test holds it at thirty days or more.

**Read back by `GET /api/admin/kept`**, which takes a card, one of errors, logs, sql or store, and a
window, 24h, 7d or 30d, both picked from a fixed list, and answers with the newest two hundred
entries, how many the window holds in all, and whether anything is kept here. It is cached for half a
minute a card and window, because a public endpoint that runs a query in the store on every call is
an invitation. Each of the four cards has four buttons, Now, Last 24 hours, Last 7 days and Last 30
days, and a sentence under them that says which of three things an empty table means: nothing is
kept on this machine, nothing happened in the window, or the answer has not arrived. Now is the ring,
as it always was. The tiles and the timing card go on reading the rings whatever a card is showing.

**What it costs.** Nothing on the bill: the account is on the free tier's thousand request units a
second, and a batch of a hundred small creates into a container that indexes five paths is a few
hundred units once a minute on a busy minute. The log ring takes a line for every statement and every
operation, so those are kept twice: once as a line and once as what they were, which is what the two
cards show today and is left alone. Timing has no window of its own because it does not need one: the
traffic card's day, week and month are drawn from the minutes each site keeps (ADR: What the machines
are doing).

### Files, this addendum

- [`api/TheYard.Application/Logs.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/Logs.cs): the four kinds, the retention, and the port's new read.
- [`api/TheYard.Api/Logs.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Logs.cs): the writer the rings hand to, and the reader behind the endpoint.
- [`api/TheYard.Api/AdminObservability.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/AdminObservability.cs) and [`api/TheYard.Api/Observability.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Observability.cs): each ring hands on what it takes.
- [`api/TheYard.Infrastructure.Cosmos/CosmosLogStore.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure.Cosmos/CosmosLogStore.cs): the ring read, and the keyed query naming its own kinds.
- [`src/lib/keptCards.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/keptCards.ts): the windows, the sentence, and the stamp, React-free.
- [`api/TheYard.Tests/KeptRingTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/KeptRingTests.cs): the rings, the writer, the reader, the endpoint, and the store on the gate's Cosmos DB pass.

```csharp
/// <summary>
/// The Admin tab's public cards, kept (ADR: Logs that outlive the container, the addendum on the cards). The
/// tab's four lists, recent errors, the log, the SQL the application ran and
/// what the document store ran, are rings in this process's memory and every
/// roll empties them. Each entry a ring takes is also kept here as one more
/// kind of event, carrying the entry exactly as the ring serves it, so a card
/// reading a day, a week or a month draws the same rows with the same code.
///
/// <para>What makes that safe to serve without a key is that it is the same
/// thing already served without one. An error entry has the exception's type
/// and frames and no message; a statement has parameter names and types and
/// nowhere to put a value; a store operation describes its partition and does
/// not name it. Nothing is added on the way to the store, and the spine's own
/// private fields (visitor, network, message, detail) are left empty.</para>
/// </summary>
public static class KeptRings
{
    public const string Errors = "ring-errors";
    public const string Log = "ring-log";
    public const string Sql = "ring-sql";
    public const string Store = "ring-store";

    /// <summary>The names a card asks by, and the kind each is kept under.</summary>
    public static readonly IReadOnlyDictionary<string, string> ByCard = new Dictionary<string, string>(StringComparer.Ordinal)
    {
        ["errors"] = Errors,
        ["logs"] = Log,
        ["sql"] = Sql,
        ["store"] = Store,
    };

    /// <summary>Thirty-five days, as the activity counters keep: the widest window a card offers is thirty, and a document must outlive the window that reads it.</summary>
    public const int RetentionSeconds = 35 * 86_400;

    /// <summary>An entry larger than this is not kept; a transactional batch is a hundred of them and has a size of its own to stay under.</summary>
    public const int MostCharacters = 16_000;

    /// <summary>One ring entry as an event: the kind, the site that took it, when, and the entry as the ring serves it.</summary>
    public static LogEvent Entry(string kind, string site, DateTimeOffset at, string json) =>
        new(at, kind, site, "", "", "", "", 0, 0, "", "", "", "", "", json, RetentionSeconds);
}

/// <summary>A window of one kept ring: the newest entries as the ring would have served them, and how many the window holds in all.</summary>
/// <param name="Entries">The newest entries in the window, as JSON strings the ring would have served.</param>
/// <param name="Total">How many entries the window holds in all.</param>
public sealed record KeptRingPage(IReadOnlyList<string> Entries, int Total);
```
*Live from [`api/TheYard.Application/Logs.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Application/Logs.cs#L68-L113), region `kept-rings`, read from this build at 1d6b50b.*

```csharp
/// <summary>
/// One kept ring, newest first, and how many the window holds. The kind,
/// the site and the two bounds are parameters; the entry comes back as the
/// JSON it was written as, so the endpoint hands it to the page untouched.
/// </summary>
public async Task<KeptRingPage> RingAsync(string kind, string site, DateTimeOffset since, int take, CancellationToken cancellation)
{
    if (!(await AvailabilityAsync(cancellation)).Available)
    {
        return new KeptRingPage([], 0);
    }

    const string Where = " FROM c WHERE c.day >= @day AND c.at >= @at AND c.kind = @kind AND c.store = @site";
    QueryDefinition With(string text) => new QueryDefinition(text)
        .WithParameter("@day", since.UtcDay)
        .WithParameter("@at", since.ToUniversalTime().ToString("O"))
        .WithParameter("@kind", kind)
        .WithParameter("@site", site);

    int most = Math.Clamp(take, 1, 500);
    var entries = new List<string>();
    using (var feed = _container.GetItemQueryIterator<RingRow>(
        With("SELECT TOP @take c.entry, c.at" + Where + " ORDER BY c.at DESC").WithParameter("@take", most),
        requestOptions: new QueryRequestOptions { MaxItemCount = most }))
    {
        while (feed.HasMoreResults && entries.Count < most)
        {
            var page = await feed.ReadNextAsync(cancellation);
            Count(page.RequestCharge);
            entries.AddRange(page.Where(row => row.Entry is not null).Select(row => row.Entry!.Value.GetRawText()));
        }
    }

    int total = 0;
    using (var feed = _container.GetItemQueryIterator<int>(With("SELECT VALUE COUNT(1)" + Where)))
    {
        while (feed.HasMoreResults)
        {
            var page = await feed.ReadNextAsync(cancellation);
            Count(page.RequestCharge);
            total += page.Sum();
        }
    }

    return new KeptRingPage(entries.Take(most).ToList(), total);
}

private sealed class RingRow
{
    public System.Text.Json.JsonElement? Entry { get; set; }
    public string At { get; set; } = "";
}
```
*Live from [`api/TheYard.Infrastructure.Cosmos/CosmosLogStore.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Infrastructure.Cosmos/CosmosLogStore.cs#L185-L236), region `ring`, read from this build at 1d6b50b.*
