refactor(realtime): split error reporting — span for per-action, logger for ad-hoc

Spans model per-action operations (open/message/close); they're a poor fit for ad-hoc
events with no operation lifecycle. Split logError to dispatch by whether a span is
active:

- Active span (realtime.open / realtime.message / realtime.close catches) -> attach
  the error to the existing span; the Sentry span exporter ships it with full operation
  context (attributes, duration, trace_id).
- No active span (pub/sub subscriber, onStart, Swoole error handler, updateWorkerDocument)
  -> push a utopia/logger Log via the realtimeLogger registry. Goes to Sentry as an event
  via the Sentry logger adapter (or to logOwl / Raygun / AppSignal). Same dedicated
  Realtime project either way.

Restores the realtimeLogger registry (dropped in the previous "spans only" pass), inlines
the now-single-caller $createLogger closure into the logger registry, and drops the
recordRealtimeErrorSpan helper — logError is the only function on this path now.

Also registers a Pretty span exporter in app/init/realtime/span.php for non-self-hosted
editions so Realtime spans are visible in the container's stdout (on self-hosted the
existing app/init/span.php already provides it; gating avoids duplicate output).
This commit is contained in:
Prem Palanisamy
2026-05-13 09:32:21 +01:00
parent f451f507f6
commit 2c185d1a9e
3 changed files with 136 additions and 55 deletions
+11
View File
@@ -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;
});
}
+62 -20
View File
@@ -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();
+63 -35
View File
@@ -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));