Framework
Features

Logging

Four built-in logs — actions, errors, queries and devices — each stored as a model, so you can see what happened in your app with almost no wiring.

The logs

LogRecords
ActionLogUser actions — who did what, and to which record.
ErrorLogPHP errors and uncaught exceptions.
QueryLogSlow database queries (past DB_LOG_TIME).
DeviceLogDevices added and removed on sign-in.

Each one is read back the same way — a getList() / getTotal() pair fed by its generated request — so wiring any of them to an admin screen looks alike.

Action logging

Actions are declared with attributes, so they are typed and translatable. Tag a class with #[Section] to group its actions and each method with #[Action] — both take a name plus per-language labels. Then record the action from inside that method with ActionLog::add(), passing the generated Sec / Act cases (the current user and session are filled in for you):

use Framework\Log\Attr\Section;
use Framework\Log\Attr\Action;
use Framework\Log\ActionLog;
use Framework\System\Sec;
use Framework\System\Act;

#[Section("Products", en: "Products", es: "Productos")]
class ProductConstructor {
    #[Action("Create", en: "Created a product", es: "Creó un producto")]
    public static function create(ProductRequest $request): Response {
        $productID = Product::create($request);

        ActionLog::add(Sec::Products, Act::Create, dataID: $productID);
        return Response::success("PRODUCT_CREATED");
    }
}
The Sec and Act enums are generated from these attributes, so run ./framework build once after adding or changing a #[Section] / #[Action] before you can reference Sec::Products or Act::Create.

Sessions & what is stored

Actions are not stored as a flat list — each one belongs to a session. When a user signs in the framework opens a session row and keeps it open until they sign out, recording who it belongs to along with the ip and userAgent they connected from. Every ActionLog::add() during that sign-in is attached to it, so the log reads as a timeline of what happened in each visit — and you can see the device and address behind a run of actions.

Each action row itself stores:

FieldHolds
sessionIDThe sign-in session the action belongs to.
credentialID / currentUserWho performed it (and the impersonated user, when signed in as someone).
module / actionThe #[Section] and #[Action] it came from.
dataIDThe record it affected — what you pass as dataID.
moduleName / actionNameThe translated labels, ready to display.

Viewing the log

To surface the log in an admin screen, expose a route that reads it back. getAll() returns the filtered entries and getAmount() the total — hand both to Response::data as a list and a total, which the front end renders as a paginated table:

use Framework\Discovery\Attr\Route;
use Framework\System\Access;
use Framework\Log\ActionLog;
use Framework\Log\Schema\LogActionRequest;
use Framework\IO\Response;

class ActionLogController {
    #[Route("/log/action/getAll", Access::Admin)]
    public static function getAll(LogActionRequest $request): Response {
        return Response::data([
            "list"  => ActionLog::getAll($request),
            "total" => ActionLog::getAmount($request),
        ]);
    }
}

LogActionRequest is the generated request for the log model, so its fields — page, amount, credential, date range — filter and paginate the query straight from what the front end sent. Pass a field => LogActionColumn map as a second argument to also filter by your own columns, such as a client or organization.

Error log

PHP errors and uncaught exceptions are captured to the database on their own — you write nothing to record them. Each entry stores the errorCode, errorLevel and errorText, the file and line it happened on, a backtrace, and the environment it came from. Repeats of the same error increment an amount instead of piling up rows, so the list stays readable.

Expose them the same way as the action log, with getList() / getTotal(). Since an error is something you fix, markResolved() clears an entry once it is handled:

use Framework\Discovery\Attr\Route;
use Framework\System\Access;
use Framework\Log\ErrorLog;
use Framework\Log\Schema\LogErrorRequest;
use Framework\IO\Response;

class ErrorLogController {
    #[Route("/log/error/getAll", Access::Admin)]
    public static function getAll(LogErrorRequest $request): Response {
        return Response::data([
            "list"  => ErrorLog::getList($request),
            "total" => ErrorLog::getTotal($request),
        ]);
    }

    #[Route("/log/error/markResolved", Access::Admin)]
    public static function markResolved(LogErrorRequest $request): Response {
        ErrorLog::markResolved($request->ids);
        return Response::success("ERROR_LOG_RESOLVED");
    }
}

Query log

Every database query slower than DB_LOG_TIME is recorded automatically, so a slow page has a trail to follow. An entry stores the query expression, its environment, and the timings — elapsedTime for the slowest run and totalTime across all of them. As with errors, the same query increments an amount rather than adding a row, so you can sort by how often it hurts.

It is read and cleared exactly like the error log — getList() / getTotal() to list, markResolved() once a query has been optimized:

use Framework\Discovery\Attr\Route;
use Framework\System\Access;
use Framework\Log\QueryLog;
use Framework\Log\Schema\LogQueryRequest;
use Framework\IO\Response;

class QueryLogController {
    #[Route("/log/query/getAll", Access::Admin)]
    public static function getAll(LogQueryRequest $request): Response {
        return Response::data([
            "list"  => QueryLog::getList($request),
            "total" => QueryLog::getTotal($request),
        ]);
    }

    #[Route("/log/query/markResolved", Access::Admin)]
    public static function markResolved(LogQueryRequest $request): Response {
        QueryLog::markResolved($request->ids);
        return Response::success("QUERY_LOG_RESOLVED");
    }
}

Device log

Each device a user adds or removes when signing in and out is recorded by the framework through DeviceLog::added() and DeviceLog::removed(). An entry stores the credentialID, the playerID of the device, the userAgent behind it, and a wasAdded flag telling the two apart — so you can trace when a user started or stopped receiving notifications on a device.

Expose the history with the same pair:

use Framework\Discovery\Attr\Route;
use Framework\System\Access;
use Framework\Log\DeviceLog;
use Framework\Log\Schema\LogDeviceRequest;
use Framework\IO\Response;

class DeviceLogController {
    #[Route("/log/device/getAll", Access::Admin)]
    public static function getAll(LogDeviceRequest $request): Response {
        return Response::data([
            "list"  => DeviceLog::getList($request),
            "total" => DeviceLog::getTotal($request),
        ]);
    }
}

Retention

Every log grows forever if left alone, so each one has a deleteOld() that prunes entries past its retention window. How long each log is kept is set in the environment:

KeyKeeps
ACTION_LOG_DELETE_DAYSAction-log rows, in days.
ERROR_LOG_DELETE_DAYSError-log rows.
QUERY_LOG_DELETE_DAYSQuery-log rows.
DEVICE_LOG_DELETE_DAYSDevice-log rows.

Nothing prunes on its own — you schedule it. Expose one console command that calls deleteOld() on each log:

use Framework\Discovery\Attr\ConsoleCommand;
use Framework\Log\ActionLog;
use Framework\Log\ErrorLog;
use Framework\Log\QueryLog;
use Framework\Log\DeviceLog;

class LogCommands {
    #[ConsoleCommand("cleanLogs")]
    public static function clean(): void {
        ActionLog::deleteOld();
        ErrorLog::deleteOld();
        QueryLog::deleteOld();
        DeviceLog::deleteOld();
    }
}

Then run it from your server's cron once a day — the logs only need pruning at that granularity:

# crontab — daily at 05:00
0 5 * * *   cd /srv/app && ./framework cleanLogs