Skip to content

Commit 710f7ea

Browse files
authored
Merge pull request #4 from kodus/server-log
Server log
2 parents 0fd4d00 + 105ed83 commit 710f7ea

File tree

3 files changed

+212
-6
lines changed

3 files changed

+212
-6
lines changed

README.md

+22-3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ Alternative to the original [ChromeLogger](https://craig.is/writing/chrome-logge
1111
* [PSR-7](http://www.php-fig.org/psr/psr-7/) HTTP message abstraction for the models, and
1212
* [PSR-15](https://www.php-fig.org/psr/psr-15/) compliant middleware for quick integration.
1313

14+
✨ An alternative to the ChromeLogger extension [is also available](http://github.com/kodus/server-log)
15+
and is [highly recommended](#header-size-limit).
1416

1517
## Usage
1618

@@ -22,6 +24,8 @@ $logger = new ChromeLogger();
2224
$logger->notice("awesome sauce!");
2325
```
2426

27+
Note that this will have a [header-size limit](#header-size-limit) by default.
28+
2529
Using a PSR-7 compliant `ResponseInterface` instance, such as in a middleware stack, you can populate
2630
the Response as follows:
2731

@@ -69,11 +73,26 @@ try {
6973
Any PHP values injected via the context array will be serialized for client-side inspection - including complex
7074
object graphs and explicit serialization of problematic types like `Exception` and `DateTime`.
7175

76+
77+
<a name="header-size-limit"></a>
7278
### Header Size Limit
7379

74-
Note that [Chrome has a 250KB header size limit](https://cs.chromium.org/chromium/src/net/http/http_stream_parser.h?q=ERR_RESPONSE_HEADERS_TOO_BIG&sq=package:chromium&dr=C&l=159),
75-
which we have to respect - due to this fact, the beginning of the log may get truncated, if the header-size is above
76-
a set limit, which by default is 240KB. You can change this limit using the `ChromeLogger::setLimit()` method.
80+
[Chrome has a 250KB header size limit](https://cs.chromium.org/chromium/src/net/http/http_stream_parser.h?q=ERR_RESPONSE_HEADERS_TOO_BIG&sq=package:chromium&dr=C&l=159)
81+
and many popular web-servers (including NGINX and Apache) also have a limit.
82+
83+
By default, the beginning of the log will be truncated to keep the header size under the limit.
84+
85+
You can change this limit using the `ChromeLogger::setLimit()` method - but a better approach is
86+
to enable logging to local files, which will persist in a web-accessible folder for 60 seconds:
87+
88+
```php
89+
$logger->usePersistence("/var/www/mysite/webroot/logs", "/logs");
90+
```
91+
92+
Note that this isn't supported by the ChromeLogger extension - you will need to install the alternative
93+
[Server Log Chrome extension](http://github.com/kodus/server-log) instead. (It is backwards
94+
compatible with the header-format of the original ChromeLogger extension, so you can use this as
95+
a drop-in replacement for the original extension.)
7796

7897

7998
## Limitations

src/ChromeLogger.php

+103-3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
use DateTimeInterface;
66
use Error;
77
use Exception;
8+
use function file_put_contents;
9+
use InvalidArgumentException;
810
use JsonSerializable;
911
use Psr\Http\Message\ResponseInterface;
1012
use Psr\Log\AbstractLogger;
@@ -31,6 +33,7 @@ class ChromeLogger extends AbstractLogger implements LoggerInterface
3133
const CLASS_NAME = "type";
3234
const TABLES = "tables";
3335
const HEADER_NAME = "X-ChromeLogger-Data";
36+
const LOCATION_HEADER_NAME = "X-ServerLog-Location";
3437

3538
const LOG = "log";
3639
const WARN = "warn";
@@ -56,6 +59,16 @@ class ChromeLogger extends AbstractLogger implements LoggerInterface
5659
*/
5760
protected $entries = [];
5861

62+
/**
63+
* @var string|null
64+
*/
65+
private $local_path;
66+
67+
/**
68+
* @var string|null
69+
*/
70+
private $public_path;
71+
5972
/**
6073
* Logs with an arbitrary level.
6174
*
@@ -92,6 +105,32 @@ public function getLimit()
92105
return $this->limit;
93106
}
94107

108+
/**
109+
* Enables persistence to local log-files served from your web-root.
110+
*
111+
* Bypasses the header-size limitation (imposed by Chrome, NGINX, etc.) by avoiding the
112+
* large `X-ChromeLogger-Data` header and instead storing the log in a flat file.
113+
*
114+
* Requires the [ServerLog](https://github.com/mindplay-dk/server-log) Chrome extension,
115+
* which replaces the ChromeLogger extension - this does NOT work with the regular
116+
* ChromeLogger extension.
117+
*
118+
* @link https://github.com/mindplay-dk/server-log
119+
*
120+
* @param string $local_path absolute local path to a dedicated log-folder in your public web-root,
121+
* e.g. "/var/www/mysite.com/webroot/log"
122+
* @param string $public_path absolute public path, e.g. "/log"
123+
*/
124+
public function usePersistence(string $local_path, string $public_path)
125+
{
126+
if (! is_dir($local_path)) {
127+
throw new InvalidArgumentException("local path does not exist: {$local_path}");
128+
}
129+
130+
$this->local_path = $local_path;
131+
$this->public_path = $public_path;
132+
}
133+
95134
/**
96135
* Adds headers for recorded log-entries in the ChromeLogger format, and clear the internal log-buffer.
97136
*
@@ -104,11 +143,15 @@ public function getLimit()
104143
*/
105144
public function writeToResponse(ResponseInterface $response)
106145
{
107-
$value = $this->getHeaderValue();
146+
if ($this->local_path) {
147+
$response = $response->withHeader(self::LOCATION_HEADER_NAME, $this->createLogFile());
148+
} else {
149+
$response = $response->withHeader(self::HEADER_NAME, $this->getHeaderValue());
150+
}
108151

109152
$this->entries = [];
110153

111-
return $response->withHeader(self::HEADER_NAME, $value);
154+
return $response;
112155
}
113156

114157
/**
@@ -126,7 +169,11 @@ public function emitHeader()
126169
throw new RuntimeException("unable to emit ChromeLogger header: headers have already been sent");
127170
}
128171

129-
header(self::HEADER_NAME . ": " . $this->getHeaderValue());
172+
if ($this->local_path) {
173+
header(self::LOCATION_HEADER_NAME . ": " . $this->createLogFile());
174+
} else {
175+
header(self::HEADER_NAME . ": " . $this->getHeaderValue());
176+
}
130177

131178
$this->entries = [];
132179
}
@@ -165,6 +212,59 @@ protected function getHeaderValue()
165212
return $value;
166213
}
167214

215+
/**
216+
* @return string public path to log-file
217+
*/
218+
protected function createLogFile(): string
219+
{
220+
$this->collectGarbage();
221+
222+
$content = json_encode(
223+
$this->createData($this->entries),
224+
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
225+
);
226+
227+
$filename = $this->createUniqueFilename();
228+
229+
$local_path = "{$this->local_path}/{$filename}";
230+
231+
if (@file_put_contents($local_path, $content) === false) {
232+
throw new RuntimeException("unable to write log-file: {$local_path}");
233+
}
234+
235+
return "{$this->public_path}/{$filename}";
236+
}
237+
238+
/**
239+
* @return string pseudo-random log filename
240+
*/
241+
protected function createUniqueFilename(): string
242+
{
243+
return uniqid("log-", true) . ".json";
244+
}
245+
246+
/**
247+
* Garbage-collects log-files older than one minute.
248+
*/
249+
protected function collectGarbage()
250+
{
251+
foreach (glob("{$this->local_path}/*.json") as $path) {
252+
$age = $this->getTime() - filemtime($path);
253+
254+
if ($age > 60) {
255+
@unlink($path);
256+
}
257+
}
258+
}
259+
260+
/**
261+
* @return int
262+
*/
263+
protected function getTime(): int
264+
{
265+
return time();
266+
}
267+
168268
/**
169269
* Encodes the ChromeLogger-compatible data-structure in JSON/base64-format
170270
*

tests/unit/ChromeLoggerCest.php

+87
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22

33
namespace Kodus\Logging\Test\Unit;
44

5+
use Codeception\Util\FileSystem;
6+
use function json_decode;
57
use Kodus\Logging\ChromeLogger;
68
use Mockery;
79
use Mockery\MockInterface;
810
use Psr\Http\Message\ResponseInterface;
911
use RuntimeException;
12+
use function str_repeat;
1013
use UnitTester;
14+
use Zend\Diactoros\Response;
1115

1216
class Foo
1317
{
@@ -191,6 +195,89 @@ public function truncateExcessiveLogData(UnitTester $I)
191195
$I->assertEquals(ChromeLogger::LIMIT_WARNING, end($data["rows"])[0][0]);
192196
}
193197

198+
public function persistToLocalFiles(UnitTester $I)
199+
{
200+
$logger = new class extends ChromeLogger
201+
{
202+
private $time;
203+
204+
public function __construct()
205+
{
206+
// NOTE: no call to parent::_construct() here because PHP demands horrible code.
207+
208+
$this->time = time();
209+
}
210+
211+
public function skipTime(int $time)
212+
{
213+
$this->time += $time;
214+
}
215+
216+
protected function getTime(): int
217+
{
218+
return $this->time;
219+
}
220+
};
221+
222+
$local_path = dirname(__DIR__) . "/_output/log";
223+
224+
FileSystem::deleteDir($local_path);
225+
226+
mkdir($local_path);
227+
228+
$public_path = "/log";
229+
230+
$logger->setLimit(2048); // will be ignored!
231+
232+
$logger->usePersistence($local_path, $public_path);
233+
234+
$unique_locations = [];
235+
236+
$generate_log = function () use ($I, $logger, $local_path, &$unique_locations) {
237+
// write 20*20*10 = 4000 bytes (over the 2048 limit, which should be ignored)
238+
239+
$num_rows = 20;
240+
241+
for ($i=1; $i<= $num_rows; $i++) {
242+
$logger->debug(str_repeat("0123456789", 20));
243+
}
244+
245+
$response = new Response(fopen("php://temp", "rw+"));
246+
247+
$response = $logger->writeToResponse($response);
248+
249+
$location = $response->getHeaderLine("X-ServerLog-Location");
250+
251+
$unique_locations[$location] = true;
252+
253+
$I->assertRegExp('/^\/log\/log-.*\.json$/', $location);
254+
255+
$contents = file_get_contents("{$local_path}/" . basename($location));
256+
257+
$I->assertCount($num_rows, json_decode($contents, true)["rows"],
258+
"all rows should be written to file, despite being over the header size limit");
259+
};
260+
261+
$num_files = 2;
262+
263+
for ($i=1; $i<= $num_files; $i++) {
264+
$generate_log();
265+
}
266+
267+
$I->assertCount($num_files, $unique_locations,
268+
"every generated log should have a unique file-name");
269+
270+
$I->assertCount($num_files, glob("{$local_path}/*.json"),
271+
"no files should have been garbage-collected at this point");
272+
273+
$logger->skipTime(60 + 1); // log-files expire after 60 seconds
274+
275+
$generate_log();
276+
277+
$I->assertCount(1, glob("{$local_path}/*.json"),
278+
"previous {$num_files} log-files should be garbage-collected");
279+
}
280+
194281
/**
195282
* Extract data from the data-header created by a ChromeLogger instance.
196283
*

0 commit comments

Comments
 (0)