Laravel аналитика. Зачем и как я сделал свой пакет
Для этого было несколько причин:
- В последнее время Google Analytics стала довольно сложным и медленным. Особенно с введением нового Google Analytics 4 он стал сложнее, и я понял, что не использую даже 0,1% его возможностей. Этот блог и другие веб-сайты, которые я разрабатывал как сторонние проекты, нуждаются только в простых вещах, таких как количество посетителей в определённый период и просмотры страниц для наиболее посещаемых страниц. Вот и всё!
- Я хотел максимально избавиться от сторонних файлов cookie.
- Сторонние инструменты аналитики в основном блокируются блокировщиками рекламы, поэтому я вижу меньшие цифры, а не количество реальных посетителей.
Требования
- Это должен быть Laravel, так как я хочу использовать его в нескольких проектах.
- Будь проще, только базовый функционал.- Отслеживание посещений страниц по uri, а также по релевантным идентификаторам моделей, если применимо (например,idсообщения в блоге илиidпродукта).
- Хранение UserAgentsдля возможного дальнейшего анализа устройств посетителей (настольных и мобильных) и для фильтрации трафика ботов.
- Хранение IPадреса для запланированной функции: сегментация пользователей по странам и городам.
- Внутреннее решение, отслеживание данных в собственной базе данных приложения.
- Только бэкенд функции для отслеживания, без фронтенд отслеживания.
- Создание диаграммы посетителей за последние 28 дней и наиболее посещаемых страниц за тот же период.
 
- Отслеживание посещений страниц по 
- Создание MVP и отложить все дополнительные функции, такие как:- Агрегировать данные в отдельные таблицы вместо того, чтобы запрашивать таблицу page_view(я создам её, когда запросы станут медленными).
- Добавить базу данных geoipи сохранить страну и город пользователя на основе его IP-адреса.
- Добавить возможность изменить период времени отображаемый на графиках.
 
- Агрегировать данные в отдельные таблицы вместо того, чтобы запрашивать таблицу 
База данных
Как я упоминал ранее, цель состояла в том, чтобы всё было очень просто, поэтому база данных состоит только из одной таблицы с именем laravel_analytics_page_views, где префикс laravel_analytics_ настраивается в файле конфигурации для предотвращения потенциальных конфликтов с таблицами базы данных приложения.
Схема структуры/миграции выглядит следующим образом:
$tableName = config('laravel-analytics.db_prefix') . 'page_views';
Schema::create($tableName, function (Blueprint $table) {
    $table->id();
    $table->string('session_id')->index();
    $table->string('path')->index();
    $table->string('user_agent')->nullable();
    $table->string('ip')->nullable();
    $table->string('referer')->nullable()->index();
    $table->string('county')->nullable()->index();
    $table->string('city')->nullable();
    $table->string('page_model_type')->nullable();
    $table->string('page_model_id')->nullable();
    $table->timestamp('created_at')->nullable()->index();
    $table->timestamp('updated_at')->nullable();
    $table->index(['page_model_type', 'page_model_id']);
});Мы отслеживаем уникальных посетителей по session_id, что конечно не идеально и не на 100% точно, но работает.
Мы создаём полиморфное отношение с page_model_type и page_model_id, если для отслеживаемой страницы есть релевантная модель, мы сохраняем тип и идентификатор для использования в будущем, если это необходимо. Также создан комбинированный индекс для этих двух полей, так как они чаще всего запрашиваются вместе при использовании полиморфных отношений.
Middleware
Я хотел универсальное решение, а не добавление аналитики ко всем контроллерам, создал middleware, которое может обрабатывать отслеживание. Middleware можно добавить ко всем маршрутам или к определённой группе/группам маршрутов.
Само middleware довольно простое, оно отслеживает только запросы на получение и пропускает вызовы ajax. Поскольку отслеживать трафик ботов не имеет смысла, я использовал пакет https://github.com/JayBizzle/Crawler-Detect для обнаружения сканеров и ботов. Когда сканер обнаруживается, он просто пропускается отслеживанием, таким образом, мы можем избежать бесполезных данных в таблице.
Было несколько сложно получить связанную модель для URL-адреса универсальным способом. Итоговое решение не является полностью универсальным, поскольку оно предполагает, что приложение использует привязку модели маршрута, и предполагает, что первая привязка имеет отношение к этой странице. Опять, это не идеально, но соответствует минималистичному подходу, которому я следовал при разработке этого пакета.
Вот код middleware:
public function handle(Request $request, Closure $next)
{
    $response = $next($request);
    try {
        if (!$request->isMethod('GET')) {
            return $response;
        }
        if ($request->isJson()) {
            return $response;
        }
        $userAgent = $request->userAgent();
        if (is_null($userAgent)) {
            return $response;
        }
        /** @var CrawlerDetect $crawlerDetect */
        $crawlerDetect = app(CrawlerDetect::class);
        if ($crawlerDetect->isCrawler($userAgent)) {
            return $response;
        }
        /** @var PageView $pageView */
        $pageView = PageView::make([
            'session_id' => session()->getId(),
            'path' => $request->path(),
            'user_agent' => Str::substr($userAgent, 0, 255),
            'ip' => $request->ip(),
            'referer' => $request->headers->get('referer'),
        ]);
        $parameters = $request->route()?->parameters();
        $model = null;
        if (!is_null($parameters)) {
            $model = reset($parameters);
        }
        if (is_a($model, Model::class)) {
            $pageView->pageModel()->associate($model);
        }
        $pageView->save();
        return $response;
    } catch (Throwable $e) {
        report($e);
        return $response;
    }
}Маршруты
При разработке Laravel пакетов можно настроить сервис провайдер пакетов, чтобы сказать приложению использовать маршруты из пакета. Обычно я не использую этот подход, потому что после этого у вас не будет полного контроля над маршрутами в приложении. Например, вы не сможете добавить префикс, поместить их в группу или добавить к ним middleware.
Мне нравиться создавать класс со статическим методом route, где я определяю маршруты.
public static function routes()
{
    Route::get(
        'analytics/page-views-per-days',
        [AnalyticsController::class, 'getPageViewsPerDays']
    );
    Route::get(
        'analytics/page-views-per-path',
        [AnalyticsController::class, 'getPageViewsPerPaths']
    );
}Таким образом я мог бы легко поместить маршруты пакета, например, в часть /admin в моём приложении.
Фронтенд компоненты
Фронтенд часть состоит из двух компонентов vue: один для диаграммы посетителей, а другой содержит простую таблицу наиболее посещаемых страниц. Для диаграммы я использовал Vue библиотеку chartjs.
<template>
    <div>
        <div><strong>Visitors: {{chartData.datasets[0].data.reduce((a, b) => a + b, 0)}}</strong></div>
        <div>
            <LineChartGenerator
                :chart-options="chartOptions"
                :chart-data="chartData"
                :chart-id="chartId"
                :dataset-id-key="datasetIdKey"
                :plugins="plugins"
                :css-classes="cssClasses"
                :styles="styles"
                :width="width"
                :height="height"
            />
        </div>
    </div>
</template>
<script>
import { Line as LineChartGenerator } from 'vue-chartjs/legacy'
import {
    Chart as ChartJS,
    Title,
    Tooltip,
    Legend,
    LineElement,
    LinearScale,
    CategoryScale,
    PointElement
} from 'chart.js'
ChartJS.register(
    Title,
    Tooltip,
    Legend,
    LineElement,
    LinearScale,
    CategoryScale,
    PointElement
)
export default {
    name: 'VisitorsPerDays',
    components: { LineChartGenerator },
    props: {
        'initialData': Object,
        'baseUrl': String,
        chartId: {
            type: String,
            default: 'line-chart'
        },
        datasetIdKey: {
            type: String,
            default: 'label'
        },
        width: {
            type: Number,
            default: 400
        },
        height: {
            type: Number,
            default: 400
        },
        cssClasses: {
            default: '',
            type: String
        },
        styles: {
            type: Object,
            default: () => {}
        },
        plugins: {
            type: Array,
            default: () => []
        }
    },
    data() {
        return {
            chartData: {
                labels: Object.keys(this.initialData),
                datasets: [
                    {
                        label: 'Visitors',
                        backgroundColor: '#f87979',
                        data: Object.values(this.initialData)
                    }
                ]
            },
            chartOptions: {
                responsive: true,
                maintainAspectRatio: false,
                scales: {
                    y: {
                        ticks: {
                            precision: 0
                        }
                    }
                }
            }
        }
    },
    mounted() {
    },
    methods: {
    },
}
</script>Заключение
Это был довольно забавный и интересный проект, и после месяца его использования и анализа результатов, похоже, он работает нормально. Если вас интересует код или вы хотите попробовать пакет, он размещён на GitHub wdev-rs/laravel-analytics.