-
-
Notifications
You must be signed in to change notification settings - Fork 52
/
AccessLogHandler.php
140 lines (121 loc) · 4.92 KB
/
AccessLogHandler.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
<?php
namespace FrameworkX;
use FrameworkX\Io\LogStreamHandler;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use React\Http\Message\Response;
use React\Promise\PromiseInterface;
use React\Stream\ReadableStreamInterface;
/**
* @final
*/
class AccessLogHandler
{
/** @var ?LogStreamHandler */
private $logger;
/** @var bool */
private $hasHighResolution;
/**
* @param ?string $path (optional) absolute log file path or will log to console output by default
* @throws \InvalidArgumentException if given `$path` is not an absolute file path
* @throws \RuntimeException if given `$path` can not be opened in append mode
*/
public function __construct(?string $path = null)
{
if ($path === null) {
$path = \PHP_SAPI === 'cli' ? 'php://output' : 'php://stderr';
}
$logger = new LogStreamHandler($path);
if (!$logger->isDevNull()) {
// only assign logger if we're not logging to /dev/null (which would discard any logs)
$this->logger = $logger;
}
$this->hasHighResolution = \function_exists('hrtime'); // PHP 7.3+
}
/**
* [Internal] Returns whether we're writing to /dev/null (which will discard any logs)
*
* @internal
* @return bool
*/
public function isDevNull(): bool
{
return $this->logger === null;
}
/**
* @return ResponseInterface|PromiseInterface<ResponseInterface>|\Generator
*/
public function __invoke(ServerRequestInterface $request, callable $next)
{
if ($this->logger === null) {
// Skip if we're logging to /dev/null (which will discard any logs).
// As an additional optimization, the `App` will automatically
// detect we no longer need to invoke this instance at all.
return $next($request); // @codeCoverageIgnore
}
$now = $this->now();
$response = $next($request);
if ($response instanceof PromiseInterface) {
/** @var PromiseInterface<ResponseInterface> $response */
return $response->then(function (ResponseInterface $response) use ($request, $now) {
$this->logWhenClosed($request, $response, $now);
return $response;
});
} elseif ($response instanceof \Generator) {
return (function (\Generator $generator) use ($request, $now) {
$response = yield from $generator;
$this->logWhenClosed($request, $response, $now);
return $response;
})($response);
} else {
$this->logWhenClosed($request, $response, $now);
return $response;
}
}
/**
* checks if response body is closed (not streaming) before writing log message for response
*/
private function logWhenClosed(ServerRequestInterface $request, ResponseInterface $response, float $start): void
{
$body = $response->getBody();
if ($body instanceof ReadableStreamInterface && $body->isReadable()) {
$size = 0;
$body->on('data', function (string $chunk) use (&$size) {
$size += strlen($chunk);
});
$body->on('close', function () use (&$size, $request, $response, $start) {
$this->log($request, $response, $size, $this->now() - $start);
});
} else {
$this->log($request, $response, $body->getSize() ?? strlen((string) $body), $this->now() - $start);
}
}
/**
* writes log message for response after response body is closed (not streaming anymore)
*/
private function log(ServerRequestInterface $request, ResponseInterface $response, int $responseSize, float $time): void
{
$method = $request->getMethod();
$status = $response->getStatusCode();
// HEAD requests and `204 No Content` and `304 Not Modified` always use an empty response body
if ($method === 'HEAD' || $status === Response::STATUS_NO_CONTENT || $status === Response::STATUS_NOT_MODIFIED) {
$responseSize = 0;
}
\assert($this->logger instanceof LogStreamHandler);
$this->logger->log(
($request->getAttribute('remote_addr') ?? $request->getServerParams()['REMOTE_ADDR'] ?? '-') . ' ' .
'"' . $this->escape($method) . ' ' . $this->escape($request->getRequestTarget()) . ' HTTP/' . $request->getProtocolVersion() . '" ' .
$status . ' ' . $responseSize . ' ' . sprintf('%.3F', $time < 0 ? 0 : $time)
);
}
private function escape(string $s): string
{
return (string) preg_replace_callback('/[\x00-\x1F\x7F-\xFF"\\\\]+/', function (array $m) {
return str_replace('%', '\x', rawurlencode($m[0]));
}, $s);
}
private function now(): float
{
return $this->hasHighResolution ? hrtime(true) * 1e-9 : microtime(true);
}
}