timefrontiers / php-error-log
PHP Error Log writer and reader with pagination
Requires
- php: >=8.5
Requires (Dev)
- php-parallel-lint/php-parallel-lint: ^1.4
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.5
This package is auto-updated.
Last update: 2026-09-01 01:14:35 UTC
README
A confined, injection-safe flat-file diagnostic logger with bounded pagination, rotation, retention, and v1 record compatibility.
Requirements
- PHP 8.5 or newer
- a private application-owned log root
composer require timefrontiers/php-error-log:^1.1
Configure a confined logger
use TimeFrontiers\ErrorLog; use TimeFrontiers\ErrorLogging\LogPolicy; $log = new ErrorLog( base_dir: '/srv/linktude/var', process_name: 'billing-operation', is_error_log: true, policy: new LogPolicy( maxErrors: 50, maxRecordBytes: 65_536, maxFileBytes: 10_485_760, retentionSeconds: 2_592_000, maxRetentionDeletes: 1_000, maxReadBytes: 10_485_760, ), );
The generated target is {root}/errors/{YYYY-MM}/{process}.log, or {root}/logs/... when is_error_log is false. An empty process name produces a date-based filename.
The process name is a basename, not a path. Active log stems are limited to 166 characters so the complete timestamped rotation filename remains within the package's leaf bound. Separators, drive names, .., NULs, Windows alternate streams and reserved device names are rejected. The same active-stem limit applies to confined custom targets. A leaf matching the package's timestamped rotation grammar is reserved: it is accepted only inside the exactly owned .process.rotated store under the platform's case rules and never falls back to an ordinary custom filename. The configured root is canonicalized, generated and custom targets must remain beneath it, and existing symbolic links in the path are rejected. setFile() and the optional path passed to read() accept only confined .log files:
$log->setFile('/srv/linktude/var/custom/import.log'); // inside configured root
Directories and files created by the package are set to 0700 and 0600 where POSIX modes are supported. On Windows, chmod() is best effort and does not replace NTFS ACL configuration; provision the root with an account-specific ACL. The root must remain application-owned because PHP does not expose a portable openat(O_NOFOLLOW) equivalent for closing every filesystem race.
Stable response statuses
use TimeFrontiers\ResponseStatus; $log->write(ResponseStatus::NO_ERROR, 'Import completed'); // 0.0 $log->write(ResponseStatus::NO_TASK, 'Nothing changed'); // 0.1 $log->write(ResponseStatus::NO_DATA, 'No rows found'); // 0.2 $log->write( ResponseStatus::PROCESS_ERROR, 'The import could not be completed.', ['The input archive was rejected.'], // 3.1 );
0.0, 0.1, and 0.2 are complete statuses and require zero errors. 1. through 5. are typed prefixes and receive the normalized error count. A caller may supply a complete error status such as 4.2 only when it exactly matches the two supplied errors. Malformed values, controls and record delimiters fail closed.
The existing write(ResponseStatus|string, string, string|array): bool entry point remains available. Array values are deliberately limited to finite scalars and null; nested arrays, resources and objects are rejected rather than converted accidentally.
Structured diagnostics and redaction
Keep guest-safe text separate from internal context:
use TimeFrontiers\ErrorLogging\LogRecord; $log->writeRecord(new LogRecord( status: ResponseStatus::THIRD_PARTY_ERROR, publicMessage: 'The delivery provider rejected the request.', safeDetails: ['The operation can be retried.'], internalContext: [ 'provider' => 'example', 'attempt' => 2, 'token' => $providerToken, // value becomes [REDACTED] 'exception' => $exception, // class only; no message or trace 'provider_payload' => $payload, // value becomes [REDACTED] ], ));
DefaultRedactor is the central hook for public text, details and structured context. It removes common credentials, authorization tokens, email addresses, card-like values, SQL statements and stack frames. Sensitive context keys—including SQL, tokens, credentials, personal data, provider payloads and stack traces—are replaced wholesale. A custom RedactorInterface may enforce a stricter application policy.
Do not pass raw SQL/database errors, credentials, tokens, personal data, provider payloads or exception traces as supposedly safe text. The default redactor is a final containment layer, not authorization to collect secrets.
Versioned record format
New records use format version 2:
Version:>2/>Date:>2026-08-20 12:34:56/>Status:>3.1/>ErrorCount:>1/>Message:>Import failed/>Errors:>Archive rejected
The physical record remains one line. Version 2 escapes %, />, :>, \>, CR and LF before serialization, then decodes them in reverse order. Literal escape-looking input therefore remains literal and cannot forge another field or record. The reader also accepts valid v1 lines and normalizes the old 0.00, 0.10, and 0.20 serialization defect to 0.0, 0.1, and 0.2.
Default bounds are 50 diagnostics, 64 KiB per record, 10 MiB per active file and 10 MiB per read snapshot. A record that exceeds a bound is not written.
Reads and failure states
use TimeFrontiers\ErrorLogging\ReadState; $result = $log->readResult(page: 1, limit: 25); if ($result->state === ReadState::OK || $result->state === ReadState::PARTIAL) { foreach ($result->entries as $entry) { // status, date, subject, content } }
ReadState distinguishes missing, empty, ok, partial, and failed. A partial result contains valid records plus a malformed-record count. read() remains the compatibility adapter returning only the valid entries; inspect getReadState() or getLastReadResult() when the distinction matters.
Active-file readers take a shared companion lock and cache a bounded snapshot for pagination. Writers take the paired exclusive lock, so cooperating readers never see a partially appended record. Rotated-file readers share one stable .process.rotated.lock beside the private rotation directory; retention takes that same lock exclusively before inspecting or deleting rotations. Windows ownership and lock mapping use the same ASCII case-insensitive comparison and a lowercase stable-lock stem, so accepted active/store/file case variants cannot split reader and retention locks. Reusing the logger with read(null, $nextPage, $limit) reuses the snapshot when file metadata is unchanged. Failures expose only a stable safe message through getLastFailure() and an exception class through getLastFailureType(); paths and raw internal exception messages remain private.
print_r() and var_dump() receive a bounded ErrorLog projection containing only read state, pagination counts and the safe failure/type. Retained paths, collaborators and exception graphs are excluded. ErrorLog and LogRecord both reject serialization and unserialization; LogRecord debugging exposes only status, byte length and counts, never public text or internal-context keys/values.
Rotation and retention
Before an append would cross maxFileBytes, the active file is renamed to:
.process.rotated/process.YYYYMMDD-HHMMSS.8hex.log
The append then creates a new private active file. Each active log owns a private rotation directory, so unrelated files before or after the process filename in the active directory are never enumerated. The 166-character active-stem contract is also used to recognize rotated names and derive locks, including confined custom files. Rotation locking uses the single sibling .process.rotated.lock, not per-rotation files inside the directory. On writes, retention holds that lock exclusively and uses a memory-lazy iterator over package-owned rotation candidates. Current rotations do not consume the deletion budget; each pass removes at most maxRetentionDeletes expired rotations, regardless of directory order. This is a deletion bound, not a bound on the number of directory entries inspected. Rotated reads leave only the stable sibling lock, so their artifacts cannot consume every later batch; when more expired rotations exist than one deletion batch permits, later writes continue converging. Set retentionSeconds to 0 to disable automatic deletion.
Development gate
composer validate --strict --no-check-publish composer check composer audit --locked
CI runs the gate on PHP 8.5 with current and lowest supported dependencies.
License
MIT. See LICENSE.