Skip to content

abilian_cdn.records

What happened: the counters and the log.

records.audit

abilian_cdn.records.audit

Who did what, to what, from where.

Every write worth asking about later leaves an entry here: objects uploaded, replaced and deleted, caches purged, tokens minted and revoked, zones created and reconfigured, people invited and disabled. Reads are not recorded — that is what the statistics are for, and a log with a line per download is one nobody reads.

The actor is described as well as referenced. A token that has since been revoked still has to have a name against the entries it left, or the log stops answering the question it exists for.

Actor dataclass

Whoever did it and where from, in a form that outlives them.

Entry dataclass

One thing that happened, before it is written down.

by_user

by_user(
    user: User, request: Request | None = None
) -> Actor

A person, named by the address they sign in with.

by_token

by_token(
    principal: Principal, request: Request | None = None
) -> Actor

A machine, named by whatever the token was called when it was made.

by_the_system

by_the_system(what: str = 'the command line') -> Actor

Something with no credential to name — provisioning, a worker.

record

record(
    session: AsyncSession,
    organisation_id: UUID,
    entry: Entry,
) -> AuditEntry

Add one entry. The caller commits, with whatever it was recording.

Not committed here on purpose: an entry saying an object was deleted, in a transaction that then rolls back, is a lie — and one saying nothing when the delete succeeded is a gap. They go together or not at all.

about_object

about_object(
    session: AsyncSession,
    zone: Zone,
    path: str,
    *,
    action: str,
    actor: Actor,
    detail: dict[str, str] | None = None,
) -> None

The common case: something happened to one object in one zone.

recent async

recent(
    session: AsyncSession,
    organisation_id: UUID,
    limit: int = 200,
) -> list[AuditEntry]

The most recent entries for one organisation, newest first.

forget_old async

forget_old(
    session: AsyncSession, now: datetime | None = None
) -> int

Drop entries past retention.

Append-only means nothing is edited, not that nothing is ever dropped: a log kept for ever is a growing store of who did what, which is a liability rather than an asset once nobody is going to ask.

records.stats.counters

abilian_cdn.records.stats.counters

Counting on the request path, and getting the counts to the database.

The request path never writes to the database. A delivered response bumps an in-memory counter; a background task upserts the counters every few seconds. Ten thousand downloads of one file in one hour cost one row and one update, rather than ten thousand inserts in front of the client.

That trade is deliberate and it is lossy: a process killed between flushes forgets its last few seconds. Statistics are for deciding what to cache and what to bill against, not for accounting, and paying a round trip per delivered byte-range would be the wrong price for the difference.

Counters

The in-memory tally, drained by the flush task.

One per process. Not thread-safe and not trying to be: an asyncio worker runs one event loop, and the increments below never await, so no request can observe a half-applied one.

record

record(
    zone_slug: str,
    path: str,
    outcome: str,
    bytes_sent: int,
    now: datetime | None = None,
) -> None

Count one response.

drain

drain(
    now: datetime | None = None,
) -> dict[Key, tuple[int, int]]

Take everything counted so far, leaving the tally empty.

CountingMiddleware

Bases: ASGIMiddleware

Counts what actually reached the client, on the way out.

Bytes are measured here rather than taken from Content-Length because a download that stops half way costs what it transferred, and a range request never claimed the whole object to begin with. The status comes from the response too, so a 404 or a refused hotlink is counted as one — Litestar puts its exception handler between this and the handler exactly so that send wrappers see the error response.

The counters come from the application rather than from this instance: registering a Router deep-copies it, middleware included, so anything this object held would be a copy nobody else can read — an empty chart with no error anywhere to explain it.

outcome_of

outcome_of(status: int, cache_status: str) -> str

Which counter a response belongs to.

A delivered response is classified by where its bytes came from, and a refused one by why it was refused — those are the two questions the numbers get asked. 304 counts as delivered: the client got the answer it asked for, and it came from what we had.

flush async

flush(
    session: AsyncSession,
    counts: Mapping[Key, tuple[int, int]],
) -> int

Add drained counters to what the database already holds.

Counters for a zone that no longer exists are dropped rather than inserted: the slug in memory is whatever a client asked for, and most of what does not resolve was never a zone in the first place.

flushing

flushing(
    config: SQLAlchemyAsyncConfig,
    counters: Counters,
    interval: float = FLUSH_INTERVAL,
)

A lifespan that keeps the counters moving to the database.

records.stats.reports

abilian_cdn.records.stats.reports

Answering the questions: how much went out, to what, and how often.

What the dashboard renders and what the API returns, including the CSV a person opens in a spreadsheet.

Summary dataclass

What one zone did over a window, broken down by outcome.

hit_ratio property

hit_ratio: float | None

Share of delivered responses that came off local disk.

None rather than zero when nothing was delivered: "no requests" and "every request missed" are different facts, and a chart that draws them the same way sends someone looking for a cache bug.

Point dataclass

One day of one zone's traffic.

PathCount dataclass

One path's share of a zone's traffic.

clamp_days

clamp_days(days: int) -> int

A window anyone may ask for: at least a day, at most what is kept.

window

window(days: int, now: datetime | None = None) -> datetime

The start of the days-day window ending today, in UTC.

summaries async

summaries(
    session: AsyncSession,
    zone_ids: Sequence[UUID],
    since: datetime,
) -> dict[UUID, Summary]

Totals per zone since since, for every zone asked about.

daily async

daily(
    session: AsyncSession, zone_id: UUID, since: datetime
) -> list[Point]

One zone's traffic per day, oldest first, skipping days with none.

fill

fill(
    points: Sequence[Point], since: datetime, days: int
) -> list[Point]

One point per day of the window, zeros included.

daily skips days nothing happened on, which is the right answer for a table and the wrong one for a chart: a bar chart with the quiet days missing draws a busy Monday next to a busy Friday and calls them neighbours.

top_paths async

top_paths(
    session: AsyncSession,
    zone_id: UUID,
    since: datetime,
    limit: int = 50,
) -> list[PathCount]

The busiest paths of one zone, by request count.

downloads async

downloads(
    session: AsyncSession,
    zone_id: UUID,
    paths: Sequence[str],
    since: datetime,
) -> dict[str, int]

How many times each of these paths was delivered.

Hits and misses only: a HEAD asked about the object without taking it, and a 404 is not a download of anything.

as_csv

as_csv(points: Sequence[Point]) -> str

A daily series, for a spreadsheet.

csv_download

csv_download(
    zone_slug: str, points: Sequence[Point]
) -> Response[str]

The export, as a browser and a token client both receive it.

One place, because the UI and the API offer the same file, and a filename fixed in one of them would otherwise stay wrong in the other.

series async

series(
    session: AsyncSession, zone_id: UUID, days: int
) -> tuple[datetime, list[Point]]

One point per day of the window, and the day the window starts on.

records.stats.rollup

abilian_cdn.records.stats.rollup

Folding hourly rows into daily ones, and dropping what has expired.

Hourly detail stops being interesting long before the traffic does, so a worker folds anything past the window into one row per day and deletes what is older than the retention.

compact async

compact(
    session: AsyncSession, now: datetime | None = None
) -> tuple[int, int]

Fold old hourly rows into daily ones and drop what is past retention.

Both halves in one transaction: a reader that saw the daily rows inserted but not the hourly ones deleted would count the same traffic twice.

records.stats.periods

abilian_cdn.records.stats.periods

How a period is named, and how the rows inside one add up.

Shared by all three of counting, folding and reporting, because a day that started at a different hour in one of them would make the three disagree about the same traffic.