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:
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¶
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¶
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¶
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 running — PHP_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 count — 209× 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¶
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:
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.