{"id":51178386,"url":"https://github.com/tsitsishvili/elastic-audit","last_synced_at":"2026-06-29T07:00:57.285Z","repository":{"id":367073818,"uuid":"1278280789","full_name":"tsitsishvili/elastic-audit","owner":"tsitsishvili","description":"Laravel package that logs third-party HTTP traffic (outgoing requests + incoming callbacks) and actor/model activity to a dedicated Elasticsearch cluster — with redaction, queued indexing, sampling, and optional dashboards","archived":false,"fork":false,"pushed_at":"2026-06-27T08:58:51.000Z","size":453,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-28T06:24:58.041Z","etag":null,"topics":["activity-logs","audit-log","audit-trails","elasticsearch","guzzle","http-client","http-logging","laravel","laravel-packages","logging","observability","php","queue","redaction"],"latest_commit_sha":null,"homepage":"","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/tsitsishvili.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":"AUDIT_LOGS.md","citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-23T16:32:57.000Z","updated_at":"2026-06-27T08:58:34.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/tsitsishvili/elastic-audit","commit_stats":null,"previous_names":["tsitsishvili/elastic-audit"],"tags_count":7,"template":false,"template_full_name":null,"purl":"pkg:github/tsitsishvili/elastic-audit","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tsitsishvili%2Felastic-audit","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tsitsishvili%2Felastic-audit/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tsitsishvili%2Felastic-audit/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tsitsishvili%2Felastic-audit/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/tsitsishvili","download_url":"https://codeload.github.com/tsitsishvili/elastic-audit/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tsitsishvili%2Felastic-audit/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34916411,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-29T02:00:05.398Z","response_time":58,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["activity-logs","audit-log","audit-trails","elasticsearch","guzzle","http-client","http-logging","laravel","laravel-packages","logging","observability","php","queue","redaction"],"created_at":"2026-06-27T05:30:38.782Z","updated_at":"2026-06-29T07:00:57.248Z","avatar_url":"https://github.com/tsitsishvili.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Elastic Audit\n\nLaravel package that logs third-party HTTP traffic and actor/model activity to a dedicated Elasticsearch cluster.\n\nThe package is intended for internal applications that need a consistent audit/debug trail for provider calls,\ncallbacks, latency, status codes, entity context, and sanitized request/response payload previews.\n\nIt has two independent subsystems that share a single Elasticsearch connection:\n\n- **HTTP logs** — the `HttpLog` facade logs outgoing third-party requests and incoming callbacks\n  (config `http_logs.php`, commands `http-logs:*`).\n- **Activity logs** — the `ActivityLog` facade (and `ActivityLoggable` trait) logs actor actions and\n  Eloquent model changes (config `activity_logs.php`, commands `activity-logs:*`).\n\nEach subsystem has its own config, Elasticsearch index/aliases, queue, console commands, and optional\ndashboard, so you can enable only what you need.\n\n## Project Documents\n\n- [Changelog](CHANGELOG.md)\n- [Upgrade Guide](UPGRADE.md)\n- [Contributing](CONTRIBUTING.md)\n- [Coding Standards](CODING_STANDARDS.md)\n\n## Table of Contents\n\n- [Project Documents](#project-documents)\n- [Quick Start](#quick-start)\n- [Requirements](#requirements)\n- [Installation](#installation)\n- [Publish Configuration](#publish-configuration)\n- [Environment Variables](#environment-variables)\n- [Configuration Reference](#configuration-reference)\n- [Register Application Enums](#register-application-enums)\n- [What Gets Logged](#what-gets-logged)\n- [Create Elasticsearch Index](#create-elasticsearch-index)\n- [Logging Outgoing Requests](#logging-outgoing-requests)\n- [Logging Incoming Callbacks](#logging-incoming-callbacks)\n- [Manual Incoming Logging](#manual-incoming-logging)\n- [Queues](#queues)\n- [Dashboard](#dashboard)\n- [Pruning Old Logs](#pruning-old-logs)\n- [Redaction Notes](#redaction-notes)\n- [Sampling](#sampling)\n- [Troubleshooting](#troubleshooting)\n- [Development / Testing](#development--testing)\n- [Testing Example](#testing-example)\n- [Searching Logs in Elasticsearch](#searching-logs-in-elasticsearch)\n- [Activity Logging](#activity-logging)\n- [Internal Versioning](#internal-versioning)\n\n## Quick Start\n\n1. Add the GitLab repository to the consuming application's `composer.json`.\n2. Install the package:\n\n    ```bash\n    composer require tsitsishvili/elastic-audit:^1.0\n    ```\n\n3. Publish the config files and enum stubs:\n\n    ```bash\n    php artisan vendor:publish --tag=elastic-audit\n    ```\n\n4. Register the application's provider, event type, and entity type enums in `config/http_logs.php`.\n5. Configure Elasticsearch and enable logging in `.env`.\n6. Create the Elasticsearch index and aliases:\n\n    ```bash\n    php artisan http-logs:create-index\n    ```\n\n7. Run a queue worker for the configured logs queue:\n\n    ```bash\n    php artisan queue:work --queue=default\n    ```\n\n8. Use `HttpLog::make(...)` for outgoing provider calls or `IncomingHttpLogMiddleware` for incoming callbacks.\n\n## Requirements\n\n- PHP `^8.3 || ^8.4`\n- Laravel `^12.0 || ^13.0`\n- Elasticsearch PHP client `^8.0 || ^9.0`\n- A queue worker, because logs are indexed through queued jobs\n\n## Installation\n\nAdd the package repository to the consuming application's `composer.json`.\n\n```json\n{\n  \"repositories\": [\n    {\n      \"type\": \"vcs\",\n      \"url\": \"git@gitlab.tsitsishvili.ge:laravel-packages/http-logs.git\"\n    }\n  ]\n}\n```\n\nInstall a tagged version:\n\n```bash\ncomposer require tsitsishvili/elastic-audit:^1.0\n```\n\nLaravel auto-discovers the package service provider.\n\n## Publish Configuration\n\n```bash\nphp artisan vendor:publish --tag=elastic-audit\n```\n\nThis publishes:\n\n```text\nconfig/http_logs.php\nconfig/log_elasticsearch.php\napp/Enums/ElasticAudit/Provider.php\napp/Enums/ElasticAudit/EventType.php\napp/Enums/ElasticAudit/EntityType.php\n```\n\nThe enum stubs are starting-point implementations of the three package contracts. Edit them to match the providers and\nevent types used by the application.\n\n## Environment Variables\n\n```dotenv\nHTTP_LOGS_ENABLED=true\nHTTP_LOGS_QUEUE=default\nHTTP_LOGS_SAMPLE_RATE=1.0\nHTTP_LOGS_BODY_PREVIEW_BYTES=4096\nHTTP_LOGS_BODY_MAX_BYTES=32768\nHTTP_LOGS_PAYMENT_BODY_MODE=preview\n\nHTTP_LOGS_DASHBOARD_ENABLED=true\nELASTIC_AUDIT_DASHBOARD_PREFIX=logger\nHTTP_LOGS_DASHBOARD_PATH=third-party\n\nLOG_ELASTICSEARCH_HOST=localhost\nLOG_ELASTICSEARCH_PORT=9200\nLOG_ELASTICSEARCH_SCHEME=http\nLOG_ELASTICSEARCH_USERNAME=\nLOG_ELASTICSEARCH_PASSWORD=\nLOG_ELASTICSEARCH_INDEX_PREFIX=my_app\nLOG_ELASTICSEARCH_REPLICAS=1\n```\n\n| Variable                         | Description                                                                                                                        |\n|----------------------------------|------------------------------------------------------------------------------------------------------------------------------------|\n| `HTTP_LOGS_ENABLED`              | Set to `true` to enable logging.                                                                                                   |\n| `HTTP_LOGS_QUEUE`                | Queue name for log jobs.                                                                                                           |\n| `HTTP_LOGS_SAMPLE_RATE`          | Float `0.0`–`1.0`. `1.0` = log all, `0.0` = log none. Intermediate values sample randomly.                                         |\n| `HTTP_LOGS_BODY_PREVIEW_BYTES`   | Max bytes stored as sanitized body preview.                                                                                        |\n| `HTTP_LOGS_BODY_MAX_BYTES`       | Max raw body size before truncation.                                                                                               |\n| `HTTP_LOGS_PAYMENT_BODY_MODE`    | Body handling mode for payment providers (`preview` or `omit`).                                                                    |\n| `HTTP_LOGS_DASHBOARD_ENABLED`    | Set to `true` to register the web dashboard routes.                                                                                |\n| `ELASTIC_AUDIT_DASHBOARD_PREFIX` | Shared URL prefix for both dashboards (default `logger`). Composes as `{prefix}/{path}`. Set to empty string to serve at the root. |\n| `HTTP_LOGS_DASHBOARD_PATH`       | This dashboard's subpath under the group prefix (default `third-party`). Served at `/logger/third-party`.                          |\n\nThe package writes to aliases based on `LOG_ELASTICSEARCH_INDEX_PREFIX`:\n\n```text\nmy_app_http_logs\nmy_app_http_logs_write\n```\n\n## Configuration Reference\n\n### `http_logs.php`\n\n| Key                         | Default                    | Description                                                                                                                                                                             |\n|-----------------------------|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| `enabled`                   | `false`                    | Enables or disables third-party HTTP logging. When disabled, no log jobs are dispatched.                                                                                                |\n| `queue`                     | `default`                  | Queue name used by `LogHttpRequestJob`.                                                                                                                                                 |\n| `sample_rate`               | `1.0`                      | Float between `0.0` and `1.0`. `1.0` logs every request, `0.0` logs none, intermediate values use probabilistic sampling (e.g. `0.1` logs ~10%). Controlled by `HTTP_LOGS_SAMPLE_RATE`. |\n| `body_preview_bytes`        | `4096`                     | Maximum number of sanitized body bytes stored as preview.                                                                                                                               |\n| `body_max_bytes`            | `32768`                    | Maximum raw body size considered before truncation handling.                                                                                                                            |\n| `payment_body_mode`         | `preview`                  | Controls payment provider body handling.                                                                                                                                                |\n| `index_alias`               | `{prefix}_http_logs`       | Elasticsearch read alias.                                                                                                                                                               |\n| `index_alias_write`         | `{prefix}_http_logs_write` | Elasticsearch write alias.                                                                                                                                                              |\n| `enums.provider`            | `null`                     | Backed enum class implementing `ProviderContract`.                                                                                                                                      |\n| `enums.event_type`          | `null`                     | Backed enum class implementing `EventTypeContract`.                                                                                                                                     |\n| `enums.entity_type`         | `null`                     | Backed enum class implementing `EntityTypeContract`.                                                                                                                                    |\n| `enums.entity_type_default` | `none`                     | Fallback entity type value for incoming callback logs.                                                                                                                                  |\n| `payment_provider_values`   | `[]`                       | Provider enum values that should use payment-specific redaction.                                                                                                                        |\n| `redaction.headers.allow`   | `[]`                       | Header names to never redact, even when a default rule matches (exact match; takes precedence). See [Redaction Notes](#redaction-notes).                                                |\n| `redaction.headers.block`   | `[]`                       | Extra header names to always redact, in addition to the defaults (whole-word match).                                                                                                    |\n| `redaction.body.allow`      | `[]`                       | Body keys to never redact, even when a default rule matches (exact match; takes precedence).                                                                                            |\n| `redaction.body.block`      | `[]`                       | Extra body keys to always redact, in addition to the defaults (whole-word match).                                                                                                       |\n| `dashboard.enabled`         | `true`                     | Registers the web dashboard routes. Set to `false` to hide the UI entirely.                                                                                                             |\n| `dashboard.prefix`          | `logger`                   | Shared group URL segment placed before every dashboard. Both dashboards read `ELASTIC_AUDIT_DASHBOARD_PREFIX`; changing it moves both at once. Set to `''` to serve at the root.        |\n| `dashboard.path`            | `third-party`              | This dashboard's own subpath under the group prefix. Composes with `prefix` as `{prefix}/{path}`, e.g. `/logger/third-party`.                                                           |\n| `dashboard.middleware`      | `['web']`                  | Middleware applied to dashboard routes. The package always appends its authorization middleware after this stack.                                                                       |\n| `dashboard.per_page`        | `25`                       | Number of log rows shown per page in the list view.                                                                                                                                     |\n\n### `log_elasticsearch.php`\n\n| Key                            | Default      | Description                                                                                    |\n|--------------------------------|--------------|------------------------------------------------------------------------------------------------|\n| `hosts.0.host`                 | `localhost`  | Elasticsearch host used for log indexing.                                                      |\n| `hosts.0.port`                 | `9200`       | Elasticsearch port.                                                                            |\n| `hosts.0.scheme`               | `http`       | Elasticsearch scheme, usually `http` or `https`.                                               |\n| `basicAuthentication.username` | empty string | Optional Elasticsearch basic auth username.                                                    |\n| `basicAuthentication.password` | empty string | Optional Elasticsearch basic auth password.                                                    |\n| `index_prefix`                 | `app_logs`   | Prefix used when creating physical indexes and aliases.                                        |\n| `replicas`                     | `1`          | Number of Elasticsearch replicas for the logs index. Use `0` for single-node staging clusters. |\n\n## Register Application Enums\n\nEach application defines its own provider, event type, and entity type enums. These enums must implement the package\ncontracts.\n\n```php\n\u003c?php\n\nnamespace App\\Enums\\ElasticAudit;\n\nuse Tsitsishvili\\ElasticAudit\\Contracts\\ProviderContract;\n\nenum Provider: string implements ProviderContract\n{\n    case Delivery = 'delivery';\n    case Payment = 'payment';\n\n    public function getValue(): string\n    {\n        return $this-\u003evalue;\n    }\n}\n```\n\n```php\n\u003c?php\n\nnamespace App\\Enums\\ElasticAudit;\n\nuse Tsitsishvili\\ElasticAudit\\Contracts\\EventTypeContract;\n\nenum EventType: string implements EventTypeContract\n{\n    case DeliveryOrderCreate = 'delivery_order_create';\n    case DeliveryStatusCallback = 'delivery_status_callback';\n    case PaymentCallback = 'payment_callback';\n\n    public function getValue(): string\n    {\n        return $this-\u003evalue;\n    }\n}\n```\n\n```php\n\u003c?php\n\nnamespace App\\Enums\\ElasticAudit;\n\nuse Tsitsishvili\\ElasticAudit\\Contracts\\EntityTypeContract;\n\nenum EntityType: string implements EntityTypeContract\n{\n    case Order = 'order';\n    case Payment = 'payment';\n    case None = 'none';\n\n    public function getValue(): string\n    {\n        return $this-\u003evalue;\n    }\n}\n```\n\nRegister them in `config/http_logs.php`:\n\n```php\n'enums' =\u003e [\n    'provider' =\u003e App\\Enums\\Provider::class,\n    'event_type' =\u003e App\\Enums\\EventType::class,\n    'entity_type' =\u003e App\\Enums\\EntityType::class,\n    'entity_type_default' =\u003e 'none',\n],\n\n'payment_provider_values' =\u003e [\n    App\\Enums\\Provider::Payment-\u003evalue,\n],\n```\n\nProviders listed in `payment_provider_values` use the payment redactor.\n\n## What Gets Logged\n\nEach document is built from `HttpLogData` and includes operational metadata, entity context, sanitized payload\ndata, and failure information.\n\n| Field               | Description                                                                               |\n|---------------------|-------------------------------------------------------------------------------------------|\n| `event_id`          | Unique ULID for the log document.                                                         |\n| `@timestamp`        | Time the log data was created.                                                            |\n| `request_id`        | Correlation ULID shared by the log context.                                               |\n| `provider`          | Provider enum value, for example `delivery` or `payment`.                                 |\n| `event_type`        | Event type enum value, for example `delivery_order_create`.                               |\n| `direction`         | `outgoing` for provider calls or `incoming` for callbacks.                                |\n| `http.method`       | HTTP method, for example `GET`, `POST`, or `PATCH`.                                       |\n| `http.url`          | URL without query string. Query strings are stripped to avoid storing tokens or API keys. |\n| `http.host`         | Parsed host from the URL.                                                                 |\n| `http.path`         | Parsed path from the URL.                                                                 |\n| `http.status_code`  | Response status code when available.                                                      |\n| `http.status_class` | Status class such as `2xx`, `4xx`, or `5xx`.                                              |\n| `latency_ms`        | Request or callback handling duration in milliseconds.                                    |\n| `entity.type`       | Entity type from the log context, for example `order`.                                    |\n| `entity.id`         | Internal entity identifier from the log context.                                          |\n| `external_id`       | Optional external provider identifier.                                                    |\n| `user_id`           | Optional application user id.                                                             |\n| `attempt`           | Queue/job attempt or request attempt value.                                               |\n| `success`           | Boolean success flag.                                                                     |\n| `retention_days`    | Retention window used by `http-logs:prune`.                                               |\n| `request`           | Sanitized request headers, body preview, body hash, and truncation flag.                  |\n| `response`          | Sanitized response headers, body preview, body hash, and truncation flag.                 |\n| `error.class`       | Exception class for failed outgoing calls when available.                                 |\n| `error.message`     | Sanitized exception message for failed outgoing calls when available.                     |\n\nBoth incoming callback logs and outgoing request logs store request **and** response payloads. For incoming callbacks\nthe\nresponse is captured automatically by `IncomingHttpLogMiddleware`, or when you pass the response to\n`HttpLog::logIncoming(...)`. Outgoing request logs capture the provider's response when available.\n\n## Create Elasticsearch Index\n\nCreate the physical index and attach read/write aliases:\n\n```bash\nphp artisan http-logs:create-index\n```\n\nThis command refuses to create the logs index when the configured logs Elasticsearch host matches the product-search\nElasticsearch host.\n\n## Logging Outgoing Requests\n\nUse `HttpLog::make(...)` to obtain a logging-aware HTTP client instead of using Laravel's `Http` facade\ndirectly.\n\n```php\n\u003c?php\n\nnamespace App\\Services;\n\nuse App\\Enums\\EntityType;\nuse App\\Enums\\EventType;\nuse App\\Enums\\Provider;\nuse Tsitsishvili\\ElasticAudit\\DataTransferObjects\\HttpLogContext;\nuse Tsitsishvili\\ElasticAudit\\Facades\\HttpLog;\n\nclass DeliveryProviderClient\n{\n    public function createOrder(int $orderId): array\n    {\n        $context = HttpLogContext::forEntity(\n            entityType: EntityType::Order,\n            entityId: (string) $orderId,\n            externalId: null,\n            userId: auth()-\u003eid(),\n            retentionDays: 360,\n        );\n\n        $response = HttpLog::make(\n            provider: Provider::Delivery,\n            eventType: EventType::DeliveryOrderCreate,\n            context: $context,\n        )\n            -\u003etimeout(10)\n            -\u003eretry(2, 200)\n            -\u003ewithToken(config('services.delivery.token'))\n            -\u003epost('https://provider.example/orders', [\n                'order_id' =\u003e $orderId,\n            ]);\n\n        return $response-\u003ejson();\n    }\n}\n```\n\n`HttpLog::make(...)` returns **Laravel's own HTTP client** (`Illuminate\\Http\\Client\\PendingRequest`) with an\noutgoing-request logging middleware already attached. There is no custom wrapper — the **entire** Laravel HTTP client\nAPI\nis available and every request you make through it is logged automatically:\n\n```php\nHttpLog::make($provider, $eventType, $context)-\u003eget($url, $query);\nHttpLog::make($provider, $eventType, $context)-\u003epost($url, $data);\nHttpLog::make($provider, $eventType, $context)\n    -\u003eacceptJson()\n    -\u003ewithBasicAuth($username, $password)\n    -\u003ewithQueryParameters(['page' =\u003e 1])\n    -\u003epost($url, $data);\n\n// Form-encoded body (application/x-www-form-urlencoded) — native Laravel, logged the same way:\nHttpLog::make($provider, $eventType, $context)\n    -\u003easForm()\n    -\u003epost('https://provider.example/oauth/token', ['grant_type' =\u003e 'client_credentials']);\n```\n\nBecause logging happens at the transport (Guzzle middleware) layer, the wire format is irrelevant to logging: JSON,\nform,\nmultipart, etc. are all redacted and stored uniformly, and no method call can bypass logging.\n\nThe original provider call behavior is preserved. If the provider request fails, the package dispatches the log job and\nrethrows the original exception.\n\n## Logging Incoming Callbacks\n\nRegister the middleware on callback routes:\n\n```php\nuse App\\Http\\Controllers\\DeliveryCallbackController;\nuse Illuminate\\Support\\Facades\\Route;\nuse Tsitsishvili\\ElasticAudit\\Http\\Middleware\\IncomingHttpLogMiddleware;\n\nRoute::post('/callbacks/delivery', DeliveryCallbackController::class)\n    -\u003emiddleware(IncomingHttpLogMiddleware::class);\n```\n\nSet trusted request attributes server-side before the response is returned. The middleware reads these attributes after\nthe request has been handled.\n\n```php\n\u003c?php\n\nnamespace App\\Http\\Controllers;\n\nuse App\\Enums\\EntityType;\nuse App\\Enums\\EventType;\nuse App\\Enums\\Provider;\nuse Illuminate\\Http\\Request;\n\nclass DeliveryCallbackController\n{\n    public function __invoke(Request $request)\n    {\n        $orderId = (string) $request-\u003einput('order_id', 'unknown');\n\n        $request-\u003eattributes-\u003eset('third_party_provider', Provider::Delivery-\u003evalue);\n        $request-\u003eattributes-\u003eset('third_party_event_type', EventType::DeliveryStatusCallback-\u003evalue);\n        $request-\u003eattributes-\u003eset('third_party_entity_type', EntityType::Order-\u003evalue);\n        $request-\u003eattributes-\u003eset('third_party_entity_id', $orderId);\n\n        // Handle the callback...\n\n        return response()-\u003ejson(['received' =\u003e true]);\n    }\n}\n```\n\nDo not resolve provider or event type from URL segments or request input. Set these values from application code so\nuser-controlled data cannot spoof log metadata.\n\nThe middleware automatically logs the response it returns (status code, headers, and sanitized body) alongside the\nrequest — no extra code is required.\n\n## Manual Incoming Logging\n\nIf middleware is not a good fit, call `HttpLog::logIncoming(...)` directly.\n\n```php\nuse App\\Enums\\EntityType;\nuse App\\Enums\\EventType;\nuse App\\Enums\\Provider;\nuse Illuminate\\Http\\Request;\nuse Tsitsishvili\\ElasticAudit\\DataTransferObjects\\HttpLogContext;\nuse Tsitsishvili\\ElasticAudit\\Facades\\HttpLog;\n\npublic function webhook(Request $request)\n{\n    $context = HttpLogContext::forEntity(\n        entityType: EntityType::Payment,\n        entityId: (string) $request-\u003einput('payment_id', 'unknown'),\n        retentionDays: 180,\n    );\n\n    // Build the response first so it can be logged, then return the same instance.\n    $response = response()-\u003ejson(['ok' =\u003e true]);\n\n    HttpLog::logIncoming(\n        request: $request,\n        provider: Provider::Payment,\n        eventType: EventType::PaymentCallback,\n        context: $context,\n        latencyMs: 0,\n        httpStatusCode: $response-\u003egetStatusCode(),\n        success: true,\n        response: $response, // optional — captures sanitized response headers and body\n    );\n\n    return $response;\n}\n```\n\n## Queues\n\nLogs are dispatched through `LogHttpRequestJob`.\n\nRun a worker for the configured queue:\n\n```bash\nphp artisan queue:work --queue=default\n```\n\nIf you use a dedicated queue:\n\n```dotenv\nHTTP_LOGS_QUEUE=logs\n```\n\n```bash\nphp artisan queue:work --queue=logs\n```\n\n## Dashboard\n\nThe package ships a Horizon-style web dashboard for browsing logged requests. It reads directly from the\nElasticsearch read alias and is rendered with server-side Blade (Tailwind + Alpine via CDN) — there is no build\nstep and no assets to compile or publish.\n\nOnce the package is installed it is served (by default) at:\n\n```text\n/logger/third-party\n```\n\nIt provides three views:\n\n- **Overview** — totals, success rate, 4xx/5xx counts, average/p95 latency, a throughput chart, and breakdowns by\n  status class and provider.\n- **Logs** — a paginated, filterable table (provider, event type, direction, status class, success, entity id, and a\n  date range). Each row links to its detail view.\n- **Log detail** — full operational metadata plus sanitized request/response headers, body previews, body hashes, and\n  error information for a single log document.\n\n### Access control\n\nAccess is gated by an authorization callback. **By default the dashboard is only reachable in the `local`\nenvironment** — every other environment is denied until you grant access explicitly.\n\nRegister a callback from any service provider's `boot()` method (for example `App\\Providers\\AppServiceProvider`):\n\n```php\nuse Tsitsishvili\\ElasticAudit\\Dashboard\\Dashboard;\n\npublic function boot(): void\n{\n    Dashboard::auth(fn ($request) =\u003e $request-\u003euser()?-\u003eisAdmin() === true);\n}\n```\n\nThe callback receives the current `Illuminate\\Http\\Request` and must return a boolean. Requests that fail it receive\na `403`.\n\n### Configuration\n\n```php\n// config/http_logs.php\n'dashboard' =\u003e [\n    'enabled'    =\u003e env('HTTP_LOGS_DASHBOARD_ENABLED', true),\n    'prefix'     =\u003e env('ELASTIC_AUDIT_DASHBOARD_PREFIX', 'logger'),\n    'path'       =\u003e env('HTTP_LOGS_DASHBOARD_PATH', 'third-party'),\n    'middleware' =\u003e ['web'],\n    'per_page'   =\u003e 25,\n],\n```\n\nSet `enabled` to `false` to omit the routes completely. The package always appends its own authorization middleware\nafter the configured `middleware` stack.\n\n### Customizing the views\n\nTo override the bundled Blade templates, publish them and edit the copies in your application:\n\n```bash\nphp artisan vendor:publish --tag=elastic-audit-views\n```\n\nThis publishes the views to `resources/views/vendor/elastic-audit`.\n\n## Pruning Old Logs\n\nEach log document stores `retention_days` from `HttpLogContext`.\n\nRun pruning manually:\n\n```bash\nphp artisan http-logs:prune\n```\n\nSchedule it in the consuming application:\n\n```php\nuse Illuminate\\Support\\Facades\\Schedule;\n\nSchedule::command('http-logs:prune')-\u003edailyAt('03:00');\n```\n\n## Redaction Notes\n\nThe package sanitizes headers, request bodies, response bodies, and exception messages before indexing. Query strings\nare stripped from stored URLs because they can contain API keys or tokens.\n\n### How matching works\n\nSensitive header names and body keys are matched as **whole words**, not raw substrings. Names are first normalized —\n`camelCase`, `kebab-case`, dotted and spaced variants all fold to `snake_case`, so `accessToken`, `access-token`, and\n`access_token` are treated identically. A built-in word only matches at a word boundary, so it never fires inside a\nlarger word (e.g. `key` does not match `monkey` or `keyword`).\n\nMost secret words (`password`, `secret`, `signature`, `hmac`, `authorization`, `credential`, …) match in any position,\nso compound keys like `password_confirmation` and `webhook_secret` are redacted. The positional words `token` and `key`\nmatch only as the **final** word, so `access_token` / `x-api-key` are redacted while the non-secret `token_type` and\n`token_expires_in` are kept.\n\n### Customizing what gets redacted\n\nYou can extend or override the built-in rules per surface (headers vs. body) without forking the package, via the\n`redaction` config — kept separate for headers and body so a body rule never affects a header and vice versa:\n\n```php\n// config/http_logs.php\n'redaction' =\u003e [\n    'headers' =\u003e [\n        'block' =\u003e ['x-internal-trace'], // always redact this header (in addition to defaults)\n        'allow' =\u003e [],\n    ],\n    'body' =\u003e [\n        'block' =\u003e ['customer_reference'], // whole-word: also redacts 'customerReference'\n        'allow' =\u003e ['email'],              // keep emails in logs, even though 'email' is redacted by default\n    ],\n],\n```\n\n- **`block`** — extra names to always redact, matched as whole words exactly like the built-ins. The word `reference`\n  blocks any `*_reference` key.\n- **`allow`** — names to never redact, even when a built-in or `block` rule matches. Matched **exactly** (after\n  normalization), so it un-redacts only the named field, not a whole family — and it takes precedence over everything\n  else. Use with care: anything listed here is stored in clear text.\n\nBody storage is controlled by:\n\n```dotenv\nHTTP_LOGS_BODY_PREVIEW_BYTES=4096\nHTTP_LOGS_BODY_MAX_BYTES=32768\nHTTP_LOGS_PAYMENT_BODY_MODE=preview\n```\n\nFor payment providers, add the provider enum value to `payment_provider_values`.\n\n\u003e Activity logging applies the same redaction to its `changes` and `metadata` maps — see\n\u003e [Activity Logging](#activity-logging).\n\n## Sampling\n\nControl what fraction of requests are logged using the `sample_rate` config key (or `HTTP_LOGS_SAMPLE_RATE` env\nvariable).\n\n| Value | Behaviour                                      |\n|-------|------------------------------------------------|\n| `1.0` | Every request is logged (default).             |\n| `0.0` | No requests are logged.                        |\n| `0.1` | ~10% of requests are logged, chosen at random. |\n\nSampling is applied independently to each request via `mt_rand()` before any payload is built or any job dispatched, so\nskipped requests have zero overhead beyond the random check. Setting `sample_rate` to `1.0` skips the random check\nentirely.\n\n```dotenv\n# Log roughly 25% of requests\nHTTP_LOGS_SAMPLE_RATE=0.25\n```\n\n\u003e **Note:** Sampling is statistical. At low rates and low traffic volumes the actual percentage may deviate noticeably\n\u003e from the configured value.\n\n## Troubleshooting\n\n### No logs are created\n\nCheck that logging is enabled:\n\n```dotenv\nHTTP_LOGS_ENABLED=true\n```\n\nAlso confirm the consuming application is using `HttpLog::make(...)` for outgoing requests or\n`IncomingHttpLogMiddleware` / `HttpLog::logIncoming(...)` for incoming callbacks.\n\n### Log jobs are dispatched but documents do not appear in Elasticsearch\n\nCheck that a queue worker is running for the configured queue:\n\n```bash\nphp artisan queue:work --queue=default\n```\n\nIf `HTTP_LOGS_QUEUE=logs`, run:\n\n```bash\nphp artisan queue:work --queue=logs\n```\n\n### Elasticsearch index or alias is missing\n\nCreate the index and aliases:\n\n```bash\nphp artisan http-logs:create-index\n```\n\nConfirm `LOG_ELASTICSEARCH_INDEX_PREFIX` matches the alias you are querying.\n\n### Cannot connect to Elasticsearch\n\nVerify the logs cluster settings:\n\n```dotenv\nLOG_ELASTICSEARCH_HOST=localhost\nLOG_ELASTICSEARCH_PORT=9200\nLOG_ELASTICSEARCH_SCHEME=http\nLOG_ELASTICSEARCH_USERNAME=\nLOG_ELASTICSEARCH_PASSWORD=\n```\n\nThe `http-logs:create-index` command will fail if the logs Elasticsearch host matches the configured\nproduct-search Elasticsearch host.\n\n### Incoming callback logs are skipped\n\nThe callback middleware only logs when these request attributes are set by server-side code:\n\n```php\n$request-\u003eattributes-\u003eset('third_party_provider', Provider::Delivery-\u003evalue);\n$request-\u003eattributes-\u003eset('third_party_event_type', EventType::DeliveryStatusCallback-\u003evalue);\n$request-\u003eattributes-\u003eset('third_party_entity_type', EntityType::Order-\u003evalue);\n$request-\u003eattributes-\u003eset('third_party_entity_id', $orderId);\n```\n\nProvider, event type, and entity type enum classes must also be registered in `config/http_logs.php`.\n\n### Payment data appears too detailed\n\nAdd payment provider enum values to `payment_provider_values`:\n\n```php\n'payment_provider_values' =\u003e [\n    App\\Enums\\Provider::Payment-\u003evalue,\n],\n```\n\nThen review:\n\n```dotenv\nHTTP_LOGS_PAYMENT_BODY_MODE=preview\nHTTP_LOGS_BODY_PREVIEW_BYTES=4096\nHTTP_LOGS_BODY_MAX_BYTES=32768\n```\n\n### Config changes are not applied\n\nClear Laravel's cached config:\n\n```bash\nphp artisan config:clear\n```\n\n## Development / Testing\n\nInstall package dependencies:\n\n```bash\ncomposer install\n```\n\nValidate Composer metadata:\n\n```bash\ncomposer validate --no-check-publish\n```\n\nThe package includes `phpunit.xml` with separate Unit and Feature test suites.\n\nBefore running the tests in a fresh checkout, make sure the package has these development dependencies installed:\n\n```bash\ncomposer require --dev phpunit/phpunit orchestra/testbench\n```\n\nRun all tests:\n\n```bash\nvendor/bin/phpunit\n```\n\nRun only unit tests:\n\n```bash\nvendor/bin/phpunit --testsuite Unit\n```\n\nRun only feature tests:\n\n```bash\nvendor/bin/phpunit --testsuite Feature\n```\n\nUseful checks before opening a merge request:\n\n```bash\ncomposer validate --no-check-publish\nvendor/bin/phpunit\n```\n\n## Testing Example\n\n### Integration-style tests\n\nFake Laravel's bus and HTTP client so the facade still executes real application code but\nno real HTTP calls are made and no log jobs are dispatched to a queue:\n\n```php\nuse Illuminate\\Support\\Facades\\Bus;\nuse Illuminate\\Support\\Facades\\Http;\nuse Tsitsishvili\\ElasticAudit\\Jobs\\LogHttpRequestJob;\n\nBus::fake();\n\nHttp::fake([\n    'https://provider.example/*' =\u003e Http::response(['ok' =\u003e true], 200),\n]);\n\n// Execute code that calls HttpLog::make(...)\n\nBus::assertDispatched(LogHttpRequestJob::class);\n```\n\n### Unit tests — faking the facade with a spy\n\nUse `HttpLog::spy()` to replace the underlying manager with a Mockery spy. The spy\nrecords every call but executes no real code, so no HTTP requests are made and no jobs are\ndispatched. This is appropriate when the subject under test calls the facade and you want to\nassert what it called without wiring up the full stack.\n\n```php\nuse App\\Enums\\ElasticAudit\\EventType;\nuse App\\Enums\\ElasticAudit\\Provider;\nuse Tsitsishvili\\ElasticAudit\\DataTransferObjects\\HttpLogContext;\nuse Tsitsishvili\\ElasticAudit\\Facades\\HttpLog;\n\nHttpLog::spy();\n\n// Execute code that calls HttpLog::make(...)\n\nHttpLog::shouldReceive('make')\n    -\u003eonce()\n    -\u003ewith(\n        Provider::Delivery,\n        EventType::DeliveryOrderCreate,\n        \\Mockery::type(HttpLogContext::class),\n    );\n```\n\nTo assert the facade was never called:\n\n```php\nHttpLog::spy();\n\n// Execute code that should NOT trigger logging\n\nHttpLog::shouldReceive('make')-\u003enever();\n```\n\n### Unit tests — controlling responses with `Http::fake()`\n\nBecause `make()` returns Laravel's real `PendingRequest`, you stub provider responses with\n`Http::fake()` and assert the outgoing call with `Http::assertSent()` — exactly as you would test\nany code that uses the `Http` facade. There is no custom client to mock.\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\nHttp::fake([\n    'provider.example/*' =\u003e Http::response(['ok' =\u003e true], 200),\n]);\n\n// Execute code under test — it calls HttpLog::make(...)-\u003epost('https://provider.example/orders', ...)\n\nHttp::assertSent(function (\\Illuminate\\Http\\Client\\Request $request) {\n    return $request-\u003eurl() === 'https://provider.example/orders'\n        \u0026\u0026 $request['order_id'] === 1;\n});\n```\n\nTo assert the request was sent as a form, use `$request-\u003eisForm()`; for JSON, `$request-\u003eisJson()`.\n\nIf you also want to assert that the logging job was queued, fake the bus and check for the job:\n\n```php\nuse Illuminate\\Support\\Facades\\Bus;\nuse Tsitsishvili\\ElasticAudit\\Jobs\\LogHttpRequestJob;\n\nBus::fake();\nHttp::fake(['provider.example/*' =\u003e Http::response(['ok' =\u003e true], 200)]);\n\n// Execute code under test\n\nBus::assertDispatched(LogHttpRequestJob::class);\n```\n\nIf you only need to assert that `make()` was called with a particular provider/event/context (and do\nnot care about the HTTP exchange), you can still spy on the facade as shown above with\n`HttpLog::spy()` + `shouldReceive('make')`.\n\n## Searching Logs in Elasticsearch\n\nThe package writes documents to the read alias configured as `index_alias` in\n`config/http_logs.php` (defaults to `{prefix}_http_logs`).\n\n### Using the PHP client\n\nResolve `LogElasticsearchClientInterface` from the container and call `search()`:\n\n```php\nuse Tsitsishvili\\ElasticAudit\\Services\\Elasticsearch\\LogElasticsearchClientInterface;\n\n$client = app(LogElasticsearchClientInterface::class);\n\n$results = $client-\u003esearch([\n    'index' =\u003e config('http_logs.index_alias'),\n    'body'  =\u003e [\n        'query' =\u003e [\n            'bool' =\u003e [\n                'must'   =\u003e [\n                    ['term' =\u003e ['provider'     =\u003e 'delivery']],\n                    ['term' =\u003e ['entity.type'  =\u003e 'order']],\n                    ['term' =\u003e ['entity.id'    =\u003e (string) $orderId]],\n                ],\n                'filter' =\u003e [\n                    ['range' =\u003e ['@timestamp' =\u003e ['gte' =\u003e 'now-7d', 'lt' =\u003e 'now']]],\n                ],\n            ],\n        ],\n        'sort' =\u003e [['@timestamp' =\u003e ['order' =\u003e 'desc']]],\n        'size' =\u003e 50,\n    ],\n]);\n\n$hits = $results['hits']['hits'];\n```\n\n### Common filter combinations\n\n**All failed outgoing requests for a provider:**\n\n```json\n{\n  \"query\": {\n    \"bool\": {\n      \"must\": [\n        {\n          \"term\": {\n            \"provider\": \"delivery\"\n          }\n        },\n        {\n          \"term\": {\n            \"direction\": \"outgoing\"\n          }\n        },\n        {\n          \"term\": {\n            \"success\": false\n          }\n        }\n      ]\n    }\n  },\n  \"sort\": [\n    {\n      \"@timestamp\": {\n        \"order\": \"desc\"\n      }\n    }\n  ],\n  \"size\": 100\n}\n```\n\n**All logs for a specific entity (e.g., order 42) across providers:**\n\n```json\n{\n  \"query\": {\n    \"bool\": {\n      \"must\": [\n        {\n          \"term\": {\n            \"entity.type\": \"order\"\n          }\n        },\n        {\n          \"term\": {\n            \"entity.id\": \"42\"\n          }\n        }\n      ]\n    }\n  },\n  \"sort\": [\n    {\n      \"@timestamp\": {\n        \"order\": \"desc\"\n      }\n    }\n  ],\n  \"size\": 50\n}\n```\n\n**Slow outgoing requests (latency over 3 seconds) in the last 24 hours:**\n\n```json\n{\n  \"query\": {\n    \"bool\": {\n      \"must\": [\n        {\n          \"term\": {\n            \"direction\": \"outgoing\"\n          }\n        }\n      ],\n      \"filter\": [\n        {\n          \"range\": {\n            \"http.latency_ms\": {\n              \"gte\": 3000\n            }\n          }\n        },\n        {\n          \"range\": {\n            \"@timestamp\": {\n              \"gte\": \"now-24h\"\n            }\n          }\n        }\n      ]\n    }\n  },\n  \"sort\": [\n    {\n      \"http.latency_ms\": {\n        \"order\": \"desc\"\n      }\n    }\n  ],\n  \"size\": 50\n}\n```\n\n**5xx responses by provider in the last hour:**\n\n```json\n{\n  \"query\": {\n    \"bool\": {\n      \"must\": [\n        {\n          \"term\": {\n            \"http.status_class\": \"5xx\"\n          }\n        }\n      ],\n      \"filter\": [\n        {\n          \"range\": {\n            \"@timestamp\": {\n              \"gte\": \"now-1h\"\n            }\n          }\n        }\n      ]\n    }\n  },\n  \"aggs\": {\n    \"by_provider\": {\n      \"terms\": {\n        \"field\": \"provider\",\n        \"size\": 20\n      }\n    }\n  },\n  \"size\": 0\n}\n```\n\n**Incoming callbacks for a specific event type:**\n\n```json\n{\n  \"query\": {\n    \"bool\": {\n      \"must\": [\n        {\n          \"term\": {\n            \"direction\": \"incoming\"\n          }\n        },\n        {\n          \"term\": {\n            \"event_type\": \"delivery_status_callback\"\n          }\n        }\n      ],\n      \"filter\": [\n        {\n          \"range\": {\n            \"@timestamp\": {\n              \"gte\": \"now-7d\"\n            }\n          }\n        }\n      ]\n    }\n  },\n  \"sort\": [\n    {\n      \"@timestamp\": {\n        \"order\": \"desc\"\n      }\n    }\n  ],\n  \"size\": 50\n}\n```\n\nAll queries can be run directly in Kibana Dev Tools against the read alias. Replace\n`my_app_http_logs` with the alias configured for your application.\n\n## Activity Logging\n\nAn independent subsystem for recording **what actors did or changed** — user actions and Eloquent model\nchanges — indexed to a dedicated Elasticsearch index. It is fully decoupled from the HTTP logger: the two share\nonly the Elasticsearch client and the service provider. Each has its own config, index/aliases, DTOs, job,\nindexer, commands, and dashboard.\n\n### How It Works\n\nThe capture → queue → index pipeline mirrors the HTTP logger:\n\n```text\nActivityLogger::record()  →  ActivityLogData (immutable DTO)  →  LogActivityJob (queued)\n    →  ActivityLogIndexer  →  LogElasticsearchClientInterface  →  activity write alias\n```\n\nCapture never throws and is gated by `activity_logs.enabled`. Indexing happens asynchronously on the\nconfigured queue. The document ID is `sha256(eventId)`.\n\n### Activity Configuration\n\nPublished alongside the other configs under the `elastic-audit` tag:\n\n```bash\nphp artisan vendor:publish --tag=elastic-audit\n```\n\n`config/activity_logs.php`:\n\n```php\nreturn [\n    'enabled'           =\u003e env('ACTIVITY_LOGS_ENABLED', true),\n    'queue'             =\u003e env('ACTIVITY_LOGS_QUEUE', 'default'),\n    'retention_days'    =\u003e 360,\n\n    'index_alias'       =\u003e strtolower(env('LOG_ELASTICSEARCH_INDEX_PREFIX', env('APP_NAME'))) . '_activity_logs',\n    'index_alias_write' =\u003e strtolower(env('LOG_ELASTICSEARCH_INDEX_PREFIX', env('APP_NAME'))) . '_activity_logs_write',\n\n    // Redaction applied to the 'changes' and 'metadata' maps before queueing.\n    'redaction' =\u003e [\n        'block' =\u003e [],\n        'allow' =\u003e [],\n    ],\n\n    'dashboard' =\u003e [\n        'enabled'    =\u003e env('ACTIVITY_LOGS_DASHBOARD_ENABLED', true),\n        'prefix'     =\u003e env('ELASTIC_AUDIT_DASHBOARD_PREFIX', 'logger'),\n        'path'       =\u003e env('ACTIVITY_LOGS_DASHBOARD_PATH', 'activity'),\n        'middleware' =\u003e ['web'],\n        'per_page'   =\u003e 25,\n    ],\n];\n```\n\nIt reuses the existing `log_elasticsearch.php` connection — activity logs are never written to the\nproduct-search cluster.\n\nThe `changes` and `metadata` maps are redacted by key name before queueing, using the same rules as the HTTP logger\n(so a model's `password` / `email` attribute diffs never reach Elasticsearch in clear text). Tune it with\n`activity_logs.redaction.block` / `.allow` — same semantics as the HTTP [Redaction Notes](#redaction-notes), but a\nsingle flat list since activity events have no headers.\n\nRelevant environment variables:\n\n| Variable                          | Default    | Purpose                                                                                           |\n|-----------------------------------|------------|---------------------------------------------------------------------------------------------------|\n| `ACTIVITY_LOGS_ENABLED`           | `true`     | Master on/off switch for capture                                                                  |\n| `ACTIVITY_LOGS_QUEUE`             | `default`  | Queue the indexing job is dispatched to                                                           |\n| `ACTIVITY_LOGS_DASHBOARD_ENABLED` | `true`     | Register the dashboard routes                                                                     |\n| `ELASTIC_AUDIT_DASHBOARD_PREFIX`  | `logger`   | Shared URL prefix for both dashboards. Composes as `{prefix}/{path}`. Set to `''` for root paths. |\n| `ACTIVITY_LOGS_DASHBOARD_PATH`    | `activity` | This dashboard's subpath under the group prefix. Served at `/logger/activity`.                    |\n\n### Create the Activity Index\n\n```bash\nphp artisan activity-logs:create-index\n```\n\nCreates the physical index (`\u003cprefix\u003e_activity_logs_\u003ctimestamp\u003e`) with a `dynamic: strict` mapping and\nattaches the read/write aliases.\n\n### Manual Logging\n\nUse the `ActivityLog` facade. Build an `ActivityLogContext` describing the actor and entity, then record an\naction with an optional field-level diff and metadata.\n\n```php\nuse Tsitsishvili\\ElasticAudit\\Facades\\ActivityLog;\nuse Tsitsishvili\\ElasticAudit\\DataTransferObjects\\ActivityLogContext;\nuse App\\Enums\\ElasticAudit\\EntityType;\n\n// User-driven change with a before/after diff\nActivityLog::record(\n    action: 'order.status_updated',\n    context: ActivityLogContext::forActor(\n        actorType: 'user',\n        actorId: $userId,\n        entityType: EntityType::Order,\n        entityId: (string) $order-\u003eid,\n        requestId: $request-\u003eheader('X-Request-ID'), // optional; auto-ULID if omitted\n    ),\n    changes: [\n        'status' =\u003e ['old' =\u003e 'pending', 'new' =\u003e 'paid'],\n        'amount' =\u003e ['old' =\u003e 100,       'new' =\u003e 95],\n    ],\n);\n\n// System/cron action — no actor id, marked as failed\nActivityLog::record(\n    action: 'invoice.auto_cancelled',\n    context: ActivityLogContext::forActor(\n        actorType: 'cron',\n        actorId: null,\n        entityType: EntityType::Invoice,\n        entityId: (string) $invoice-\u003eid,\n    ),\n    metadata: ['reason' =\u003e 'payment_timeout'],\n    success: false,\n    errorClass: TimeoutException::class,\n    errorMessage: 'Payment confirmation timed out',\n);\n```\n\n`entityType` accepts any `EntityTypeContract` (typically a backed enum published into your app).\n`actorType` is a free string — conventionally `user`, `system`, `cron`, or `job`. `retentionDays` defaults to\n`360` and can be overridden per call via `ActivityLogContext::forActor(..., retentionDays: 90)`.\n\n### Automatic Model Logging (the `ActivityLoggable` trait)\n\nAdd the trait to an Eloquent model to log `created` / `updated` / `deleted` automatically with a computed diff:\n\n```php\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Tsitsishvili\\ElasticAudit\\Traits\\ActivityLoggable;\n\nclass Order extends Model\n{\n    use ActivityLoggable;\n\n    // Optional — defaults to Str::snake(class_basename($model)), e.g. \"order\"\n    protected string $activityEntityType = 'order';\n\n    // Optional — fields excluded from the diff.\n    // Defaults to ['created_at', 'updated_at', 'deleted_at'] when not defined.\n    protected array $activityLogExcept = ['updated_at', 'created_at'];\n\n    // Optional — if non-empty, only these fields appear in the diff.\n    protected array $activityLogOnly = [];\n}\n```\n\n| Eloquent event | Action logged      | `changes` content                                            |\n|----------------|--------------------|--------------------------------------------------------------|\n| `created`      | `{entity}.created` | `{field: {old: null, new: value}}` for all logged attributes |\n| `updated`      | `{entity}.updated` | `{field: {old, new}}` for dirty fields only                  |\n| `deleted`      | `{entity}.deleted` | `{}` (the entity itself is the event)                        |\n\n`$activityLogOnly` is applied first (whitelist), then `$activityLogExcept` (blacklist). The entity id is\n`(string) $model-\u003egetKey()`.\n\n### Actor Resolution\n\nThe trait resolves the current actor automatically:\n\n1. `Auth::check()` is true → `actorType: \"user\"`, `actorId: Auth::id()`\n2. Otherwise → `actorType: \"system\"`, `actorId: null`\n\nFor manual `ActivityLog::record()` calls you set the actor explicitly via the context.\n\n### Document Shape\n\n```json\n{\n  \"@timestamp\": \"2026-06-04T10:00:00Z\",\n  \"event_id\": \"01JX...\",\n  \"schema_version\": 1,\n  \"request_id\": \"01JX...\",\n  \"actor\": {\n    \"type\": \"user\",\n    \"id\": 42\n  },\n  \"action\": \"order.status_updated\",\n  \"entity\": {\n    \"type\": \"order\",\n    \"id\": \"99\"\n  },\n  \"changes\": {\n    \"status\": {\n      \"old\": \"pending\",\n      \"new\": \"paid\"\n    }\n  },\n  \"metadata\": {\n    \"ip\": \"1.2.3.4\"\n  },\n  \"success\": true,\n  \"error\": {\n    \"class\": null,\n    \"message\": null\n  },\n  \"retention_days\": 360\n}\n```\n\n`changes` and `metadata` are stored but **not indexed** (`enabled: false`) — their keys are caller-defined, so\nthey are searchable by `event_id`/`action`/`actor`/`entity` but not by their inner keys.\n\n### Activity Dashboard\n\nWhen `activity_logs.dashboard.enabled` is true, the dashboard is served under the configured path (default\n`/logger/activity`):\n\n- **Overview** — total / success / failure counts, top actions, top actor types.\n- **List** — paginated, newest first, filterable by action, actor type, success, entity id, and date range.\n- **Detail** — full event, a before/after change table, and a metadata dump.\n\nAccess is gated by the same authorization callback as the HTTP dashboard:\n\n```php\nuse Tsitsishvili\\ElasticAudit\\Dashboard\\Dashboard;\n\nDashboard::auth(fn ($request) =\u003e $request-\u003euser()?-\u003ecan('viewActivityLogs') === true);\n```\n\nBy default (no callback registered) access is restricted to the `local` environment.\n\n### Pruning Activity Logs\n\n```bash\nphp artisan activity-logs:prune\n```\n\nDeletes documents older than their own `retention_days` value (each document carries its retention, so different\nactions can have different lifetimes). Schedule it daily.\n\n### Guarantees\n\n- **Capture never throws.** A logging failure can never break the surrounding request — errors are swallowed and\n  the job's own failures are logged, not propagated.\n- **Disabled is a true no-op.** With `activity_logs.enabled = false`, `record()` returns immediately and no job\n  is dispatched.\n- **Backward compatibility.** The indexed document shape is versioned via `ActivityLogData::SCHEMA_VERSION`; the\n  mapping is `dynamic: strict`.\n\n## Internal Versioning\n\nUse Git tags as Composer versions.\n\n```bash\ngit tag v1.0.0\ngit push origin v1.0.0\n```\n\nRecommended policy:\n\n- Patch: bug fixes only, for example `v1.0.1`\n- Minor: backward-compatible features, for example `v1.1.0`\n- Major: breaking config, contract, class, or behavior changes, for example `v2.0.0`\n\nApplications should depend on stable tags:\n\n```json\n{\n  \"require\": {\n    \"tsitsishvili/elastic-audit\": \"^1.0\"\n  }\n}\n```\n\nAvoid using `dev-main` in production applications.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftsitsishvili%2Felastic-audit","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ftsitsishvili%2Felastic-audit","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftsitsishvili%2Felastic-audit/lists"}