Skip to content

Commit 61ad72a

Browse files
authored
Add query logging (#3934)
1 parent 1c40341 commit 61ad72a

File tree

5 files changed

+360
-1
lines changed

5 files changed

+360
-1
lines changed

app/Listeners/LogQueryTimeout.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Listeners;
10+
11+
use Illuminate\Database\Events\QueryExecuted;
12+
use Illuminate\Support\Facades\Log;
13+
14+
/**
15+
* Tracks long-running SQL queries that may timeout.
16+
*
17+
* This listener works by storing query start information in cache
18+
* and checking execution time after completion.
19+
*/
20+
class LogQueryTimeout
21+
{
22+
/**
23+
* Handle the event when a query is executed.
24+
*
25+
* @param QueryExecuted $event
26+
*
27+
* @return void
28+
*/
29+
public function handle(QueryExecuted $event): void
30+
{
31+
$max_execution_time = config('octane.max_execution_time', 30) * 1000; // convert to ms
32+
$critical_threshold = $max_execution_time * 0.9; // 90% of timeout
33+
$warning_threshold = $max_execution_time * 0.7; // 70% of timeout
34+
35+
// Log if query is dangerously slow
36+
if ($event->time >= $critical_threshold) {
37+
Log::error('🚨 CRITICAL: Query approaching timeout', [
38+
'execution_time_ms' => $event->time,
39+
'execution_time_s' => round($event->time / 1000, 2),
40+
'timeout_limit_s' => $max_execution_time / 1000,
41+
'percentage' => round(($event->time / $max_execution_time) * 100, 1) . '%',
42+
'sql' => $event->sql,
43+
'bindings' => $event->bindings,
44+
'connection' => $event->connectionName,
45+
'url' => request()?->fullUrl() ?? 'N/A',
46+
]);
47+
} elseif ($event->time >= $warning_threshold) {
48+
Log::warning('⚠️ WARNING: Slow query detected', [
49+
'execution_time_ms' => $event->time,
50+
'execution_time_s' => round($event->time / 1000, 2),
51+
'timeout_limit_s' => $max_execution_time / 1000,
52+
'percentage' => round(($event->time / $max_execution_time) * 100, 1) . '%',
53+
'sql' => $event->sql,
54+
'bindings' => $event->bindings,
55+
'connection' => $event->connectionName,
56+
'url' => request()?->fullUrl() ?? 'N/A',
57+
]);
58+
}
59+
}
60+
}

app/Providers/AppServiceProvider.php

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,29 @@ private function registerDatabaseOptions(): void
194194
DB::prohibitDestructiveCommands(config('app.env', 'production') !== 'dev');
195195
if (config('database.db_log_sql', false) === true) {
196196
// @codeCoverageIgnoreStart
197-
DB::listen(fn ($q) => $this->logSQL($q));
197+
// Log queries when they start
198+
DB::beforeExecuting(fn ($sql, $bindings, $connection) => $this->logSQLStart($sql, $bindings, $connection));
199+
200+
// Log queries as they execute (for successful queries)
201+
DB::listen(fn (QueryExecuted $q): null => $this->logSQL($q));
202+
203+
// Register shutdown function to catch queries that timeout
204+
// This logs queries that were running when PHP times out
205+
register_shutdown_function(function (): void {
206+
$error = error_get_last();
207+
if ($error !== null && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_RECOVERABLE_ERROR], true)) {
208+
// Check if it's a timeout error
209+
if (str_contains($error['message'], 'Maximum execution time') || str_contains($error['message'], 'timeout')) {
210+
Log::error('🔥 PHP TIMEOUT DETECTED', [
211+
'error' => $error['message'],
212+
'file' => $error['file'],
213+
'line' => $error['line'],
214+
'url' => request()?->fullUrl() ?? 'N/A',
215+
'method' => request()?->method() ?? 'N/A',
216+
]);
217+
}
218+
}
219+
});
198220
// @codeCoverageIgnoreEnd
199221
}
200222

@@ -232,6 +254,52 @@ private function registerLoggerAccess(): void
232254
});
233255
}
234256

257+
/**
258+
* @codeCoverageIgnore
259+
*/
260+
private function logSQLStart(string $sql, array $bindings, string $connection): void
261+
{
262+
$uri = request()?->getRequestUri() ?? '';
263+
// Quick exit
264+
if (
265+
Str::contains($uri, 'logs', true) ||
266+
Str::contains($sql, $this->ignore_log_SQL)
267+
) {
268+
return;
269+
}
270+
271+
// Get the call stack to find where the query originated
272+
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 15);
273+
$caller = null;
274+
275+
// Skip framework internals and find the first app file
276+
foreach ($trace as $frame) {
277+
if (isset($frame['file']) &&
278+
!Str::contains($frame['file'], 'vendor/') &&
279+
!Str::contains($frame['file'], 'AppServiceProvider.php')) {
280+
$caller = [
281+
'file' => str_replace(base_path() . '/', '', $frame['file']),
282+
'line' => $frame['line'] ?? 0,
283+
];
284+
break;
285+
}
286+
}
287+
288+
// Get message with binding
289+
$msg = 'START: ' . $sql . ' [' . implode(', ', $bindings) . ']';
290+
291+
$context = [
292+
'connection' => $connection,
293+
'url' => request()?->fullUrl() ?? 'N/A',
294+
];
295+
296+
if ($caller !== null) {
297+
$context['origin'] = $caller['file'] . ':' . $caller['line'];
298+
}
299+
300+
Log::debug($msg, $context);
301+
}
302+
235303
/**
236304
* @codeCoverageIgnore
237305
*/

app/Providers/EventServiceProvider.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use App\Events\TaggedRouteCacheUpdated;
2525
use App\Listeners\AlbumCacheCleaner;
2626
use App\Listeners\CacheListener;
27+
use App\Listeners\LogQueryTimeout;
2728
use App\Listeners\MetricsListener;
2829
use App\Listeners\OrderCompletedListener;
2930
use App\Listeners\RecomputeAlbumSizeOnAlbumChange;
@@ -36,6 +37,7 @@
3637
use Illuminate\Cache\Events\CacheMissed;
3738
use Illuminate\Cache\Events\KeyForgotten;
3839
use Illuminate\Cache\Events\KeyWritten;
40+
use Illuminate\Database\Events\QueryExecuted;
3941
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
4042
use Illuminate\Support\Facades\Event;
4143
use SocialiteProviders\Amazon\AmazonExtendSocialite;
@@ -91,6 +93,13 @@ public function boot(): void
9193
Event::listen(AlbumRouteCacheUpdated::class, AlbumCacheCleaner::class . '@handle');
9294
Event::listen(TaggedRouteCacheUpdated::class, TaggedRouteCacheCleaner::class . '@handle');
9395

96+
// Log slow/timeout SQL queries when DB_LOG_SQL is enabled
97+
// @codeCoverageIgnoreStart
98+
if (config('database.db_log_sql', false) === true) {
99+
Event::listen(QueryExecuted::class, LogQueryTimeout::class . '@handle');
100+
}
101+
// @codeCoverageIgnoreEnd
102+
94103
Event::listen(AlbumDownload::class, MetricsListener::class . '@handle');
95104
Event::listen(AlbumShared::class, MetricsListener::class . '@handle');
96105
Event::listen(AlbumVisit::class, MetricsListener::class . '@handle');
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# SQL Timeout Logging
2+
3+
This document explains how to log SQL requests that are timing out or approaching timeout limits in Lychee.
4+
5+
## Overview
6+
7+
When running with Laravel Octane (FrankenPHP/Swoole/RoadRunner), requests have a maximum execution time (default: 30 seconds as configured in `config/octane.php`). SQL queries that take too long can cause the entire request to timeout.
8+
9+
The logging system tracks:
10+
1. **Slow queries** - Queries taking longer than configured threshold
11+
2. **Queries approaching timeout** - Queries using >70% of max execution time
12+
3. **Critical queries** - Queries using >90% of max execution time
13+
4. **PHP timeouts** - When the entire request times out
14+
15+
## Configuration
16+
17+
### Enable SQL Logging
18+
19+
Set the following in your `.env` file:
20+
21+
```env
22+
# Enable SQL query logging
23+
DB_LOG_SQL=true
24+
25+
# Optional: Minimum execution time to log (in milliseconds, default: 100)
26+
DB_LOG_SQL_MIN_TIME=100
27+
28+
# Optional: Enable EXPLAIN for MySQL SELECT queries
29+
DB_LOG_SQL_EXPLAIN=true
30+
```
31+
32+
### Timeout Settings
33+
34+
The max execution time is configured in `config/octane.php`:
35+
36+
```php
37+
'max_execution_time' => 30, // seconds
38+
```
39+
40+
## How It Works
41+
42+
### 1. Query Execution Logging
43+
44+
**Location**: [app/Providers/AppServiceProvider.php:238-300](app/Providers/AppServiceProvider.php#L238-L300)
45+
46+
The `logSQL()` method logs queries after they complete with severity based on execution time:
47+
- **Debug**: Normal slow queries (>100ms)
48+
- **Warning**: Queries taking >1 second
49+
- **Error**: Queries approaching timeout (>90% of max_execution_time)
50+
51+
### 2. Timeout Detection Listener
52+
53+
**Location**: [app/Listeners/LogQueryTimeout.php](app/Listeners/LogQueryTimeout.php)
54+
55+
Registered in [app/Providers/EventServiceProvider.php:97-99](app/Providers/EventServiceProvider.php#L97-L99)
56+
57+
This listener provides detailed logging for queries that exceed warning/critical thresholds:
58+
- **70% threshold**: WARNING level log
59+
- **90% threshold**: CRITICAL/ERROR level log
60+
61+
### 3. PHP Timeout Handler
62+
63+
**Location**: [app/Providers/AppServiceProvider.php:200-216](app/Providers/AppServiceProvider.php#L200-L216)
64+
65+
A shutdown function that catches when PHP times out entirely, logging:
66+
- Error message
67+
- File and line where timeout occurred
68+
- Request URL and method
69+
70+
## Log Files
71+
72+
Logs are written to different files based on severity:
73+
74+
- `storage/logs/errors.log` - Critical/slow queries (>90% timeout)
75+
- `storage/logs/warning.log` - Slow queries (>80% timeout or >1s)
76+
- `storage/logs/daily.log` - All SQL queries (when DB_LOG_SQL=true)
77+
78+
## Example Log Entries
79+
80+
### Slow Query Warning
81+
```
82+
[2026-01-04 10:15:32] warning.WARNING: ⚠️ WARNING: Slow query detected {"execution_time_ms":21000,"execution_time_s":21,"timeout_limit_s":30,"percentage":"70%","sql":"SELECT * FROM photos WHERE album_id = ?","bindings":[123],"connection":"mysql","url":"https://lychee.local/api/albums/123/photos"}
83+
```
84+
85+
### Critical Query Near Timeout
86+
```
87+
[2026-01-04 10:20:45] error.ERROR: 🚨 CRITICAL: Query approaching timeout {"execution_time_ms":27500,"execution_time_s":27.5,"timeout_limit_s":30,"percentage":"91.7%","sql":"SELECT * FROM photos WHERE...","bindings":[...],"connection":"mysql","url":"https://lychee.local/api/..."}
88+
```
89+
90+
### PHP Timeout Detected
91+
```
92+
[2026-01-04 10:25:12] error.ERROR: 🔥 PHP TIMEOUT DETECTED {"error":"Maximum execution time of 30 seconds exceeded","file":"/app/vendor/laravel/framework/src/Illuminate/Database/Connection.php","line":742,"url":"https://lychee.local/api/albums/delete","method":"DELETE"}
93+
```
94+
95+
## Troubleshooting Timeouts
96+
97+
When you see timeout logs:
98+
99+
1. **Check the SQL query** - Look for missing indexes, inefficient joins, or full table scans
100+
2. **Use EXPLAIN** - Enable `DB_LOG_SQL_EXPLAIN=true` to see query execution plans
101+
3. **Add indexes** - Common fixes involve adding database indexes
102+
4. **Optimize queries** - Rewrite queries to be more efficient
103+
5. **Increase timeout** - As a last resort, increase `max_execution_time` in `config/octane.php`
104+
6. **Use queues** - Move long-running operations to background jobs
105+
106+
## Performance Impact
107+
108+
SQL logging has minimal performance impact when disabled. When enabled:
109+
- Each query execution triggers event listeners
110+
- EXPLAIN queries add overhead for SELECT statements (MySQL only)
111+
- Logs are written asynchronously via Monolog
112+
113+
**Recommendation**: Only enable in development or temporarily in production for debugging.
114+
115+
## Related Configuration
116+
117+
### Database Connection Timeouts
118+
119+
MySQL connection settings in [config/database.php:111-113](config/database.php#L111-L113):
120+
121+
```php
122+
PDO::ATTR_TIMEOUT => 5, // Connection timeout (5 seconds)
123+
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET SESSION wait_timeout=28800', // 8 hours
124+
```
125+
126+
### Octane Database Ping
127+
128+
The AppServiceProvider pings database connections every 30 seconds to prevent timeouts: [app/Providers/AppServiceProvider.php:340-341](app/Providers/AppServiceProvider.php#L340-L341)
129+
130+
## Viewing Logs
131+
132+
Logs can be viewed via:
133+
1. **Log Viewer** - Built-in at `/log-viewer` (requires admin access)
134+
2. **Command line**: `tail -f storage/logs/errors.log`
135+
3. **Docker**: `docker logs <container_name>`
136+
137+
## Additional Notes
138+
139+
- Timeout detection works best with FrankenPHP, Swoole, or RoadRunner
140+
- Traditional PHP-FPM may not trigger all timeout handlers consistently
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
/**
10+
* We don't care for unhandled exceptions in tests.
11+
* It is the nature of a test to throw an exception.
12+
* Without this suppression we had 100+ Linter warning in this file which
13+
* don't help anything.
14+
*
15+
* @noinspection PhpDocMissingThrowsInspection
16+
* @noinspection PhpUnhandledExceptionInspection
17+
*/
18+
19+
namespace Tests\Unit\Listeners;
20+
21+
use App\Listeners\LogQueryTimeout;
22+
use Illuminate\Database\Events\QueryExecuted;
23+
use Illuminate\Support\Facades\Config;
24+
use Illuminate\Support\Facades\Log;
25+
use Tests\AbstractTestCase;
26+
27+
class LogQueryTimeoutTest extends AbstractTestCase
28+
{
29+
public function testQueryBelowWarningThreshold(): void
30+
{
31+
Log::shouldReceive('error')->never();
32+
Log::shouldReceive('warning')->never();
33+
34+
Config::set('octane.max_execution_time', 30);
35+
36+
$listener = new LogQueryTimeout();
37+
$event = new QueryExecuted(
38+
'SELECT * FROM users',
39+
[],
40+
10000, // 10 seconds (below 70% of 30s = 21s)
41+
new \Illuminate\Database\Connection(new \PDO('sqlite::memory:'))
42+
);
43+
44+
$listener->handle($event);
45+
}
46+
47+
public function testQueryAtWarningThreshold(): void
48+
{
49+
Log::shouldReceive('error')->never();
50+
Log::shouldReceive('warning')->once()->with('⚠️ WARNING: Slow query detected', \Mockery::type('array'));
51+
52+
Config::set('octane.max_execution_time', 30);
53+
54+
$listener = new LogQueryTimeout();
55+
$event = new QueryExecuted(
56+
'SELECT * FROM large_table',
57+
['param1'],
58+
22000, // 22 seconds (above 70% of 30s = 21s, below 90% = 27s)
59+
new \Illuminate\Database\Connection(new \PDO('sqlite::memory:'))
60+
);
61+
62+
$listener->handle($event);
63+
}
64+
65+
public function testQueryAtCriticalThreshold(): void
66+
{
67+
Log::shouldReceive('warning')->never();
68+
Log::shouldReceive('error')->once()->with('🚨 CRITICAL: Query approaching timeout', \Mockery::type('array'));
69+
70+
Config::set('octane.max_execution_time', 30);
71+
72+
$listener = new LogQueryTimeout();
73+
$event = new QueryExecuted(
74+
'SELECT * FROM very_large_table',
75+
['param1', 'param2'],
76+
28000, // 28 seconds (above 90% of 30s = 27s)
77+
new \Illuminate\Database\Connection(new \PDO('sqlite::memory:'))
78+
);
79+
80+
$listener->handle($event);
81+
}
82+
}

0 commit comments

Comments
 (0)