Skip to content

Commit 632c3ed

Browse files
committed
feature: send logs to sentry
1 parent 88302c1 commit 632c3ed

8 files changed

Lines changed: 382 additions & 59 deletions

File tree

README.md

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -90,41 +90,6 @@ It would be helpful if you could create your issue in the appropriate repository
9090
[contributing]: https://github.com/PhpGt/WebEngine/blob/master/CONTRIBUTING.md
9191
[issues]: https://github.com/PhpGt/WebEngine/issues
9292

93-
## Optional Sentry error reporting
94-
95-
Install `sentry/sentry` in your application (the `sentry/sdk` meta-package also
96-
provides it), then configure your project's `config.ini`:
97-
98-
```ini
99-
[sentry]
100-
dsn=https://YOUR_KEY@app.glitchtip.com/YOUR_PROJECT
101-
environment=production
102-
```
103-
104-
The DSN can point to Sentry or a compatible service such as GlitchTip.
105-
The optional `sentry.environment` is trimmed and included with reported errors.
106-
If missing, empty or whitespace-only, the SDK's default behavior applies:
107-
`SENTRY_ENVIRONMENT` if supplied by the server, otherwise `production`.
108-
109-
WebEngine initializes an SDK client in `Application::start()` when both the SDK
110-
and a nonempty DSN are available. No Sentry initialization in `setup.php` is necessary. Leave the
111-
DSN empty in environments that should not report errors.
112-
113-
Exceptions escaping request logic are reported before the normal error page or
114-
custom error script runs. Failures escaping error-page rendering are also
115-
reported. Expected HTTP responses below 500 are excluded. Reporting failures
116-
do not replace the application's error response. Exceptions caught and handled
117-
by application code still require explicit reporting if desired.
118-
119-
The reporter uses an injected `Sentry\ClientInterface` and PSR-7 request, without
120-
the SDK's global hub or default integrations. WebEngine reports fatal errors
121-
through its shutdown handler once the reporter is initialized. Errors before
122-
initialization are not captured. Performance tracing is not enabled.
123-
124-
Request context includes only the HTTP method and URL without credentials,
125-
query parameters or fragments. Headers, cookies and request bodies are omitted.
126-
Exception messages may still contain sensitive data; review what your application throws.
127-
12893
# Proudly sponsored by
12994

13095
[JetBrains Open Source sponsorship program](https://www.jetbrains.com/community/opensource/)

config.default.ini

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,12 @@ log_not_modified=false
3737
log_redirects=false
3838
ignore_post_fields=password,pass,passwd,password_confirm,password_confirmation,current_password,new_password,old_password,secret,client_secret,token,access_token,refresh_token,id_token,api_key,apikey,authorization,auth,bearer,otp,totp,mfa_code,verification_code,recovery_code,card_number,cc_number,credit_card,cvv,cvc,pin
3939
debug_to_javascript=true
40-
stderr_level=ERROR
40+
stderr_level=error
41+
; Comma-separated destinations: stdout,sentry (requires sentry.dsn and SDK).
4142
type=stdout
43+
; Levels (case-insensitive): debug,info,notice,warning,error,critical,alert,emergency.
44+
; One minimum level for all destinations, or comma-separated levels matching type order.
45+
; Example: type=sentry,stdout and level=error,debug.
4246
level=debug
4347
path=
4448
timestamp_format=Y-m-d H:i:s

src/Application.php

Lines changed: 68 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
use GT\WebEngine\Debug\OutputBuffer;
1616
use GT\WebEngine\Debug\Timer;
1717
use GT\WebEngine\Debug\SentryReporter;
18+
use GT\WebEngine\Debug\SentryLogHandler;
19+
use GT\WebEngine\Debug\LoggerConfigurationException;
1820
use GT\WebEngine\Redirection\Redirect;
1921
use GT\WebEngine\Redirection\RedirectUri;
2022
use GT\WebEngine\Dispatch\Dispatcher;
@@ -59,6 +61,7 @@ class Application {
5961
private static bool $loggerConfigured = false;
6062
private bool $finished = false;
6163
private ?SentryReporter $sentryReporter = null;
64+
private ?SentryLogHandler $sentryLogHandler = null;
6265

6366
/**
6467
* @param null|array<string, array<string, string>> $globals
@@ -99,6 +102,9 @@ public function __construct(
99102
], $globals ?? $GLOBALS);
100103
$this->globalProtection = $globalProtection ?? new Protection();
101104
register_shutdown_function($handleShutdown ?? $this->handleShutdown(...));
105+
if($this->sentryLogHandler) {
106+
register_shutdown_function($this->sentryLogHandler->flush(...));
107+
}
102108
}
103109

104110
public function start():void {
@@ -138,7 +144,7 @@ public function start():void {
138144

139145
// Initialise SDK options before protecting globals. Request context is injected
140146
// into the reporter, so reporting itself does not require global access.
141-
$this->sentryReporter ??= SentryReporter::create($this->config, $request);
147+
$this->initializeSentry();
142148
$this->protectGlobals();
143149

144150
// The Dispatcher is a core component responsible for:
@@ -263,6 +269,7 @@ private function finish(
263269

264270
$this->timer->stop();
265271
$this->timer->logDelta();
272+
$this->sentryLogHandler?->flush();
266273
}
267274

268275
private function protectGlobals():void {
@@ -312,22 +319,11 @@ private function loadConfig():Config {
312319
}
313320

314321
private function configureLoggerStreams():void {
322+
$destinations = $this->getLoggerDestinations();
315323
if(self::$loggerConfigured) {
316324
return;
317325
}
318326

319-
$minimumLogLevel = $this->getMinimumLogLevel();
320-
$minimumLogLevelIndex = array_search($minimumLogLevel, LogLevel::ALL_LEVELS, true);
321-
if($minimumLogLevelIndex === false) {
322-
return;
323-
}
324-
325-
$stderrMinLevel = $this->getStderrMinimumLogLevel();
326-
$stderrMinLevelIndex = array_search($stderrMinLevel, LogLevel::ALL_LEVELS, true);
327-
if($stderrMinLevelIndex === false) {
328-
return;
329-
}
330-
331327
if(!class_exists(StdErrHandler::class)) {
332328
return;
333329
}
@@ -338,7 +334,25 @@ private function configureLoggerStreams():void {
338334
return;
339335
}
340336

341-
LogConfig::setDefaultHandlerLevel($minimumLogLevel);
337+
LogConfig::setDefaultHandlerLevel($this->getMinimumLogLevel());
338+
foreach($destinations as $destination => $level) {
339+
if($destination === "sentry") {
340+
$this->sentryLogHandler = new SentryLogHandler();
341+
LogConfig::addHandler($this->sentryLogHandler, $level);
342+
}
343+
else {
344+
$this->configureLocalLogger($level);
345+
}
346+
}
347+
self::$loggerConfigured = true;
348+
}
349+
350+
private function configureLocalLogger(string $minimumLogLevel):void {
351+
$minimumLogLevelIndex = array_search($minimumLogLevel, LogLevel::ALL_LEVELS, true);
352+
$stderrMinLevelIndex = array_search($this->getStderrMinimumLogLevel(), LogLevel::ALL_LEVELS, true);
353+
if($minimumLogLevelIndex === false || $stderrMinLevelIndex === false) {
354+
return;
355+
}
342356

343357
if($stderrMinLevelIndex > $minimumLogLevelIndex) {
344358
$stdoutMaxLevel = LogLevel::ALL_LEVELS[$stderrMinLevelIndex - 1];
@@ -353,7 +367,13 @@ private function configureLoggerStreams():void {
353367
LogLevel::ALL_LEVELS[max($stderrMinLevelIndex, $minimumLogLevelIndex)],
354368
LogLevel::EMERGENCY,
355369
);
356-
self::$loggerConfigured = true;
370+
}
371+
372+
private function initializeSentry():void {
373+
$this->sentryReporter ??= SentryReporter::create($this->config, $this->request);
374+
if($this->sentryLogHandler) {
375+
$this->sentryReporter?->connectLogHandler($this->sentryLogHandler);
376+
}
357377
}
358378

359379
private function handleShutdown():void {
@@ -456,9 +476,9 @@ private function logErrorMessage(string $message, array $context = []):void {
456476
}
457477

458478
private function getStderrMinimumLogLevel():string {
459-
$configuredLevel = strtoupper(
479+
$configuredLevel = strtoupper(trim(
460480
$this->config->getString("logger.stderr_level") ?: LogLevel::ERROR
461-
);
481+
));
462482
if(in_array($configuredLevel, LogLevel::ALL_LEVELS, true)) {
463483
return $configuredLevel;
464484
}
@@ -467,16 +487,41 @@ private function getStderrMinimumLogLevel():string {
467487
}
468488

469489
private function getMinimumLogLevel():string {
470-
$configuredLevel = $this->config->getString("logger.level")
471-
?: LogLevel::DEBUG;
472-
$configuredLevel = strtoupper($configuredLevel);
473-
if(in_array($configuredLevel, LogLevel::ALL_LEVELS, true)) {
474-
return $configuredLevel;
490+
$levels = $this->getLoggerDestinations();
491+
foreach(LogLevel::ALL_LEVELS as $level) {
492+
if(in_array($level, $levels, true)) {
493+
return $level;
494+
}
475495
}
476-
477496
return LogLevel::DEBUG;
478497
}
479498

499+
/** @return array<string, string> */
500+
private function getLoggerDestinations():array {
501+
$types = array_map("trim", explode(",", strtolower($this->config->getString("logger.type") ?: "stdout")));
502+
$levels = array_map("trim", explode(",", strtoupper($this->config->getString("logger.level") ?: "debug")));
503+
if(count($levels) !== 1 && count($levels) !== count($types)) {
504+
throw new LoggerConfigurationException(
505+
"logger.level must contain one shared level or one level per logger.type destination; "
506+
. count($levels) . " levels supplied for " . count($types) . " destinations."
507+
);
508+
}
509+
$destinations = [];
510+
foreach($types as $index => $type) {
511+
$level = $levels[count($levels) === 1 ? 0 : $index];
512+
if(!in_array($level, LogLevel::ALL_LEVELS, true)) {
513+
$level = LogLevel::DEBUG;
514+
}
515+
// A repeated destination needs only one handler, at its lowest threshold.
516+
if(isset($destinations[$type]) && array_search($destinations[$type], LogLevel::ALL_LEVELS, true)
517+
< array_search($level, LogLevel::ALL_LEVELS, true)) {
518+
continue;
519+
}
520+
$destinations[$type] = $level;
521+
}
522+
return $destinations;
523+
}
524+
480525
/**
481526
* @return array<string, mixed>
482527
* @SuppressWarnings("PHPMD.Superglobals")
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?php
2+
namespace GT\WebEngine\Debug;
3+
4+
use GT\WebEngine\WebEngineException;
5+
6+
class LoggerConfigurationException extends WebEngineException {}

src/Debug/SentryLogHandler.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
namespace GT\WebEngine\Debug;
3+
4+
use GT\Logger\LogHandler\LogHandler;
5+
use Sentry\ClientInterface;
6+
use Sentry\Event;
7+
use Sentry\Logs\Log;
8+
use Sentry\Logs\LogLevel;
9+
use Sentry\Tracing\TraceId;
10+
use Throwable;
11+
12+
/** Bounded structured logging without the SDK's global hub. */
13+
class SentryLogHandler extends LogHandler {
14+
private const BATCH_SIZE = 100;
15+
/** @var array<array{level: string, message: string, timestamp: float}> */
16+
private array $pending = [];
17+
private ?ClientInterface $client = null;
18+
private ?string $traceId = null;
19+
20+
public function setClient(ClientInterface $client):void {
21+
$this->client = $client;
22+
}
23+
24+
/** @param array<string, mixed> $context */
25+
public function handle(string $level, string $message, array $context = []):void {
26+
// Context is deliberately excluded: arbitrary values can contain secrets.
27+
$this->pending []= [
28+
"level" => strtoupper($level),
29+
"message" => substr($message, 0, 8192),
30+
"timestamp" => microtime(true),
31+
];
32+
if(count($this->pending) >= self::BATCH_SIZE) {
33+
$this->flush();
34+
}
35+
}
36+
37+
public function flush():void {
38+
if(!$this->pending) {
39+
return;
40+
}
41+
$pending = $this->pending;
42+
$this->pending = [];
43+
try {
44+
if(!$this->client) {
45+
$this->fallback($pending);
46+
return;
47+
}
48+
$this->traceId ??= (string)TraceId::generate();
49+
$logs = [];
50+
foreach($pending as $entry) {
51+
$logs []= (new Log($entry["timestamp"], $this->traceId, $this->mapLevel($entry["level"]), $entry["message"]))
52+
->setAttribute("sentry.environment", $this->client->getOptions()->getEnvironment() ?? Event::DEFAULT_ENVIRONMENT)
53+
->setAttribute("logger.level", $entry["level"]);
54+
}
55+
if($this->client->captureEvent(Event::createLogs()->setLogs($logs)) === null) {
56+
$this->fallback($pending);
57+
}
58+
}
59+
catch(Throwable) {
60+
$this->fallback($pending);
61+
}
62+
}
63+
64+
private function mapLevel(string $level):LogLevel {
65+
return match($level) {
66+
"DEBUG" => LogLevel::debug(),
67+
"WARNING" => LogLevel::warn(),
68+
"ERROR" => LogLevel::error(),
69+
"CRITICAL", "ALERT", "EMERGENCY" => LogLevel::fatal(),
70+
default => LogLevel::info(),
71+
};
72+
}
73+
74+
/** @param array<array{level: string, message: string, timestamp: float}> $entries */
75+
private function fallback(array $entries):void {
76+
foreach($entries as $entry) {
77+
error_log("WebEngine: Sentry log delivery unavailable: {$entry['level']} {$entry['message']}");
78+
}
79+
}
80+
81+
/** @param array<string, mixed> $context */
82+
protected function unwrapContext(array $context):string {
83+
return "";
84+
}
85+
}

src/Debug/SentryReporter.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,8 @@ public function report(Throwable $throwable):void {
7777
error_log("WebEngine: Sentry exception reporting failed.");
7878
}
7979
}
80+
81+
public function connectLogHandler(SentryLogHandler $handler):void {
82+
$handler->setClient($this->client);
83+
}
8084
}

0 commit comments

Comments
 (0)