diff --git a/.env.example b/.env.example index 0099258..647f030 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ APP_ENV=local +LOG_ENABLED=false +LOG_PATH=storage/logs/shift.log + DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 diff --git a/.gitignore b/.gitignore index 93a8507..09e3623 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,7 @@ Engine/storage/views/ storage/cache/* !storage/cache/.gitkeep +storage/logs/* +!storage/logs/.gitkeep \.idea/ diff --git a/README.md b/README.md index d090230..22a31b1 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,9 @@ Built-in middleware includes `Shift\Middleware\CorsMiddleware`, `Shift\Middlewar ShiftPHP loads `.env` from the project root during bootstrap. Use `.env.example` as the starting point: ```env +APP_ENV=local +LOG_ENABLED=false +LOG_PATH=storage/logs/shift.log DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 @@ -245,6 +248,25 @@ DB_CHARSET=utf8mb4 Existing server environment variables are not overwritten by `.env`. +## Logging + +Structured exception logging is available through `Shift\Logging\LoggerInterface`. It uses a no-op logger by default and writes JSON lines when logging is enabled: + +```env +LOG_ENABLED=true +LOG_PATH=storage/logs/shift.log +``` + +Each log record contains `timestamp`, `level`, `message`, and `context`. Exception context includes the exception class, status code, file, line, and request data such as method, path, IP, user agent, and `X-Request-Id` when present. + +You can replace the logger through the service container: + +```php +use Shift\Logging\LoggerInterface; + +$app->getContainer()->singleton(LoggerInterface::class, new CustomLogger()); +``` + ## Database Database access uses native PDO and is registered lazily in the service container as `Shift\Database\Database` and `db`: diff --git a/REFACTORING.md b/REFACTORING.md index 3640504..b262977 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -35,6 +35,7 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled - [x] Centralized CLI command registry shared by the dispatcher and help command. - [x] Database migrations with create, migrate, status, and rollback commands. - [x] Module discovery cache for production. +- [x] Structured exception logging with JSON file logger and service container override. - [x] Removal of view storage and example page assets from runtime. - [x] Removal of legacy `application/controllers` and `application/routes.php`. - [x] Domain-oriented framework namespaces: @@ -160,6 +161,5 @@ Internal errors return a generic `500` message unless `display_errors` is enable ## Next -- [ ] Structured logging for exceptions. - [ ] CLI command aliases and richer command metadata. - [ ] Basic package-quality checks, for example static analysis and coding style. diff --git a/docs/index.html b/docs/index.html index 689131f..a107b84 100644 --- a/docs/index.html +++ b/docs/index.html @@ -24,12 +24,13 @@

Contents

  • Responses
  • Validation and DTOs
  • Middleware
  • -
  • Service Container
  • -
  • Environment and Database
  • -
  • Migrations
  • -
  • CLI
  • -
  • Errors
  • -
  • Testing
  • +
  • Service Container
  • +
  • Environment and Database
  • +
  • Logging
  • +
  • Migrations
  • +
  • CLI
  • +
  • Errors
  • +
  • Testing
  • @@ -404,7 +405,9 @@

    Environment and Database

    DB_DATABASE=shift DB_USERNAME=root DB_PASSWORD= -DB_CHARSET=utf8mb4 +DB_CHARSET=utf8mb4 +LOG_ENABLED=false +LOG_PATH=storage/logs/shift.log

    Database access uses native PDO. The app registers Shift\Database\DatabaseConfig, Shift\Database\Database, and the db alias lazily in the container.

    @@ -471,6 +474,20 @@

    Models

    #[Guarded] fields are ignored during mass assignment through create() and query update(), but can be set explicitly before save(). Supported casts include int, float, bool, string, array, date, datetime, and class names. Class casts use fromArray() when available.

    +
    +

    Logging

    +

    Structured exception logging is available through Shift\Logging\LoggerInterface. Logging is disabled by default and can be enabled with environment variables.

    + +
    LOG_ENABLED=true
    +LOG_PATH=storage/logs/shift.log
    + +

    The file logger writes JSON lines with timestamp, level, message, and context. Exception context includes exception class, status code, file, line, and request data such as method, path, IP, user agent, and X-Request-Id when present.

    + +
    use Shift\Logging\LoggerInterface;
    +
    +$app->getContainer()->singleton(LoggerInterface::class, new CustomLogger());
    +
    +

    Migrations

    Migration files live in database/migrations. Create a migration with the CLI:

    diff --git a/src/App.php b/src/App.php index 930a27c..73261b1 100755 --- a/src/App.php +++ b/src/App.php @@ -5,6 +5,9 @@ use Shift\Error\HttpError; use Shift\Database\Database; use Shift\Database\DatabaseConfig; +use Shift\Logging\ExceptionLogger; +use Shift\Logging\LoggerFactory; +use Shift\Logging\LoggerInterface; use Shift\Middleware\MiddlewareInterface; use Shift\Middleware\MiddlewarePipeline; use Shift\Response\JsonResponse; @@ -54,6 +57,7 @@ public function start(): void try { $response = $this->handleRequest(); } catch (ValidationException $exception) { + $this->logException($exception, $exception->getStatusCode()); $response = JsonResponse::error( $exception->getMessage(), $exception->getStatusCode(), @@ -61,6 +65,7 @@ public function start(): void $exception->getHeaders() ); } catch (HttpError $exception) { + $this->logException($exception, $exception->getStatusCode()); $response = JsonResponse::error( $exception->getMessage(), $exception->getStatusCode(), @@ -68,6 +73,7 @@ public function start(): void $exception->getHeaders() ); } catch (Throwable $exception) { + $this->logException($exception, 500); $response = JsonResponse::error('Internal Server Error', 500); } @@ -367,6 +373,7 @@ private function registerDefaultServices(): void $container->resolve(DatabaseConfig::class) )); $this->container->singleton('db', fn (ServiceContainer $container): Database => $container->resolve(Database::class)); + $this->container->singleton(LoggerInterface::class, fn (): LoggerInterface => LoggerFactory::fromEnv()); } public function getContainer(): ServiceContainer @@ -383,4 +390,12 @@ public function resolve(string $service) { return $this->container->resolve($service); } + + private function logException(Throwable $exception, int $statusCode): void + { + try { + (new ExceptionLogger($this->container->resolve(LoggerInterface::class)))->log($exception, $this->request, $statusCode); + } catch (Throwable) { + } + } } diff --git a/src/Error/ErrorHandler.php b/src/Error/ErrorHandler.php index 68b858a..96752d4 100644 --- a/src/Error/ErrorHandler.php +++ b/src/Error/ErrorHandler.php @@ -2,6 +2,7 @@ namespace Shift\Error; +use Shift\Logging\ExceptionLogger; use Throwable; /** @@ -48,6 +49,11 @@ public static function handleError(int $level, string $message, string $file = ' public static function handleException(Throwable $exception): void { + try { + ExceptionLogger::default()->log($exception); + } catch (Throwable) { + } + if (self::$customHandler) { call_user_func(self::$customHandler, $exception); return; diff --git a/src/Logging/ExceptionLogger.php b/src/Logging/ExceptionLogger.php new file mode 100644 index 0000000..eeaf683 --- /dev/null +++ b/src/Logging/ExceptionLogger.php @@ -0,0 +1,45 @@ +getStatusCode() : 500; + $level = $statusCode >= 500 ? LogLevel::ERROR : LogLevel::WARNING; + + $context = [ + 'exception' => $exception::class, + 'status' => $statusCode, + 'code' => $exception->getCode(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + ]; + + if ($request instanceof Request) { + $context['request'] = [ + 'method' => $request->getMethod(), + 'path' => $request->getPath(), + 'ip' => $request->getIpAddress(), + 'user_agent' => $request->getUserAgent(), + 'request_id' => $request->getHeader('X-Request-Id'), + ]; + } + + $this->logger->log($level, $exception->getMessage(), $context); + } +} diff --git a/src/Logging/JsonFileLogger.php b/src/Logging/JsonFileLogger.php new file mode 100644 index 0000000..f704202 --- /dev/null +++ b/src/Logging/JsonFileLogger.php @@ -0,0 +1,40 @@ +path); + + if (!is_dir($directory)) { + mkdir($directory, 0775, true); + } + + $record = [ + 'timestamp' => date(DATE_ATOM), + 'level' => $level, + 'message' => $message, + 'context' => $context, + ]; + + try { + $line = json_encode($record, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) . PHP_EOL; + } catch (JsonException) { + $line = json_encode([ + 'timestamp' => date(DATE_ATOM), + 'level' => LogLevel::ERROR, + 'message' => 'Log record could not be encoded.', + ], JSON_THROW_ON_ERROR) . PHP_EOL; + } + + file_put_contents($this->path, $line, FILE_APPEND | LOCK_EX); + } +} diff --git a/src/Logging/LogLevel.php b/src/Logging/LogLevel.php new file mode 100644 index 0000000..812814b --- /dev/null +++ b/src/Logging/LogLevel.php @@ -0,0 +1,11 @@ +headers, 'Header attribute should add response header.'); assertSameValue('Shift', $payload['name'] ?? null, 'Body attribute should bind JSON body key.'); }, + 'app logs unhandled exceptions with request context' => function (): void { + $router = new Router(); + (new AttributeRouteLoader())->load($router, [FailingController::class]); + $emitter = new CapturingEmitter(); + $logger = new class implements LoggerInterface { + public array $records = []; + + public function log(string $level, string $message, array $context = []): void + { + $this->records[] = compact('level', 'message', 'context'); + } + }; + + $app = new App(makeRequest('GET', '/errors/boom'), $router, $emitter); + $app->getContainer()->singleton(LoggerInterface::class, $logger); + $app->start(); + + $payload = json_decode($emitter->content, true); + $record = $logger->records[0] ?? null; + + assertSameValue(500, $emitter->statusCode, 'Unhandled exceptions should emit a 500 response.'); + assertSameValue('Internal Server Error', $payload['error']['message'] ?? null, 'Unhandled exception details should not leak.'); + assertSameValue('error', $record['level'] ?? null, 'Unhandled exceptions should be logged as errors.'); + assertSameValue('Controller exploded', $record['message'] ?? null, 'Log message should contain the exception message.'); + assertSameValue(RuntimeException::class, $record['context']['exception'] ?? null, 'Log context should include the exception class.'); + assertSameValue('/errors/boom', $record['context']['request']['path'] ?? null, 'Log context should include request path.'); + }, ]; diff --git a/tests/Feature/LoggingTest.php b/tests/Feature/LoggingTest.php new file mode 100644 index 0000000..bb348cb --- /dev/null +++ b/tests/Feature/LoggingTest.php @@ -0,0 +1,29 @@ + function (): void { + $root = sys_get_temp_dir() . '/shift-logs-' . bin2hex(random_bytes(6)); + $path = $root . '/shift.log'; + + try { + (new JsonFileLogger($path))->log('info', 'Structured event', [ + 'request' => [ + 'path' => '/health', + ], + ]); + + assertFileExists($path, 'Log file should be created.'); + + $lines = file($path, FILE_IGNORE_NEW_LINES); + $record = json_decode($lines[0] ?? '', true); + + assertSameValue('info', $record['level'] ?? null, 'Log record should contain level.'); + assertSameValue('Structured event', $record['message'] ?? null, 'Log record should contain message.'); + assertSameValue('/health', $record['context']['request']['path'] ?? null, 'Log record should contain structured context.'); + } finally { + removeDirectory($root); + } + }, +]; diff --git a/tests/Fixtures/TestControllers.php b/tests/Fixtures/TestControllers.php index 6b1f7fd..f1946a1 100644 --- a/tests/Fixtures/TestControllers.php +++ b/tests/Fixtures/TestControllers.php @@ -168,3 +168,13 @@ public function authorize(AuthenticatedUser $user, Request $request, ?string $ab return $ability === 'view'; } } + +#[RoutePrefix('/errors')] +final class FailingController extends Controller +{ + #[Get('/boom')] + public function boom(): array + { + throw new RuntimeException('Controller exploded'); + } +}