# Work that happens after the visitor has the page

*12 August 2026*

## Why this exists now

Pulse — traffic analytics — is the next port, and it cannot be built without
this. It records one row per page view, which means doing work on **every
request a visitor makes**, which is the single most dangerous place in the
product to put anything.

The standing rule is blunt: *no outbound call inside a visitor's request*. It
was written after a queued job with an uncapped HTTP call took a live site
down. But the rule as stated only forbids; it does not say where such work
*should* go, and "the queue" is the wrong answer for something that must happen
on literally every request — pushing a queue job per page view is more work per
request, not less.

## What Convoro has today

`public/index.php` already reads well:

```php
$response = $app->handle(Request::capture());
$response->send();
$app->terminate();
```

Two things are missing behind it.

**`terminate()` is empty.** It carries a comment saying it is *"reserved for
shutdown tasks"* and does nothing, and nothing can register anything with it.

**`send()` never releases the connection.** It writes headers and echoes the
body, and the browser stays connected until PHP exits. So anything `terminate()`
did would be time the visitor spends watching a spinner — which makes the hook
worse than useless, because it looks safe and is not.

## The shape

Two small additions, and the order matters.

1. `Response::send()` ends by handing the response to the client and letting
   PHP carry on: `fastcgi_finish_request()` under PHP-FPM, and a flush of the
   output buffers otherwise. The visitor has the page.
2. `Convoro::afterResponse(callable)` registers work, and `terminate()` runs it.

🚨 **Registration happens during the request; execution happens after it.** A
callback closes over what it needs — the path, the visitor hash, the timestamp
— and touches the database only once the connection is gone. Building the row
from memory and writing it afterwards is the whole trick.

## 🚨 The four rules this hook must carry

Every one of these is a way it could go wrong quietly.

**It cannot be a second queue.** Queued work is retried, ordered, and survives
a crash. This is none of those: it runs once, in the same process, and if the
process dies the work is lost. Anything that *must* happen goes to the queue.
This is for work that is worth doing and not worth waiting for.

**A callback that throws must not take down the next one**, and must not
surface. The response is already sent — there is no page left to show an error
on, and a fatal here would appear in the log as an unattributed shutdown error.
Each callback is wrapped; failures are logged with the name of whatever
registered them.

**It must not run on the command line.** A CLI process has no response to send
and no connection to release, and a hook that fires there turns every console
command into something that also writes analytics rows.

**Under `php -S` there is no `fastcgi_finish_request`.** The dev server keeps
the connection until the process ends, so the hook still runs — just not early.
Correct either way; only the timing differs, and it must not pretend otherwise.

## What it does not do

It does not make slow work safe. A callback that takes two seconds still holds
a PHP-FPM worker for two seconds, and a worker pool has a size. The hook moves
latency off the visitor, not off the server — an outbound HTTP call belongs in
the queue whichever side of `send()` it is on. That distinction is the one
people get wrong, so it is stated in the method's own docblock rather than only
here.

## Pulse, and why it needed saying first

With the hook in place Pulse is ordinary: build a row in memory while handling
the request, register it, write it after. The port keeps the original's privacy
design exactly — no IP column, a visitor id salted per day so it counts people
without following them across days, an `is_member` flag rather than a member
id, country only from a trusted proxy header, raw events pruned into daily
totals.

Those choices are the product, not implementation detail, so they are asserted
in tests rather than documented and hoped for.
