Friday, February 28, 2025

How to Send Asynchronous Notification only with PHP


PHP's single-threaded nature often clashes with the need for real-time error reporting. Sending Sentry alerts synchronously—the default behavior of the sentry-php SDK—can lead to noticeable performance lags, especially in applications with frequent error logging. This article explores a clever workaround: achieving asynchronous alert delivery using PHP's capabilities in a way that minimizes latency and maximizes efficiency. We’ll transform the synchronous, blocking nature of traditional Sentry reporting into a streamlined, non-blocking process, keeping your application responsive even during peak error activity.

The core challenge lies in PHP's inherent limitations. Unlike JavaScript with its event loop and multi-threading, PHP executes code linearly. Directly implementing true asynchronous Sentry logging in PHP isn't possible. Instead, we leverage the power of Promises and curl_multi to mimic asynchronous behavior. This approach allows the application to continue its execution without waiting for the Sentry API call to complete, significantly reducing the impact on overall performance.

The traditional, synchronous method of sending Sentry alerts using the sentry-php SDK is straightforward:

// Initialize Sentry with your DSN
\Sentry\init([
    'dsn' => $_ENV['DSN'], // Ensure your DSN is properly configured, perhaps from a .env file
]);

// Capture a message; this blocks until the message is sent.
\Sentry\captureMessage('test', \Sentry\Severity::warning());

// ... other code execution ...
    

This seemingly simple call to \Sentry\captureMessage initiates a chain of events. Internally, the Sentry SDK uses PHP's cURL library to communicate with the Sentry server. Each call blocks execution until the server responds, causing delays that accumulate over time, especially under heavy load.

Let's examine a relevant snippet from Sentry's internal HttpClient class:

namespace Sentry\HttpClient;

class HttpClient implements HttpClientInterface
{
    public function sendRequest(Request $request, Options $options): Response
    {
        // ... (other code omitted for brevity) ...

        $curlHandle = curl_init();  // Initialize cURL resource

        // ... (setting request headers and cURL options) ...

        $body = curl_exec($curlHandle); // Blocking call – waits for server response
        curl_close($curlHandle); // Close cURL resource

        return new Response($statusCode, $responseHeaders, ''); // Return the response
    }
}
    

Notice the curl_exec() call; this is a blocking operation. The application freezes until the cURL request completes. This is the bottleneck we aim to bypass.

Our solution lies in creating a custom HTTP client that leverages the power of Promises and Guzzle, a robust HTTP client for PHP. This allows us to queue the Sentry requests and process them asynchronously using curl_multi.

Here's the custom SentryAsyncClientWrapper:

namespace Jun\PhpSentryExample;

use GuzzleHttp\Client;
use GuzzleHttp\Promise\Utils;
use GuzzleHttp\Psr7\Request as Psr7Request;
use Sentry\Client as SentryClient;
use Sentry\HttpClient\HttpClientInterface;
use Sentry\HttpClient\Request;
use Sentry\Options;
use Sentry\HttpClient\Response;

class SentryAsyncClientWrapper implements HttpClientInterface
{
    // ... (Singleton pattern implementation omitted for brevity) ...

    private Client $client; // Guzzle HTTP client instance
    private array $promises = []; // Array to store promises for asynchronous requests

    // ... (other properties omitted for brevity) ...

    public function sendRequest(Request $request, Options $options): Response
    {
        $dsn = $options->getDsn();
        if ($dsn === null) {
            throw new \RuntimeException('The DSN option must be set to use the HttpClient.');
        }

        $requestData = $request->getStringBody();
        if ($requestData === '') {
            throw new \RuntimeException('The request data is empty.');
        }

        // Prepare request headers, including authentication information
        $sentry_version = SentryClient::PROTOCOL_VERSION;
        $sentry_client = "{$this->sdkIdentifier}/{$this->sdkVersion}";
        $sentry_key = $dsn->getPublicKey();
        $requestHeaders['sentry_version'] = $sentry_version;
        $requestHeaders['sentry_client'] = $sentry_client;
        $requestHeaders['sentry_key'] = $sentry_key;
        $requestHeaders['Content-Type'] = 'application/x-sentry-envelope';

        // Build the authentication header
        $authHeader = [
            'sentry_version=' . $sentry_version,
            'sentry_client=' . $sentry_client,
            'sentry_key=' . $sentry_key,
        ];
        $requestHeaders['X-Sentry-Auth'] = 'Sentry ' . implode(', ', $authHeader);

        // Enable gzip compression if available and enabled in Sentry options
        if (\extension_loaded('zlib') && $options->isHttpCompressionEnabled()) {
            $requestData = gzcompress($requestData, -1, \ZLIB_ENCODING_GZIP);
            $requestHeaders['Content-Encoding'] = 'gzip';
        }

        // Create and queue the asynchronous request using Guzzle
        $this->promises[] = $this->client->sendAsync(new Psr7Request(
            'POST',
            $dsn->getEnvelopeApiEndpointUrl(),
            $requestHeaders,
            $requestData
        ))->then(
            function ($response) {
                // Success callback: Handle successful response
                echo 'Request succeeded: ' . $response->getBody() . PHP_EOL;
            },
            function ($reason) {
                // Failure callback: Handle request failures
                echo 'Request failed: ' . $reason . PHP_EOL;
            }
        );


        // Return an empty response; we don't wait for the actual response here
        return new Response(200, [], '');
    }

    // Method to wait for all queued requests to complete
    public function wait(): void
    {
        if (!empty($this->promises)) {
            Utils::settle($this->promises)->wait(); // Wait for all promises to resolve
            $this->promises = []; // Clear the promises array
        }
    }
}
    

The key difference lies in the use of GuzzleHttp\Client::sendAsync(). This method returns a Promise, allowing the application to continue executing while the request is handled in the background. The then() method defines callbacks for success and failure scenarios, allowing for appropriate handling of each outcome. The wait() method is crucial; it's called at a strategic point in the application lifecycle to ensure all pending Sentry alerts are sent.

Integrating this custom client into your Sentry setup is simple:

// ... (includes and setup) ...

$asyncClient = SentryAsyncClientWrapper::getInstance(new \GuzzleHttp\Client([
    'timeout' => 1, // Adjust timeout as needed
]));

// Initialize Sentry, specifying the asynchronous client
\Sentry\init([
    'dsn' => $_ENV['DSN'],
    'http_client' => $asyncClient,
]);

// Now, capture messages as usual
\Sentry\captureMessage('test', \Sentry\Severity::warning());
// ... additional Sentry calls ...

// ... (rest of your application logic) ...

// At the end of the request cycle, wait for all asynchronous requests to complete
$asyncClient->wait();
    

By replacing the default HttpClient with our SentryAsyncClientWrapper, we've transformed the error reporting into a significantly more efficient and responsive process. The wait() method ensures all pending requests are processed at a controlled time, typically just before the application terminates.

For frameworks like CodeIgniter4, integration is similar:

      use CodeIgniter\Events;
use Jun\PhpSentryExample\SentryAsyncClientWrapper;

Events::on('pre_system', static function () {
    // Initialize Sentry with the async client here; you may need adjustments based on your service provider setup
    service('sentry')->initialize(); 
});

// ... (Your application code) ...

Events::on('post_system', function () {
    // ... (Database closing, etc.) ...

    SentryAsyncClientWrapper::getInstance(new \GuzzleHttp\Client(['timeout' => 1]))->wait(); // Send all pending Sentry alerts
});
    

This example utilizes CodeIgniter's event system to trigger the wait() method at the appropriate point, which would be the post_system event. You might need adjustments based on your service provider configuration to correctly inject the async client to your Sentry service.

This approach allows for efficient asynchronous error handling, significantly improving application performance and responsiveness. Remember to adjust the timeout value in the Guzzle client according to your application's needs. By judiciously employing the power of Promises and Guzzle's curl_multi capabilities, we successfully sidestep PHP's single-threaded limitations to create a highly efficient asynchronous Sentry logging system.

0 comments:

Post a Comment