systemverk / laravel-api-telemetry
A Laravel package for API request telemetry, providing Redis-backed collection, aggregation and persistence of API request metrics and per-actor usage statistics.
Package info
github.com/systemverk/laravel-api-telemetry
pkg:composer/systemverk/laravel-api-telemetry
Requires
- php: ^8.2
- illuminate/console: ^11.0|^12.0
- illuminate/contracts: ^11.0|^12.0
- illuminate/database: ^11.0|^12.0
- illuminate/http: ^11.0|^12.0
- illuminate/redis: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
Requires (Dev)
- larastan/larastan: ^3.0
- mockery/mockery: ^1.6
- orchestra/testbench: ^9.0|^10.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
Suggests
- ext-redis: Recommended Redis client for production throughput (alternative: predis/predis).
- predis/predis: Pure PHP Redis client, usable when ext-redis is unavailable.
README
Lightweight API observability for Laravel.
Every API request is buffered in Redis after the response has been sent, flushed to SQL in batches by a scheduled command, and rolled up into daily and monthly per-actor usage statistics.
Requirements
| Supported | |
|---|---|
| PHP | 8.2, 8.3, 8.4 |
| Laravel | 11.x, 12.x |
| Redis client | ext-redis (recommended) or predis/predis |
| Database | MySQL, MariaDB, PostgreSQL, SQLite, SQL Server |
Quick Start
1. Install
composer require systemverk/laravel-api-telemetry
2. Run migrations
php artisan migrate
The package ships its migrations and loads them automatically — there is nothing to publish. Two tables are created:
api_request_logs— raw request telemetryapi_usage_stats— daily and monthly aggregates
3. Make sure Redis is configured
Requests are appended to a Redis list before being flushed to SQL.
- A connection must exist under
database.redis(ordatabase.redis.clusters) - The package uses the
defaultconnection unless told otherwise - Override with
API_TELEMETRY_REDIS_CONNECTION
If the configured connection does not exist, the middleware records nothing rather than throwing — telemetry never takes an application down.
4. Run the scheduler
The commands are registered on the scheduler automatically, but they only run if your scheduler runs.
* * * * * php /path/to/app/artisan schedule:run >> /dev/null 2>&1
During development:
php artisan schedule:work
5. Verify telemetry is flowing
php artisan api-telemetry:flush php artisan api-telemetry:consolidate-daily --date=2026-06-16 php artisan schedule:list
What Runs Automatically
With the default configuration:
LogApiRequestis appended to theapimiddleware group. If that group does not exist, registration is skipped silently.- Four scheduled commands are registered, each
withoutOverlapping():
| Command | Frequency | Purpose |
|---|---|---|
api-telemetry:flush --max-minutes=5 |
every minute | Redis buffer → api_request_logs |
api-telemetry:consolidate-daily |
daily at 02:00 | Raw logs → daily stats |
api-telemetry:consolidate-monthly |
monthly on day 1 at 03:00 | Daily stats → monthly stats |
api-telemetry:prune |
daily at 03:10 | Delete raw logs past retention |
How It Works
request ──► LogApiRequest::handle() records a start timestamp only
──► ...application...
──► response sent to the client
──► LogApiRequest::terminate() RPUSH onto api_telemetry:requests:<YYYYMMDDHHMM>
│
every minute: api-telemetry:flush ─────┘
RENAMENX the minute list to a private processing key, insert in batches,
then delete. The key is tracked in a Redis set until the write is
confirmed, so a crashed or failed flush is retried on the next run
instead of silently losing entries.
Buffering happens in terminate(), so the two Redis round trips are off the
critical path of the response.
Configuration
Defaults are usable as-is. Publish the config only if you need to change them:
php artisan vendor:publish --tag=api-telemetry-config
| Key | Env | Default | Description |
|---|---|---|---|
enabled |
API_TELEMETRY_ENABLED |
true |
Master on/off switch |
redis.connection |
API_TELEMETRY_REDIS_CONNECTION |
default |
Redis connection name |
redis.key_prefix |
API_TELEMETRY_REDIS_KEY_PREFIX |
api_telemetry: |
Redis key prefix |
redis.ttl_seconds |
API_TELEMETRY_REDIS_TTL_SECONDS |
7200 |
Buffer key TTL (min 60) |
database.connection |
API_TELEMETRY_DB_CONNECTION |
null |
Dedicated connection, or the app default |
database.tables.request_logs |
API_TELEMETRY_TABLE_REQUEST_LOGS |
api_request_logs |
Raw log table name |
database.tables.usage_stats |
API_TELEMETRY_TABLE_USAGE_STATS |
api_usage_stats |
Aggregate table name |
database.user_id_type |
API_TELEMETRY_USER_ID_TYPE |
integer |
Set to string for UUID/ULID user keys |
except |
— | ['up', 'health'] |
Paths never recorded (supports *) |
sampling_rate |
API_TELEMETRY_SAMPLING_RATE |
1.0 |
Fraction of requests recorded |
privacy.hash_ips |
API_TELEMETRY_HASH_IPS |
true |
Store a salted hash, or nothing at all |
privacy.ip_hash_salt |
API_TELEMETRY_IP_HASH_SALT |
null |
Defaults to app.key |
privacy.record_user_agent |
API_TELEMETRY_RECORD_USER_AGENT |
true |
Store the user agent string |
request_id_headers |
— | X-Request-Id, X-Correlation-Id |
Correlation-id headers, in priority order |
flush_batch_size |
API_TELEMETRY_FLUSH_BATCH_SIZE |
1000 |
Rows per insert statement |
consolidation_chunk_size |
API_TELEMETRY_CONSOLIDATION_CHUNK_SIZE |
2000 |
Read chunk size during rollup |
retention_days |
API_TELEMETRY_RETENTION_DAYS |
90 |
Raw-log retention period |
auto_register_middleware |
API_TELEMETRY_AUTO_MIDDLEWARE |
true |
Auto-append the middleware |
middleware_group |
API_TELEMETRY_MIDDLEWARE_GROUP |
api |
Group to append the middleware to |
schedule.enabled |
API_TELEMETRY_SCHEDULE_ENABLED |
true |
Auto-register scheduled commands |
schedule.flush_minutes |
API_TELEMETRY_SCHEDULE_FLUSH_MINUTES |
5 |
--max-minutes used by flush |
schedule.daily_at |
API_TELEMETRY_SCHEDULE_DAILY_AT |
02:00 |
Daily consolidation time |
schedule.monthly_at |
API_TELEMETRY_SCHEDULE_MONTHLY_AT |
03:00 |
Monthly consolidation time |
schedule.prune_at |
API_TELEMETRY_SCHEDULE_PRUNE_AT |
03:10 |
Prune time |
Excluding noisy endpoints
'except' => [ 'up', 'health', 'webhooks/*', ],
Sampling high-volume traffic
'sampling_rate' => 0.1, // record roughly one request in ten
Aggregated counts are not scaled back up, so a sampled deployment reports
sampled numbers. Keep the rate at 1.0 if the counts must be exact.
UUID or ULID user keys
Set database.user_id_type to string before running the migrations. With
the default integer setting, non-numeric identifiers are recorded as null
rather than being silently coerced to 0.
Manual Wiring
For full control, disable both switches:
API_TELEMETRY_AUTO_MIDDLEWARE=false API_TELEMETRY_SCHEDULE_ENABLED=false
Then register everything yourself:
// bootstrap/app.php use Systemverk\LaravelApiTelemetry\Http\Middleware\LogApiRequest; ->withMiddleware(function (Middleware $middleware) { $middleware->api(append: [LogApiRequest::class]); }) // routes/console.php use Illuminate\Support\Facades\Schedule; Schedule::command('api-telemetry:flush --max-minutes=5')->everyMinute()->withoutOverlapping(); Schedule::command('api-telemetry:consolidate-daily')->dailyAt('02:00'); Schedule::command('api-telemetry:consolidate-monthly')->monthlyOn(1, '03:00'); Schedule::command('api-telemetry:prune')->dailyAt('03:10');
Data Model
All timestamps are stored in UTC, independent of app.timezone.
api_request_logs (raw)
One row per recorded request:
| Column | Notes |
|---|---|
requested_at |
UTC |
method, path, route_name |
path truncated to 1024 chars |
status_code, duration_ms |
|
user_id |
Nullable; integer or string per config |
ip_hash |
Salted SHA-256, or null |
user_agent |
Truncated to 512 chars, or null |
request_id |
First matching correlation header, 64 chars |
api_usage_stats (aggregated)
One row per (period_type, period_start, actor_key):
period_typeisdayormonthactor_keyisguestoruser:{id}total_requestsplus a counter per status class:responses_1xxthroughresponses_5xx
Consolidation is idempotent — reruns upsert rather than accumulate, so a backfill can be re-run safely:
php artisan api-telemetry:consolidate-daily --date=2026-06-16 php artisan api-telemetry:consolidate-monthly --month=2026-06
Invalid --date or --month values produce a clear error and exit code 1.
Querying the Data
Both models are plain Eloquent models and are part of the public API.
use Systemverk\LaravelApiTelemetry\Models\ApiRequestLog; use Systemverk\LaravelApiTelemetry\Models\ApiUsageStat; // Slowest endpoints in the last 24 hours ApiRequestLog::query() ->where('requested_at', '>=', now()->utc()->subDay()) ->selectRaw('path, count(*) as hits, avg(duration_ms) as avg_ms') ->groupBy('path') ->orderByDesc('avg_ms') ->limit(10) ->get(); // Server errors today ApiRequestLog::query()->statusClass(5)->whereDate('requested_at', today())->count(); // Monthly usage for one customer ApiUsageStat::query()->monthly()->where('actor_key', 'user:42')->get();
Privacy
- IP addresses are never stored in clear text — only a salted SHA-256 digest, or
nothing when
privacy.hash_ipsis disabled - Request and response bodies are never recorded
- No headers are recorded except the configured correlation id
- Request paths are stored verbatim. If your API puts secrets in the URL
path, exclude those routes via
except
Rotating APP_KEY changes the default IP hash salt, so hashes recorded before
and after a rotation will not correlate. Set an explicit
API_TELEMETRY_IP_HASH_SALT if that matters to you.
Testing
composer install
composer test
composer analyse
The suite runs against SQLite in memory with an in-memory Redis double, so no services are required.
Contributing
See CONTRIBUTING.md. Security issues: SECURITY.md.
License
MIT — see LICENSE.