Skip to content

20 August 2026

14 changes:

  • A default that answered for the configuration
  • A fragment that was written twice
  • An error message from a different statement
  • A dashboard that reported empty
  • A red suite that exited zero
  • A schedule nobody was running
  • A scheduled command that reported "Done"
  • A pid that belonged to another container
  • Rows that could never be written
  • A default that named a store nobody installed
  • A table that only grew
  • A stop request with no deadline
  • A report of zeroes
  • A session that nobody is in, and a report that ranked the unmeasured first

A default that answered for the configuration

Cache::getInstance() declared $method = 'memcached'. The constructor reads the application's cache setting first and then applies the argument — if ($method != '') { $this->method = $method; } — so the argument nobody passed overwrote the configuration everybody set.

Fixed

On an installation configured for Redis, with no memcached to connect to, a single request answered three ways:

new Cache();                          // {"method":"redis","items":10}
Cache::getInstance();                 // {"method":"file","items":0}
Cache::getInstance(null, null, '');   // {"method":"redis","items":10}

The middle one is what the application actually ran on. initializeAdapter() could not reach memcached, walked down to memcache, then to the file adapter, and the process ended up with a private on-disk cache that shared nothing with the Redis store the rest of the application was using. Nothing errored. The guard immediately below, if ($this->method == '') { $this->method = 'memcached'; } — the branch meant to catch "nothing was configured" — could never be reached, because the signature had already answered.

It hit every caller without an opinion, which is every caller that should have one: CacheServiceProvider::register(), whose docblock says it warms the singleton "so it picks up the current Settings values"; Factory::getCache(); View::cache(); the four SQL-cache entry points in Database, which resolved the setting themselves and then defaulted to 'memcached' when it was absent; and the DevPanel's Cache screen, the one place that exists to show what the cache holds, which printed the item counts of an empty file store. Because getInstance() keys its instances per category, the first caller in the process — the service provider — made that file store the shared one.

$method now defaults to '', which the constructor already understood as "whatever is configured". Passing a method still wins, so nothing that named a backend changes.

Added

Two things that would have made the above visible on the day it started, rather than in a diff:

A downgrade is logged at warning level, once per process per transition, whether the adapter was abandoned because it could not connect, because its extension is missing, or because the method name was not recognised at all:

Cache: falling back from "redis" to "memcached" - could not connect to 127.0.0.1:6379.

A cache that silently changes store is a bug with no symptom of its own — a value written to Redis and read back from disk is indistinguishable from an expiry, and the application keeps answering, from a per-process cache it believes is shared.

$cache->method now names the store the instance ended up with, following the fallback chain instead of repeating the request, and getStats()['method'] reports the same name. They came from different places before: one from the requested method, one from the adapter that was actually built, so after any fallback the DevPanel labelled a store with the name of the one it had failed to reach. The original request is kept as $cache->requestedMethod, so $cache->method !== $cache->requestedMethod is now the question "did a fallback happen", and the legacy _connect(), which treats the method as a class name, still reads what it was given.

ArrayAdapter::getStats() reported 'adapter' => 'array' and no 'method' key at all; it now reports both, and Cache::getStats() fills the name in for any adapter that does not name itself rather than passing on AbstractAdapter's 'unknown' placeholder.

Documentation

Pramnos_Cache_Guide.md — when to pass a method and when to leave it out, the warning line and how to read it, and the method / requestedMethod distinction.

A fragment that was written twice

$qb->raw('NOW()') returns an Expression, and the grammar puts the fragment itself where a placeholder would go — getPlaceholder() returns the SQL, not %s. The builder then appended the Expression object to the bindings as well, so the compiled statement carried one value more than it had placeholders.

Fixed

$qb->from('authserver.loginlockouts')->where('lockoutuntil', '>', $qb->raw('NOW()'))->get();
PostgreSQL: bind message supplies 1 parameters, but prepared statement "…" requires 0
MySQL:      mysqli_stmt::bind_param(): Argument #1 ($types) must not be empty

Neither driver ran it. The expectation going in was that MySQL would tolerate the surplus and only PostgreSQL would object; MySQL prepends the statement's type string to the arguments, and a statement with no placeholders has an empty one, so it threw before the query reached the server. The integration tests cover both engines for that reason.

insert(), update(), insertOrIgnore() and upsert() each filtered Expressions out of their own value map, so the same fragment worked in a value map and threw in a WHERE — which is why the defect survived: the documented update(['last_login' => $qb->raw('NOW()')]) is fine, and the equally documented where('expires_at', '<', $qb->raw('NOW()')) was not. where(), orWhere(), having(), whereIn() and whereBetween() had no such filter.

It now lives in addBinding(), the one method every clause binds through, and covers both the scalar and the array paths (whereIn(['a', $qb->raw('…')]), a BETWEEN with one literal endpoint and one fragment).

Found while looking into an empty DevPanel: the login-lockout panel queried lockoutuntil > NOW() and its exception was swallowed into a "could not load" line, so the panel had been blank on every PostgreSQL installation since it was written.

Fixed — test suite

HumanCheckTest::testAWrongSolutionIsRefusedWithoutSpendingTheChallenge answered its challenge with a fixed string. The difficulty floor is 4 bits, so a fixed wrong answer satisfies it by accident one run in sixteen — and the nonce is fresh per run, so it was a fresh coin toss per run rather than a stable pass or a stable failure. It now searches for a candidate that provably fails meetsDifficulty() for the challenge in hand.

Documentation

Pramnos_QueryBuilder_Guide.md — what a raw value does in a WHERE, that scalars around it keep their positions, and which clauses this covers.

An error message from a different statement

Two properties on Database outlive the statement that set them, and both are read by the error path. Neither was tied to the statement being attempted.

Fixed

error_text is captured when prepare() fails, under an empty($this->error_text) guard. The guard is deliberate — the PostgreSQL retry path runs DEALLOCATE, which overwrites pg_last_error() with the retry's message, so the first error of a single prepare attempt is the one worth keeping. But nothing reset the property between statements, so "the first error of one attempt" quietly became "the first error of the request": every failure after it was reported with its message.

currentQuery is appended to the exception setError() throws, and only query() ever set it. Anything raised from a prepared statement therefore quoted whichever unprepared query had run last.

The two together produce a message that names a real error and a real query belonging to different statements, minutes apart. That is worse than no message, because it sends the reader to the wrong file — and it did:

DevPanel could not load login lockouts: 0:ERROR: bind message supplies 1 parameters,
but prepared statement "plan_6f6cd…" requires 0 ::: SQL QUERY:
INSERT INTO public."sessions" ("visitorid", "uname", "time", …)

The failing statement was a SELECT against authserver.loginlockouts. The INSERT is the session write from application boot, which had failed minutes earlier and for an unrelated reason. The same plan name appeared under three different queries in one log, which is what eventually gave it away.

prepare() now clears error_text and error_number as a statement begins, and records the statement it is preparing in currentQuery; execute() records it again when it runs a statement prepared earlier. A statement that succeeds now leaves no error behind it, so an empty error_text means "nothing went wrong here" rather than "nothing has gone wrong yet".

MySQL execution errors are untouched: since PHP 8.1 mysqli reports in strict mode, so a duplicate key throws mysqli_sql_exception out of $statement->execute() before the framework builds a message of its own, and existing callers depend on that throw. The integration tests assert it, so the difference is recorded rather than assumed.

Documentation

Pramnos_Database_API_Guide.md — a section on reading error_text, and what it used to report instead.

A dashboard that reported empty

Four panels of the DevPanel were blank on a working installation, each for its own reason, and every one of them looked identical from the browser: a table with "No data". Reported from a live PostgreSQL project — users, performance and half the overview showing nothing while the database held hundreds of rows.

Fixed

The Migrations card read "— / — / —" everywhere. It constructed \Pramnos\Database\Migrations\MigrationLoader and …\Migrations\MigrationRunner. There is no Migrations namespace — both classes live in \Pramnos\Database\ — so the line threw Class not found into a catch (\Throwable) written for a missing history table. It now resolves the same directories migrate:status does, counts a migration as applied only when its history row says result = 1, and skips the __fw_auto_* fingerprint rows, which carry no batch, sort last, and were being reported as "last applied".

The Database tab listed no tables on PostgreSQL. pg_size_pretty(pg_total_relation_size(oid)) over pg_class JOIN pg_namespace — where both relations have an oid — is column reference "oid" is ambiguous, and n_live_tup is not a pg_class column at all. Qualified, and joined to pg_stat_user_tables for the row counts. The MySQL branch bound its schema name to a ?, which the framework's prepared statements do not use; it is %s now. Sizes are also no longer printed as "552 kB KB".

The Users tab listed no sessions. The panel is headed "Active Sessions (web + API)" and filtered on ['auth', 'access_token'] — the two API types. A browser login is a web_session, so on any application that is not itself an API the panel was empty while the table held hundreds of rows. Measured on the reporting installation: 316 tokens, all web_session, all excluded.

The Performance tab threw on every request. Both queries used servertime >= NOW() - INTERVAL 24 HOUR — MySQL's interval syntax, which PostgreSQL rejects, comparing a timestamp to a column that holds a unix integer in every dialect. They also joined #PREFIX#tokenactions to a bare usertokens, users and applications, so a prefixed installation named tables that do not exist. Both are query-builder now, over an epoch window, with every table prefixed, and the endpoint resolved to its URL through the urls table instead of printing a row id under a heading that says URL.

Timestamps are rendered as timestamps. lastused, servertime and userlog.date are unix integers, and three panels printed them raw. Empty ones render as an em-dash rather than as 1970.

Sub-panels went through the query builder, so token history and user logs are no longer half-prefixed hand-built SQL.

Added

A failed section says so. panelError() wrote to a log file and nothing else, so a failed query and an empty table were the same page — which is how four broken queries survived. Failures now render as a warning above the panel, and the rest of the panel still renders.

Sessions have a time limit. A web_session token is minted per login and carries no expiry, so "active" meant "every login ever made": one installation had 342, all for one user. The panel now defaults to sessions used in the last 24 hours, with 1h / 6h / 24h / 7d / 30d / All, and leads with a per-user summary — who, which token type, how many, last seen — so a truncated list of 50 identical rows is no longer the whole answer. The count line says how many there are in total.

Background Work card, on the overview: rows waiting in the write spool and the backend holding them, plus how many scheduled tasks are defined. tokenactions is written through the spool, so an installation that never runs schedule:run accumulates rows in a file while every panel reading the drained table shows "no data" — two facts that were impossible to connect from the dashboard. The Performance tab now names the spool count in its empty state too.

The cache item Inspect button opens where you can see it. It was an ordinary div under a table of up to 100 rows, so it opened below the fold and clicking Inspect looked like nothing happening. It is a fixed overlay with a close button, and a response that is not JSON — a session that expired mid-page, most often — now says so instead of surfacing a parser error.

The Redis item browser tolerates keys it did not write. A Redis instance is shared with everything else the application keeps there; getAllItems() ran every value through unserialize(), which raises a warning rather than throwing, so the catch (\Exception) never fired and Warning: unserialize(): Error at offset 0 was printed into the page whose job is to show the cache. Foreign values are listed as type raw. load() had the same unguarded call, and then read ['data'] from its false result.

Added — guard

tests/Unit/Framework/MissingClassReferenceTest.php: no file under src/ names a fully-qualified \Pramnos\… class the autoloader cannot resolve. It is LegacyClassReferenceTest in modern clothes — that one catches the CMS-era pramnos_theme::getTheme(); this catches the same mistake made with a namespace that looks entirely plausible, which is what the migrations card was. Review does not catch it: the name is well-formed, the file it should be in exists, and the class it should name exists one segment away.

Documentation

Pramnos_DevPanel_Guide.md — a page of its own, wired into the nav: what each tab reads, what the session window means and why it exists, and a section on what to check when a panel looks empty.

A red suite that exited zero

./dockertest ran PHPUnit and then fell off the end of the script, so its exit status was whatever the last thing it happened to do returned — the if that opens the coverage report. A run that printed FAILURES! exited 0.

Fixed

FAILURES!
Tests: 9878, Assertions: 24541, Failures: 1.
[exited with code 0]

Anything reading $? — CI, a pre-push hook, an agent deciding whether it is done — was told the suite passed. The status is now captured from each of the three PHPUnit invocations and re-raised at the end. Verified both ways: a deliberate failing test exits 1, a passing run exits 0.

Broadcasting\DatabaseDriverTest::testAQuietLoopStopsAtTheRuntimeCeiling asserted that a one-second runtime ceiling ended its loop within four seconds of wall clock. It passed in isolation and failed inside a full run on a loaded machine. The invariant worth protecting is that the loop ends itself, not how many seconds that takes on any particular machine, so the bound is now generous. A test that fails for a reason unrelated to its subject teaches the reader to re-run rather than to look — which is exactly what it did here, and only the exit code above kept it from being missed entirely.

A schedule nobody was running

The framework declares periodic work of its own — spool:drain every minute, timescale:drain hourly, queue:cleanup daily — and ships work, a long-running process that runs it where there is no cron. DaemonOrchestrator supervises long-running processes. Nothing connected the two.

Added

DaemonOrchestrator now supervises work alongside the application's own daemons. Nothing to declare, nothing to remember:

[started] stats pid=118
[started] realtime pid=30
[started] schedule pid=141      ← not in buildDesiredProcesses()

The gap was invisible from every direction. A project that extends DaemonOrchestrator lists its daemons, which is what the abstract method asks for; the framework's own periodic work is not something an application should have to know exists — the whole point of FrameworkSchedule is that it does not. So an installation with an orchestrator, no crontab, and three healthy application daemons ran none of it.

Measured on the installation that surfaced this: twenty requests sitting unwritten in var/spool/, a tokenactions table that had never had a row, and a Performance panel reporting "no data for this period" — a symptom three layers from its cause, with nothing in between saying so.

includeScheduler(): false opts out for an installation whose crontab already runs schedule:run. Running both is safe — every framework task takes an overlap lock — but pointless. An application that already declares work itself keeps its own entry, recognised by what the entry runs rather than by the id it was given; a daemon merely named workflow:run or network:sync is not mistaken for it, which would have switched the schedule back off for exactly the projects that have one.

Documentation

Pramnos_Workers_And_Daemons_Guide.md — §1c gains the third way to run the schedule (nothing to do), and §3 documents what the orchestrator adds, both overrides, and why.

A scheduled command that reported "Done"

Scheduler::command('spool:drain') shelled out to the literal php pramnos, and threw the exit status away. In a scaffolded application — whose console is <cliName>.php in the project root, as the scaffolder itself documents — that command does not exist.

Fixed

Running: Write rows buffered out of the request path
Could not open input file: pramnos
  ✓ Done

Reported from a project running on dev-main: all three framework command tasks — spool:drain, timescale:drain, queue:cleanup — had never done anything, on any run, since the installation existed. WriteSpool::pending() stood at 478 with tokenactions empty. The scheduler above it was green the whole time.

The console is now the one the process is runningPHP_BINARY plus $_SERVER['SCRIPT_FILENAME'], in CLI only — because the process running the scheduler is by definition a console that knows the commands the scheduler wants to run. A fixed name is a guess; the running script is a fact. PRAMNOS_BIN still overrides it.

A non-zero exit now throws. schedule:run and work already catch per task, print the failure and count it; the status simply never reached them. This is the half that matters: either fix alone would have exposed the other, but only this one turns "a bug found by someone counting rows in a spool file days later" into "a bug found the first minute it happens".

The overlap lock is released through run()'s existing finally, so a failing command does not lock its own task out of every subsequent minute.

Documentation

Pramnos_Workers_And_Daemons_Guide.md — how a command task is run, what PRAMNOS_BIN overrides, and what a non-zero exit does.

A pid that belonged to another container

withoutOverlapping() wrote its own pid to a file and, next time round, asked posix_kill($pid, 0) whether that number was alive. Locally.

Fixed

A pid is a fact about the process table of whoever is asking, and the ordinary shape here is two containers sharing one var/ — an application container and a daemon container. Each reads the other's pid, finds some unrelated local process holding that number, and concludes the task is still running. The task is then skipped for as long as that number stays in use, which for a low pid on a busy container is indefinitely.

Nothing is logged, because "skipped: previous run still active" is a normal thing for a scheduler to say. This is the third variation of the same mistake found in this repository — after wsWorkerHealthy() and the console's process-table check — and the pattern is worth stating once more: a pid is only evidence to the kernel that owns it.

The lock is now a WorkerLock, which the framework already had and which records the host beside the pid: the pid is trusted when the host matches, and the holder is judged by heartbeat age when it does not. That also closes a race the old code had — isLocked() then acquireLock() is a check followed by a write, and two schedulers a millisecond apart both passed the check. acquire() is one atomic create.

withoutOverlapping() takes an optional second argument for the staleness threshold, for a task that legitimately runs longer than the default two minutes.

Upgrading: a lock left in the old format is a bare pid rather than JSON. WorkerLock::readState() reports it as an unknown holder with the file's own age, so it is honoured while fresh and taken over once stale — an upgrade neither runs a task that is already running nor inherits a lock that outlives the process that wrote it.

Reported by a downstream project running two containers over one volume.

Documentation

Pramnos_Workers_And_Daemons_Guide.md — a section on overlap protection across containers, and what the threshold argument is for.

Rows that could never be written

The write spool is a promise to write a row later. Nothing decided what to do when later turned out to be too late.

Added

A row that fails is retried five times and then parked in <table>.spool.failed, with the error that stopped it and a timestamp — removed from the spool, never read back, and not counted as pending.

The case is ordinary and permanent: tokenactions carries a foreign key to usertokens, and a token cleaned up while its rows waited takes the key with it.

insert or update on table "_hyper_12_225_chunk" violates foreign key constraint
DETAIL: Key (tokenid)=(3907) is not present in table "usertokens"

Nothing will make that row writable. The drain requeued it anyway, so on the installation that reported this — where the drain had never run at all, and the backlog outlived the tokens it referenced — 209 rows failed every minute once the schedule started working, each printing its own line. A backlog that cannot drain is worse than one that never drained: it is loud, it is permanent, and it buries the failures that are actionable.

Parked rather than dropped, because the row is somebody's audit trail and "here is what we could not write, and why" is something an operator can act on. spool:drain --status reports the count, WriteSpool::parked() returns it.

Identical failures are now reported once with a count209× insert or update on table … violates foreign key constraint — with at most three distinct messages per file and the rest counted. The per-row line is gone.

--max-attempts=N tunes the limit per run, WriteSpool::setMaxAttempts() in code, and the spool_max_attempts setting per installation. 0 restores the old behaviour of retrying for ever, for an installation that would rather keep the rows and fix the cause.

The line format is unchanged for rows appended normally; a requeued row is wrapped with its attempt count, and a drain reads both — so an upgrade mid-backlog loses nothing.

Documentation

Pramnos_Workers_And_Daemons_Guide.md §1d — the write spool, the retry limit, what a parked row is and what to do with it.

A default that named a store nobody installed

The cache default fixed this morning was the one in getInstance()'s signature. There was a second one, in the constructor, and it produced the same outcome from the other direction.

Fixed

if ($this->method == '') { $this->method = 'memcached'; }

On an installation with no cache section — Settings::getSetting('cache') returning false — that is what decided the store. No memcached, so the chain walked down to memcache, then to the file adapter, on a machine with Redis running and working. Reported from a project in exactly that state.

The default is now the first backend whose extension is present, Redis first, then memcached, then memcache, then file. Naming a store nobody installed is not a default, it is an answer — and it also cost two pointless hops through adapters that could never load, each of which (since fallbacks began logging this morning) wrote a warning about abandoning a store nobody had asked for.

A Redis cache with no connection details of its own now uses the framework's Redis. REDIS_HOST and friends are the documented way to configure Redis and \Pramnos\Redis\ConnectionManager is what reads them — but this class read only its own cache settings and otherwise assumed localhost. In a container stack, where Redis is a service name, that is the difference between a working cache and a file. Adopted value by value, so a cache section that names a hostname keeps it.

Measured on the reporting installation, with its cache settings removed entirely: before, requested=memcached resolved=file, an empty store; after, requested=redis resolved=redis, reading the 22 live entries the rest of the application had put there.

The DevPanel's Cache tab shows both — the adapter in use and what was configured, with a badge when they differ. An installation running Redis and caching to disk used to look exactly like an installation configured for disk.

A failed Redis connect no longer writes two entries. phpredis raises a PHP warning and returns false; ConnectionManager already turns that into an exception naming the host and port, and callers log it. The raw warning is silenced, so a cache falling back does not put a second line in the log for a condition already reported — in the exact situation where the log matters most.

Documentation

Pramnos_Cache_Guide.md — a section on what happens when nothing is configured, and how a Redis cache finds its server.

A table that only grew

A web_session token is created on every web login. It had no expiry, loadByToken() reads 0 and NULL as "never expires", and the cleanup that exists covered only auth and access_token — and had no caller anywhere in the framework.

Added

Reported from a two-day-old development installation with a single user:

tokentype   | status |  n   | expires NULL/0
web_session |      1 | 7255 | 7255

About 230 an hour. usertokens is also the table tokenactions points a foreign key at, so those rows are not only dead weight — they are what a buffered write ends up outliving.

Three things had to be true and none of them was:

A new token knows when it stops being valid. createWebSessionToken() now sets an expiry — 30 days by default, web_session_lifetime to change it, 0 to keep tokens that never expire for an installation that has a reason to want them. Generous next to the PHP session the token belongs to, whose idle timeout is 24 minutes out of the box.

The cleanup covers the type that accumulates. cleanupAllAuthTokens() retires every session-bearing type, and takes an optional list for a caller that wants only some of them. Retiring is status = 2, not a delete: the row stays for the audit trail and stops being accepted.

Something runs it. auth:token-cleanup, scheduled daily by the framework. The method it calls has existed for a long time; what was missing was anything that called it. An application with no token table is not a failure — the command recognises a missing table and says nothing, because a daily red line for a table that was never meant to exist is how a log stops being read.

lastused is updated on every request that presents a token, so "idle for a month" means what it says. Existing rows keep their expires = 0 and are retired by idleness rather than by expiry, which is the only safe reading of a token created before the rule existed.

Documentation

Pramnos_Authentication_Guide.md — web-session tokens, the lifetime setting, and the scheduled cleanup.

A stop request with no deadline

DaemonOrchestrator escalates a stop to SIGTERM after 30 seconds — on the teardown path, for a process that has left the desired set. The two paths that stop desired processes recorded nothing, so the deadline ten lines away never started.

Fixed

A redeploy calls requestStopAll() and then re-execs the orchestrator. The new image knows only what is in the state file, and the stop was not in it. The disabled path — the hook a deploy-pause sentinel hangs on — polls and nothing more. Either way a daemon that does not poll its own sentinel was never stopped, never signalled, and never reported as anything but healthy.

Reported from a project where realtime:serve ran 1h32m across three deploys, with realtime-html-<id>.lock.stop on disk the whole time and three [stop-all] stop requested lines in the log — the one worker bridging Redis to every WebSocket client, serving the old code, while the supervisor called it [ok].

requestStopAll() now records stoppingAt in the state file, where it survives the re-exec, and reconcile starts a deadline for any sentinel it finds without one — a state file from an older release, or a sentinel somebody touched by hand. Past the grace period the worker is signalled and reported as its own event:

[waiting]      realtime pid=30 — gracefully stopping, will restart when done (24s before SIGTERM)
[stop-timeout] realtime pid=30 — ignored the stop sentinel for 31s, sent SIGTERM

[stop-timeout] rather than a line about the deploy, because "this worker had to be signalled" is a fact about the worker.

A .stop sentinel now counts whatever requireLockFile says. It was read as part of the lock check, which requireLockFile => false forces true — so for exactly the daemons that keep no lock, the orchestrator's own instruction to stop was invisible to it. That is why the reported worker was healthy for an hour and a half with a stop pending: not one bug but two, each hiding the other.

[ok] … (lock active) now says (pid alive) when no lock was read. For 1h32m that line named a lock file that did not exist, in the log an operator reads to find out what is running. The state it describes is "the pid is alive" — a wedged daemon satisfies it, and so does one that never wrote a lock. Two words, and the difference between a log that reports evidence and one that reports an assumption.

Documentation

Pramnos_Workers_And_Daemons_Guide.md §3 — what a stop deadline is, what [stop-timeout] means, and what requireLockFile => false costs (the stale-heartbeat restart, which is the one restart a pid check cannot make).

A report of zeroes

The DevPanel's slowest-endpoints report finally had data in it, and it looked like this:

Endpoint                                              Method  Calls  Avg ms  Max ms
http://127.0.0.1/devpanel/logs?request=f768ff13af8a…  GET     1      0.0 ms  0.0 ms
http://127.0.0.1/devpanel/logs?request=8faf840bfe40…  GET     1      0.0 ms  0.0 ms

Two defects, one screen.

Fixed

Every duration was zero. Token::addAction() holds the row and updateAction() completes it with the status and the duration — but only the API path calls updateAction(). A web request is written by the shutdown flush, which wrote the held row exactly as it was held: no duration, no status, for every page view ever logged. The flush now fills in both, which is the whole point of writing at shutdown rather than at the start of the request: by then the request is over and the answers exist.

A negative $return_status still means "do not record an outcome" — that decision is now stored as an explicit null so the flush can tell it from "nobody has said yet".

Every URL was distinct. urls is described as a deduplicated registry — one row per endpoint — and it was given the absolute URL including the query string, so a page whose query carries an id gets a row of its own on every call. Twenty rows of one call each is a registry with nothing deduplicated in it and a report with nothing to compare.

The endpoint is now the path. The query is not lost: it goes into params, where a request's inputs belong, whenever params would otherwise be empty — which is every GET, since a GET's body is empty by definition. The scheme and host go too; every row in an installation has the same one, and an application that needs them can replace the row transformer, which is what WriteSpool::transform() is for.

Rows written before today keep their absolute URLs and will group separately until they age out.

Documentation

Pramnos_Authentication_Guide.md — what a logged request records, which path records it, and where the query string goes.

A session that nobody is in, and a report that ranked the unmeasured first

Two follow-ups from the same screen, both reported after the fixes that were supposed to settle it.

Fixed

The slowest-endpoints report still read 0.0 ms — for a reason underneath the one already fixed. Web requests now carry a duration, but every row written before that does not, and ORDER BY avg_ms DESC puts NULLs first on PostgreSQL. So the top twenty were the unmeasured historical rows, each rendered as 0.0 ms, with the real measurements pushed off the list. Measured on the reporting installation: 32 rows, 2 of them timed, and the two were invisible.

The report now reads only calls that were timed — a row with no duration has nothing to say about speed — and when rows exist but none of them are timed, it says that rather than "no data for this period". After the change, that installation's report reads /devpanel/logs GET 2 calls 20.1 ms avg 25.2 ms max.

A web session is now bounded by the PHP session it belongs to. web_session is accepted through $_SESSION['usertoken'], so once PHP has expired the session — session.gc_maxlifetime, 24 minutes out of the box — the row cannot be used by the browser that owns it, whatever expiry the token itself carries. Listing it as an active session lists something nobody can use.

The panel applies the bound per type: web sessions inside the idle timeout, API tokens inside the window you selected, and All lifts both. It says so on the page, because a session visible in the database and absent from the panel is otherwise a puzzle rather than an answer.

The same installation went from 404 "active sessions" — one user, two days of logins — to 0, which is the true number for a browser that last did something this morning.