Produced by Fourier

Laravelのミドルウェアとコンテクストを活用してアクセスログを取ろう

Ohashi Ohashi カレンダーアイコン 2024.07.19

ウェブアプリケーションの開発と運用において、ユーザーの行動を把握し、パフォーマンスを最適化するためにはどうしても避けては通れないのが「アクセスログ」です。アクセスログは実際にユーザーがどのような操作を行ったのか、どのページでどれだけの時間を過ごしたのか、どの機能がよく使われているのかなど、運用に必要不可欠な情報を提供してくれます。

今回の記事では、Laravelフレームワークを使用したアクセスログの取得方法について詳しく解説していきます。Laravelのミドルウェアと新たに導入されたコンテクストを活用して、効率的にアクセスログを収集する手法をご紹介します。また、ログの保存先についても触れています。

これらの情報を元に、自身のウェブアプリケーションの運用をより効果的に行っていただければ幸いです。

前提条件

  • Laravel11以上で環境構築済み

実装

ゲストユーザにIDを付与するミドルウェアを作成

ユーザがログインしていない状態(ゲストユーザ)でも、一定期間のアクセスログを紐付けるために、ゲストユーザに一意のIDを付与します。このIDはセッションの終了時に破棄され、新たなセッション開始時に新しいIDが付与されます。このIDは、Cookieにも保存することでフロントエンドでの使用も可能にしています。

  1. ミドルウェアを作成する
    php artisan make:middleware AssignIdToGuestUser
  2. ミドルウェアを実装する
    <?php
    
    namespace App\Http\Middleware;
    
    use Closure;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\Auth;
    use Illuminate\Support\Facades\Context;
    use Illuminate\Support\Facades\Cookie;
    use Illuminate\Support\Str;
    use Symfony\Component\HttpFoundation\Response;
    
    class AssignIdToGuestUser
    {
        const KEY = 'guest_id';
    
        /**
         * Handle an incoming request.
         *
         * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
         */
        public function handle(Request $request, Closure $next): Response
        {
                if(!Auth::check()) {
                return $this->handleGuest($request, $next);
            }
            
            $this->forget($request);
            
            return $next($request);
        }
    
        private function handleGuest(Request $request, Closure $next): Response
        {
            $guestId = $request->session()->get(self::KEY, $this->generateId());
    
            Context::add(self::KEY, $guestId);
            // フロントエンドでも使用できるよう、Cookieにも保存する
            Cookie::make(name: self::KEY, value: $guestId, httpOnly: false);
    
            return $next($request);
        }
    
        private function forget(Request $request): void
        {
            $request->session()->forget(self::KEY);
            Cookie::forget(self::KEY);
        }
    
        private function generateId(): string
        {
            return Str::orderedUuid()->toString();
        }
    }

リクエスト情報を付与するミドルウェアを作成

次に、アクセスログに必要な情報を付与するミドルウェアを作成します。ここではリクエスト情報(URLやメソッド、ユーザーエージェントなど)を付与します。

また、パフォーマンスの観点から、リクエストの開始時間と終了時間、処理にかかった時間、メモリ使用量なども記録します。これらの情報はパフォーマンスチューニングに役立つデータとなります。

  1. ログから除外する項目を config/logging.php に設定する
    return [
        //...
        'masked_fields' => [
            '_token',
            'password',
            'password_confirmation',
        ],
    ];
  2. ミドルウェアを作成する
    php artisan make:middleware AddAccessInfo
  3. ミドルウェアを実装する
    <?php
    
    namespace App\Http\Middleware;
    
    use Carbon\CarbonImmutable;
    use Closure;
    use Illuminate\Http\Request;
    use Illuminate\Log\Context\Repository;
    use Illuminate\Support\Facades\Auth;
    use Illuminate\Support\Facades\Context;
    use Illuminate\Support\Facades\Cookie;
    use Illuminate\Support\Facades\Hash;
    use Symfony\Component\HttpFoundation\Response;
    
    class AddAccessInfo
    {
        /**
         * Handle an incoming request.
         *
         * @param  \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response)  $next
         */
        public function handle(Request $request, Closure $next): Response
        {
            $startTime = CarbonImmutable::createFromTimestamp(defined('LARAVEL_START')
                ? LARAVEL_START
                : $request->server('REQUEST_TIME_FLOAT')
            );
            
            $requestId = Str::orderedUuid()->toString();
            // フロントエンドでも使用できるよう、Cookieにも保存する
            Cookie::make(name: 'request_id', value: $requestId, httpOnly: false);
    
            Context::add([
                'server' => [
                    'php_version' => phpversion(),
                ],
                'request' => [
                    'id' => $requestId,
                    'url' => $request->url(),
                    'protocol' => $request->getProtocolVersion(),
                    'method' => $request->method(),
                    'accept_content_type' => $request->getAcceptableContentTypes(),
                    'accept_language' => $request->getLanguages(),
                    'accept_encoding' => $request->getEncodings(),
                    'origin' => $request->header('Origin'),
                    'ip' => $request->ip(),
                    'ips' => $request->ips(),
                    'user_agent' => $request->userAgent(),
                    'query' => $request->query(),
                    'referer' => $request->header('Referer'),
                    'body' => array_diff_key($request->all(), array_flip(['password', 'password_confirmation'])),
                ],
                'performance' => [
                    'start_time' => $startTime->toIso8601ZuluString('microsecond'),
                ],
            ]);
    
            Context::when(
                $request->hasSession(),
                fn (Repository $context) => $context->add('session', [
                    'id' => Hash::make($request->session()->getId()),
                ]),
            );
    
            $user = Auth::user();
            Context::when(
                !is_null($user),
                fn (Repository $context) => $context->add('auth', [
                    'id' => $user->getAuthIdentifier(),
                ]),
            );
    
            $response = $next($request);
    
            $endTime = CarbonImmutable::now();
            Context::add([
                'performance' => [
                    ...Context::get('performance', []),
                    'end_time' => $endTime->toIso8601ZuluString('microsecond'),
                    'duration_μs' => $startTime->diffInMicroseconds($endTime),
                    'memory_peak_usage_b' => memory_get_peak_usage(),
                    'memory_real_peak_usage_b' => memory_get_peak_usage(true),
                ],
                'response' => [
                    'status_code' => $response->getStatusCode(),
                    'content_type' => $response->headers->get('Content-Type'),
                    'cache_control' => $response->headers->get('Cache-Control'),
                    'age' => $response->headers->get('Age'),
                    'expires' => $response->headers->get('Expires'),
                    'last_modified' => $response->headers->get('Last-Modified'),
                    'etag' => $response->headers->get('ETag'),
                ]
            ]);
    
            return $response;
        }
    }

アクセスログを保存するテーブルとモデルを作成

💡
ログの保存先として、NoSQLや専用のサービスへ保存するのがベストですが、今回はRDSへ保存するようにしています。

アクセスログを保存するためのテーブルとそのテーブルを操作するモデルを作成します。テーブルは月ごとにパーティションを分けることで、大量のログが溜まったとしても検索性能を維持できます。

  1. モデルとマイグレーションファイルの作成
    php artisan make:model -m AccessLog
  2. マイグレーションファイルの実装
    <?php
    
    use Illuminate\Database\Migrations\Migration;
    use Illuminate\Database\Schema\Blueprint;
    use Illuminate\Support\Facades\DB;
    use Illuminate\Support\Facades\Schema;
    
    return new class extends Migration
    {
        /**
         * Run the migrations.
         */
        public function up(): void
        {
            Schema::create('access_logs', function (Blueprint $table) {
                $table->uuid('id');
                $table->json('log');
                $table->dateTime('created_at');
                $table->dateTime('updated_at');
    
                $table->primary(['id', 'created_at']);
            });
    
            // partition the table by month
            $partitions = implode(",\n", array_reduce(range(2024, 2025), function (array $carry, int $year): array {
                return [
                    ...$carry,
                    ...array_reduce(range(1, 12), function (array $carry, int $month) use ($year): array {
                        $monthP = str_pad($month, 2, '0', STR_PAD_LEFT);
                        $carry[] = "PARTITION p{$year}{$monthP} VALUES LESS THAN ('{$year}-{$monthP}-01 00:00:00')";
                        return $carry;
                    }, [])
                ];
            }, []));
    
            DB::statement("
                ALTER TABLE `access_logs` PARTITION BY RANGE COLUMNS(`created_at`) (
                   {$partitions}
                );
            ");
        }
    
        /**
         * Reverse the migrations.
         */
        public function down(): void
        {
            Schema::dropIfExists('access_logs');
        }
    };
    database/migrations/2024_04_09_091506_create_access_logs_table.php
  3. モデルの実装
    <?php
    
    namespace App\Models;
    
    use Illuminate\Database\Eloquent\Concerns\HasUuids;
    use Illuminate\Database\Eloquent\Model;
    use Illuminate\Support\Str;
    
    class AccessLog extends Model
    {
        protected $primaryKey = ['id', 'created_at'];
    
        public $incrementing = false;
    
        protected $fillable = [
            'log',
        ];
    
        protected $casts = [
            'log' => 'array',
        ];
    
        protected static function boot(): void
        {
            parent::boot();
    
            static::creating(function (Model $model) {
                $model->id = (string) Str::orderedUuid();
            });
        }
    }

アクセスログを保存するミドルウェアを実装

最後に、前述のミドルウェアで付与した情報をもとに、アクセスログを保存するミドルウェアを実装します。リクエストの処理が終了した後、ログ情報をデータベースに保存します。

  1. ミドルウェアの作成
    php artisan make:middleware SaveAccessLog
  2. ミドルウェアの実装
    <?php
    
    namespace App\Http\Middleware;
    
    use App\Models\AccessLog;
    use Closure;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\Context;
    use Illuminate\Support\Facades\Log;
    use Symfony\Component\HttpFoundation\Response;
    
    class SaveAccessLog
    {
        /**
         * Handle an incoming request.
         *
         * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
         */
        public function handle(Request $request, Closure $next): Response
        {
            return $next($request);
        }
    
        public function terminate(Request $request, Response $response): void
        {
            try {
                if(empty($log = Context::all())) {
                    // For redirects
                    return;
                }
                AccessLog::query()->create(['log' => $log]);
            } catch (\Throwable $e) {
                Log::error($e->getMessage(), [
                    'file' => $e->getFile(),
                    'line' => $e->getLine(),
                    'code' => $e->getCode(),
                    'trace' => $e->getTraceAsString(),
                ]);
            }
        }
    }

ミドルウェアを登録

💡
apiルートではセッションが無効になっているのでAssignIdToGuestUserは登録しないこと

作成したミドルウェアは、Laravelのアプリケーションに登録することで有効になります。ここではwebとapiの両方のルートにミドルウェアを登録していますが、必要に応じて適切なルートに登録してください。

<?php

use App\Http\Middleware\AddAccessInfo;
use App\Http\Middleware\AssignIdToGuestUser;
use App\Http\Middleware\SaveAccessLog;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->appendToGroup('web', [
            AssignIdToGuestUser::class,
            AddAccessInfo::class,
            SaveAccessLog::class,
        ]);
        $middleware->appendToGroup('api', [
            AddAccessInfo::class,
            SaveAccessLog::class,
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions) {
        //
    })->create();

さいごに

この記事では、Laravelのミドルウェアとコンテクストを活用してアクセスログを取得する方法を解説しました。アクセスログはウェブアプリケーションの運用にあたって重要な情報を提供します。特に、ユーザーの行動を把握するためや、パフォーマンスチューニングのための情報源として利用することが可能です。

ただし、アクセスログの取得にはプライバシー保護の観点から注意が必要です。ユーザーの個人情報を適切に保護し、法令遵守に努めるようにしましょう。

今回の実装例は一例であり、アプリケーションの要件に応じて適宜カスタマイズして使用することが可能です。また、今回はRDBへの保存を例示しましたが、大量のログを扱う場合はNoSQLやログ専用のサービスへの保存も検討してみてください。

Ohashi

Ohashi slash forward icon Engineer

主にLaravelなどのバックエンドを中心にサーバー周りも担当しています。目標は腕周り40cm 越え。

関連記事