diff --git a/.env.example b/.env.example index 35db1dd..68acfcf 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,9 @@ LOG_CHANNEL=stack LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug +ASSET_LOG_FILE=asset-activity.log +ASSET_LOG_LEVEL=info +ASSET_LOG_DAYS=30 DB_CONNECTION=sqlite # DB_HOST=127.0.0.1 diff --git a/app/Services/Logging/ActivityLogger.php b/app/Services/Logging/ActivityLogger.php new file mode 100644 index 0000000..7322950 --- /dev/null +++ b/app/Services/Logging/ActivityLogger.php @@ -0,0 +1,102 @@ + $message, + 'actor_id' => optional(auth()->user())->getAuthIdentifier(), + 'ip' => $request?->ip(), + 'user_agent' => $request?->userAgent(), + 'request_id' => $request?->headers->get('X-Request-Id'), + 'performed_at' => now()->toIso8601String(), + ], $context); + + $payload['request_id'] = $payload['request_id'] ?? Str::uuid()->toString(); + + Log::channel(self::CHANNEL)->log($level, $message, $payload); + } + + /** + * Tulis log request/response agar debugging controller lebih cepat. + */ + public function logHttp(string $action, Request $request, mixed $response = null, array $context = [], string $level = 'info'): void + { + $payload = [ + 'action' => $action, + 'actor_id' => optional(auth()->user())->getAuthIdentifier(), + 'request_id' => $request->headers->get('X-Request-Id') ?? Str::uuid()->toString(), + 'performed_at' => now()->toIso8601String(), + 'http' => [ + 'method' => $request->getMethod(), + 'uri' => $request->fullUrl(), + 'ip' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'body' => $this->maskSensitiveFields($request->all()), + ], + ]; + + if ($response instanceof Response) { + $payload['http']['response'] = $this->normalizeResponse($response); + } + + if (! empty($context)) { + $payload['context'] = $context; + } + + Log::channel(self::CHANNEL)->log($level, $action, $payload); + } + + private function maskSensitiveFields(array $input): array + { + foreach ($input as $key => &$value) { + if (in_array($key, $this->hiddenRequestFields, true)) { + $value = '[MASKED]'; + } + } + + return $input; + } + + private function normalizeResponse(Response $response): array + { + $body = $response instanceof JsonResponse + ? $response->getData(true) + : $response->getContent(); + + if (is_string($body) && strlen($body) > 2000) { + $body = Str::limit($body, 2000, '...[terpotong]'); + } + + return [ + 'status' => $response->getStatusCode(), + 'body' => $body, + ]; + } +} diff --git a/app/Support/helpers.php b/app/Support/helpers.php new file mode 100644 index 0000000..9cd6c68 --- /dev/null +++ b/app/Support/helpers.php @@ -0,0 +1,23 @@ +log($message, $context, $level); + } +} + +if (! function_exists('asset_request_log')) { + /** + * Mencatat request/response untuk kebutuhan debugging per aksi (mis. Login, Create Asset). + */ + function asset_request_log(string $action, \Illuminate\Http\Request $request, mixed $response = null, array $context = [], string $level = 'info'): void + { + app(ActivityLogger::class)->logHttp($action, $request, $response, $context, $level); + } +} diff --git a/composer.json b/composer.json index 26da18b..899a3b6 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,10 @@ "App\\": "app/", "Database\\Factories\\": "database/factories/", "Database\\Seeders\\": "database/seeders/" - } + }, + "files": [ + "app/Support/helpers.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/config/logging.php b/config/logging.php index 1345f6f..324eb9f 100644 --- a/config/logging.php +++ b/config/logging.php @@ -73,6 +73,14 @@ return [ 'replace_placeholders' => true, ], + 'asset_activity' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/'.env('ASSET_LOG_FILE', 'asset-activity.log')), + 'level' => env('ASSET_LOG_LEVEL', env('LOG_LEVEL', 'info')), + 'days' => env('ASSET_LOG_DAYS', 30), + 'replace_placeholders' => true, + ], + 'slack' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), diff --git a/tests/Unit/ActivityLoggerTest.php b/tests/Unit/ActivityLoggerTest.php new file mode 100644 index 0000000..51a7099 --- /dev/null +++ b/tests/Unit/ActivityLoggerTest.php @@ -0,0 +1,84 @@ +logPath = storage_path('logs/testing-asset.log'); + + // Pastikan file log uji bersih agar hasil tidak tercampur. + if (File::exists($this->logPath)) { + File::delete($this->logPath); + } + + config([ + // Gunakan driver single di pengujian supaya nama file tidak berubah harian. + 'logging.channels.asset_activity.driver' => 'single', + 'logging.channels.asset_activity.path' => $this->logPath, + ]); + } + + protected function tearDown(): void + { + if (File::exists($this->logPath)) { + File::delete($this->logPath); + } + + parent::tearDown(); + } + + public function test_it_writes_activity_logs_to_file(): void + { + $logger = app(ActivityLogger::class); + + $logger->log('asset.created', ['asset_id' => 42], 'info'); + asset_activity_log('asset.updated', ['asset_id' => 42, 'status' => 'in_use']); + + $this->assertFileExists($this->logPath); + + $content = File::get($this->logPath); + + $this->assertStringContainsString('asset.created', $content); + $this->assertStringContainsString('"asset_id":42', $content); + $this->assertStringContainsString('asset.updated', $content); + $this->assertStringContainsString('"status":"in_use"', $content); + } + + public function test_it_logs_request_and_response_with_masking(): void + { + $logger = app(ActivityLogger::class); + + $request = Request::create('/login', 'POST', [ + 'email' => 'user@example.com', + 'password' => 'secret', + ]); + + $response = new JsonResponse(['message' => 'ok'], 200); + + $logger->logHttp('Fungsi Login', $request, $response, ['note' => 'login controller']); + + $this->assertFileExists($this->logPath); + + $content = File::get($this->logPath); + + $this->assertStringContainsString('Fungsi Login', $content); + $this->assertStringContainsString('"method":"POST"', $content); + $this->assertStringContainsString('http://localhost/login', $content); + $this->assertStringContainsString('"password":"[MASKED]"', $content); + $this->assertStringContainsString('"status":200', $content); + $this->assertStringContainsString('"note":"login controller"', $content); + $this->assertStringNotContainsString('secret', $content); + } +}