diff --git a/app/init/realtime/span.php b/app/init/realtime/span.php index 236dd0de05..bc51086b1a 100644 --- a/app/init/realtime/span.php +++ b/app/init/realtime/span.php @@ -37,3 +37,14 @@ try { } catch (Throwable $error) { Console::warning('Failed to register Realtime Sentry span exporter: ' . $error->getMessage()); } + +// Print spans to stdout for local visibility on editions where app/init/span.php — which already +// installs a Pretty exporter — isn't loaded (it's gated on `_APP_EDITION === 'self-hosted'`). +if (System::getEnv('_APP_EDITION', 'self-hosted') !== 'self-hosted') { + Span::addExporter(new Exporter\Pretty(), function (Span $span): bool { + if (\str_starts_with($span->getAction(), 'listener.')) { + return $span->getError() !== null; + } + return true; + }); +} diff --git a/app/init/registers.php b/app/init/registers.php index 83c4b7a5f8..ff1d63f924 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -46,21 +46,20 @@ if (!Http::isProduction()) { PublicDomain::allow(['request-catcher-webhook']); } -/** - * Build a utopia/logger Logger from a logging DSN, or null when the config is empty/invalid. - * `$legacyProviderName` supplies the provider name for the pre-1.5.x `;`-delimited config format. - */ -$createLogger = static function (string $providerConfig, string $legacyProviderName = ''): ?Logger { +$register->set('logger', function () { + // Register error logger + $providerName = System::getEnv('_APP_LOGGING_PROVIDER', ''); + $providerConfig = System::getEnv('_APP_LOGGING_CONFIG', ''); + if (empty($providerConfig)) { - return null; + return; } - $providerName = $legacyProviderName; try { $loggingProvider = new DSN($providerConfig); $providerName = $loggingProvider->getScheme(); - $config = match ($providerName) { + $providerConfig = match ($providerName) { 'sentry' => ['key' => $loggingProvider->getPassword(), 'projectId' => $loggingProvider->getUser() ?? '', 'host' => 'https://' . $loggingProvider->getHost()], 'logowl' => ['ticket' => $loggingProvider->getUser() ?? '', 'host' => $loggingProvider->getHost()], default => ['key' => $loggingProvider->getHost()], @@ -70,7 +69,7 @@ $createLogger = static function (string $providerConfig, string $legacyProviderN Console::warning('Using deprecated logging configuration. Please update your configuration to use DSN format.' . $th->getMessage()); $configChunks = \explode(';', $providerConfig); - $config = match ($providerName) { + $providerConfig = match ($providerName) { 'sentry' => ['key' => $configChunks[0], 'projectId' => $configChunks[1] ?? '', 'host' => ''], 'logowl' => ['ticket' => $configChunks[0], 'host' => ''], default => ['key' => $providerConfig], @@ -78,7 +77,7 @@ $createLogger = static function (string $providerConfig, string $legacyProviderN } if (empty($providerName)) { - return null; + return; } if (!Logger::hasProvider($providerName)) { @@ -87,10 +86,10 @@ $createLogger = static function (string $providerConfig, string $legacyProviderN try { $adapter = match ($providerName) { - 'sentry' => new Sentry($config['projectId'], $config['key'], $config['host']), - 'logowl' => new LogOwl($config['ticket'], $config['host']), - 'raygun' => new Raygun($config['key']), - 'appsignal' => new AppSignal($config['key']), + 'sentry' => new Sentry($providerConfig['projectId'], $providerConfig['key'], $providerConfig['host']), + 'logowl' => new LogOwl($providerConfig['ticket'], $providerConfig['host']), + 'raygun' => new Raygun($providerConfig['key']), + 'appsignal' => new AppSignal($providerConfig['key']), default => null }; } catch (Throwable $th) { @@ -99,16 +98,59 @@ $createLogger = static function (string $providerConfig, string $legacyProviderN if ($adapter === null) { Console::error('Logging provider not supported. Logging is disabled'); - return null; + return; } return new Logger($adapter); -}; +}); -$register->set('logger', static fn () => $createLogger( - System::getEnv('_APP_LOGGING_CONFIG', ''), - System::getEnv('_APP_LOGGING_PROVIDER', ''), -)); +$register->set('realtimeLogger', function () { + // Realtime falls back to the default logging config when _APP_LOGGING_CONFIG_REALTIME isn't + // set. Used by app/realtime.php logError() for ad-hoc errors (pub/sub subscriber, onStart, + // worker stats, the Swoole server error handler) — i.e., the call sites that have no active + // span. Per-action errors (inside realtime.open / realtime.message / realtime.close) attach to + // the active span instead and ship via the Sentry span exporter (app/init/realtime/span.php). + $providerConfig = System::getEnv('_APP_LOGGING_CONFIG_REALTIME', '') ?: System::getEnv('_APP_LOGGING_CONFIG', ''); + + if (empty($providerConfig)) { + return; + } + + $loggingProvider = new DSN($providerConfig); + $providerName = $loggingProvider->getScheme(); + $providerConfig = match ($providerName) { + 'sentry' => ['key' => $loggingProvider->getPassword(), 'projectId' => $loggingProvider->getUser() ?? '', 'host' => 'https://' . $loggingProvider->getHost()], + 'logowl' => ['ticket' => $loggingProvider->getUser() ?? '', 'host' => $loggingProvider->getHost()], + default => ['key' => $loggingProvider->getHost()], + }; + + if (empty($providerName)) { + return; + } + + if (!Logger::hasProvider($providerName)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Logging provider not supported. Logging is disabled'); + } + + try { + $adapter = match ($providerName) { + 'sentry' => new Sentry($providerConfig['projectId'], $providerConfig['key'], $providerConfig['host']), + 'logowl' => new LogOwl($providerConfig['ticket'], $providerConfig['host']), + 'raygun' => new Raygun($providerConfig['key']), + 'appsignal' => new AppSignal($providerConfig['key']), + default => null + }; + } catch (Throwable $th) { + $adapter = null; + } + + if ($adapter === null) { + Console::error('Logging provider not supported. Logging is disabled'); + return; + } + + return new Logger($adapter); +}); $register->set('pools', function () { $group = new Group(); diff --git a/app/realtime.php b/app/realtime.php index 64c6470ea8..5487feccf1 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -35,6 +35,7 @@ use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\DI\Container; use Utopia\DSN\DSN; +use Utopia\Logger\Log; use Utopia\Pools\Group; use Utopia\Registry\Registry; use Utopia\Span\Span; @@ -285,46 +286,73 @@ $adapter $server = new Server($adapter); -if (!function_exists('recordRealtimeErrorSpan')) { - /** Record a Realtime error onto the active span, or a short-lived `realtime.error` span if there is none. */ - function recordRealtimeErrorSpan(Throwable $error, string $action, array $tags, ?Document $project, ?Document $user, ?Authorization $authorization): void - { - $span = Span::current(); - $ownsSpan = $span === null; - if ($ownsSpan) { - $span = Span::init('realtime.error'); - } - - $span->set('realtime.action', $action); - $span->set('error.code', $error->getCode()); - if ($project !== null && !$project->isEmpty()) { - $span->set('realtime.projectId', $project->getId()); - } - if ($user !== null && !$user->isEmpty()) { - $span->set('realtime.userId', $user->getId()); - } - if ($authorization !== null) { - $span->set('realtime.roles', \implode(',', $authorization->getRoles())); - } - foreach ($tags as $key => $value) { - if (\is_scalar($value) || $value === null) { - $span->set('realtime.' . $key, $value); - } - } - $span->setError($error); - - if ($ownsSpan) { - $span->finish(); - } - } -} - // Allows overriding if (!function_exists('logError')) { function logError(Throwable $error, string $action, array $tags = [], ?Document $project = null, ?Document $user = null, ?Authorization $authorization = null): void { if (!$error instanceof Exception) { - recordRealtimeErrorSpan($error, $action, $tags, $project, $user, $authorization); + $span = Span::current(); + if ($span !== null) { + // Per-action error: attach to the active realtime.open / realtime.message / realtime.close + // span. The Sentry span exporter in app/init/realtime/span.php ships it with the + // operation's full context (attributes, duration, trace_id). + $span->set('realtime.action', $action); + $span->set('error.code', $error->getCode()); + if ($project !== null && !$project->isEmpty()) { + $span->set('realtime.projectId', $project->getId()); + } + if ($user !== null && !$user->isEmpty()) { + $span->set('realtime.userId', $user->getId()); + } + if ($authorization !== null) { + $span->set('realtime.roles', \implode(',', $authorization->getRoles())); + } + foreach ($tags as $key => $value) { + if (\is_scalar($value) || $value === null) { + $span->set('realtime.' . $key, $value); + } + } + $span->setError($error); + } else { + // Ad-hoc error (pub/sub subscriber, onStart, the Swoole server error handler, + // updateWorkerDocument): not tied to a per-action span. Push to the realtimeLogger + // (Sentry / logOwl / Raygun / AppSignal — same dedicated Realtime project). + global $register; + $logger = $register->get('realtimeLogger'); + if ($logger) { + $log = new Log(); + $log->setNamespace('realtime'); + $log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname())); + $log->setVersion(System::getEnv('_APP_VERSION', 'UNKNOWN')); + $log->setType(Log::TYPE_ERROR); + $log->setMessage($error->getMessage()); + $log->setAction($action); + + $log->addTag('code', $error->getCode()); + $log->addTag('verboseType', get_class($error)); + $log->addTag('projectId', $project?->getId() ?: 'n/a'); + $log->addTag('userId', $user?->getId() ?: 'n/a'); + foreach ($tags as $key => $value) { + $log->addTag($key, $value ?: 'n/a'); + } + + $log->addExtra('file', $error->getFile()); + $log->addExtra('line', $error->getLine()); + $log->addExtra('trace', $error->getTraceAsString()); + $log->addExtra('detailedTrace', $error->getTrace()); + $log->addExtra('roles', $authorization?->getRoles() ?? []); + + $isProduction = System::getEnv('_APP_ENV', 'development') === 'production'; + $log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING); + + try { + $responseCode = $logger->addLog($log); + Console::info('Error log pushed with status code: ' . $responseCode); + } catch (Throwable $th) { + Console::error('Error pushing log: ' . $th->getMessage()); + } + } + } } Console::error('[Error] Type: ' . get_class($error));