Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@
Engine/storage/views/
storage/cache/*
!storage/cache/.gitkeep
storage/logs/*
!storage/logs/.gitkeep

\.idea/
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`:
Expand Down
2 changes: 1 addition & 1 deletion REFACTORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
31 changes: 24 additions & 7 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ <h2>Contents</h2>
<li><a href="#responses">Responses</a></li>
<li><a href="#validation">Validation and DTOs</a></li>
<li><a href="#middleware">Middleware</a></li>
<li><a href="#services">Service Container</a></li>
<li><a href="#database">Environment and Database</a></li>
<li><a href="#migrations">Migrations</a></li>
<li><a href="#cli">CLI</a></li>
<li><a href="#errors">Errors</a></li>
<li><a href="#testing">Testing</a></li>
<li><a href="#services">Service Container</a></li>
<li><a href="#database">Environment and Database</a></li>
<li><a href="#logging">Logging</a></li>
<li><a href="#migrations">Migrations</a></li>
<li><a href="#cli">CLI</a></li>
<li><a href="#errors">Errors</a></li>
<li><a href="#testing">Testing</a></li>
</ol>
</nav>

Expand Down Expand Up @@ -404,7 +405,9 @@ <h2>Environment and Database</h2>
DB_DATABASE=shift
DB_USERNAME=root
DB_PASSWORD=
DB_CHARSET=utf8mb4</code></pre>
DB_CHARSET=utf8mb4
LOG_ENABLED=false
LOG_PATH=storage/logs/shift.log</code></pre>

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

Expand Down Expand Up @@ -471,6 +474,20 @@ <h3>Models</h3>
<p><code>#[Guarded]</code> fields are ignored during mass assignment through <code>create()</code> and query <code>update()</code>, but can be set explicitly before <code>save()</code>. Supported casts include <code>int</code>, <code>float</code>, <code>bool</code>, <code>string</code>, <code>array</code>, <code>date</code>, <code>datetime</code>, and class names. Class casts use <code>fromArray()</code> when available.</p>
</section>

<section id="logging">
<h2>Logging</h2>
<p>Structured exception logging is available through <code>Shift\Logging\LoggerInterface</code>. Logging is disabled by default and can be enabled with environment variables.</p>

<pre><code>LOG_ENABLED=true
LOG_PATH=storage/logs/shift.log</code></pre>

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

<pre><code>use Shift\Logging\LoggerInterface;

$app-&gt;getContainer()-&gt;singleton(LoggerInterface::class, new CustomLogger());</code></pre>
</section>

<section id="migrations">
<h2>Migrations</h2>
<p>Migration files live in <code>database/migrations</code>. Create a migration with the CLI:</p>
Expand Down
15 changes: 15 additions & 0 deletions src/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -54,20 +57,23 @@ public function start(): void
try {
$response = $this->handleRequest();
} catch (ValidationException $exception) {
$this->logException($exception, $exception->getStatusCode());
$response = JsonResponse::error(
$exception->getMessage(),
$exception->getStatusCode(),
['errors' => $exception->getErrors()],
$exception->getHeaders()
);
} catch (HttpError $exception) {
$this->logException($exception, $exception->getStatusCode());
$response = JsonResponse::error(
$exception->getMessage(),
$exception->getStatusCode(),
[],
$exception->getHeaders()
);
} catch (Throwable $exception) {
$this->logException($exception, 500);
$response = JsonResponse::error('Internal Server Error', 500);
}

Expand Down Expand Up @@ -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
Expand All @@ -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) {
}
}
}
6 changes: 6 additions & 0 deletions src/Error/ErrorHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Shift\Error;

use Shift\Logging\ExceptionLogger;
use Throwable;

/**
Expand Down Expand Up @@ -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;
Expand Down
45 changes: 45 additions & 0 deletions src/Logging/ExceptionLogger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

namespace Shift\Logging;

use Shift\Error\HttpError;
use Shift\Request;
use Throwable;

final class ExceptionLogger
{
public function __construct(private readonly LoggerInterface $logger)
{
}

public static function default(): self
{
return new self(LoggerFactory::fromEnv());
}

public function log(Throwable $exception, ?Request $request = null, ?int $statusCode = null): void
{
$statusCode ??= $exception instanceof HttpError ? $exception->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);
}
}
40 changes: 40 additions & 0 deletions src/Logging/JsonFileLogger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace Shift\Logging;

use JsonException;

final class JsonFileLogger implements LoggerInterface
{
public function __construct(private readonly string $path)
{
}

public function log(string $level, string $message, array $context = []): void
{
$directory = dirname($this->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);
}
}
11 changes: 11 additions & 0 deletions src/Logging/LogLevel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Shift\Logging;

final class LogLevel
{
public const DEBUG = 'debug';
public const INFO = 'info';
public const WARNING = 'warning';
public const ERROR = 'error';
}
31 changes: 31 additions & 0 deletions src/Logging/LoggerFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace Shift\Logging;

use Shift\Config\Env;

final class LoggerFactory
{
public static function fromEnv(): LoggerInterface
{
if (!self::enabled()) {
return new NullLogger();
}

return new JsonFileLogger(self::path((string) Env::get('LOG_PATH', 'storage/logs/shift.log')));
}

private static function enabled(): bool
{
return in_array(strtolower((string) Env::get('LOG_ENABLED', 'false')), ['1', 'true', 'yes', 'on'], true);
}

private static function path(string $path): string
{
if (str_starts_with($path, '/')) {
return $path;
}

return APP_ROOT . '/' . ltrim($path, '/');
}
}
8 changes: 8 additions & 0 deletions src/Logging/LoggerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace Shift\Logging;

interface LoggerInterface
{
public function log(string $level, string $message, array $context = []): void;
}
10 changes: 10 additions & 0 deletions src/Logging/NullLogger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Shift\Logging;

final class NullLogger implements LoggerInterface
{
public function log(string $level, string $message, array $context = []): void
{
}
}
1 change: 1 addition & 0 deletions storage/logs/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

28 changes: 28 additions & 0 deletions tests/Feature/AppDispatchTest.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php

use Shift\App;
use Shift\Logging\LoggerInterface;
use Shift\Routing\AttributeRouteLoader;
use Shift\Routing\Router\Router;
use Shift\Service\ServiceContainer;
Expand Down Expand Up @@ -73,4 +74,31 @@
assertArrayHasKeyValue('X-Test', 'created', $emitter->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.');
},
];
Loading
Loading