diff --git a/README.md b/README.md index 7dd0e72..460a060 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Specifically, the [PHP integration](https://posthog.com/docs/integrations/php-in ## Features - ✅ Event capture and user identification +- ✅ Error tracking with manual exception capture +- ✅ Opt-in automatic PHP exception, error, and fatal shutdown capture - ✅ Feature flag local evaluation - ✅ **Feature flag dependencies** (new!) - Create conditional flags based on other flags - ✅ Multivariate flags and payloads @@ -21,6 +23,40 @@ Specifically, the [PHP integration](https://posthog.com/docs/integrations/php-in 1. Copy `.env.example` to `.env` and add your PostHog credentials 2. Run `php example.php` to see interactive examples of all features +## Error Tracking + +Manual exception capture: + +```php +PostHog::captureException($exception, 'user-123', [ + '$current_url' => 'https://example.com/settings', +]); +``` + +Opt-in automatic capture from the core SDK: + +```php +PostHog::init('phc_xxx', [ + 'error_tracking' => [ + 'enabled' => true, + 'capture_errors' => true, + 'excluded_exceptions' => [ + \InvalidArgumentException::class, + ], + 'context_provider' => static function (array $payload): array { + return [ + 'distinctId' => $_SESSION['user_id'] ?? null, + 'properties' => [ + '$current_url' => $_SERVER['REQUEST_URI'] ?? null, + ], + ]; + }, + ], +]); +``` + +Auto error tracking is off by default. When enabled, the SDK chains existing exception and error handlers instead of replacing app behavior. + ## Questions? ### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ) diff --git a/example.php b/example.php index d78c208..eb6cb21 100644 --- a/example.php +++ b/example.php @@ -1,5 +1,7 @@ $_ENV['POSTHOG_HOST'] ?? 'https://app.posthog.com', + 'debug' => true, + 'ssl' => !str_starts_with($_ENV['POSTHOG_HOST'] ?? 'https://app.posthog.com', 'http://'), + 'error_tracking' => [ + 'enabled' => true, + 'context_provider' => static function (array $payload): array { + return [ + 'distinctId' => 'sdk-demo-user', + 'properties' => [ + '$error_source' => $payload['source'], + ], + ]; + }, + ], + ], + null, + $_ENV['POSTHOG_PERSONAL_API_KEY'] + ); + + echo "Auto capture enabled for uncaught exceptions, PHP errors, and fatal shutdown errors.\n"; + echo "The demo below still uses manual capture so it can finish without crashing the process.\n\n"; + + // 1. Capture a plain string error (no user context) + echo "1. Capturing anonymous string error...\n"; + PostHog::captureException('Something went wrong during startup'); + echo " -> sent with auto-generated distinct_id, \$process_person_profile=false\n\n"; + + // 2. Capture an exception for a known user + echo "2. Capturing exception for a known user...\n"; + try { + throw new \RuntimeException('Database connection failed'); + } catch (\RuntimeException $e) { + PostHog::captureException($e, 'user-123'); + } + echo " -> sent as \$exception event with stacktrace\n\n"; + + // 3. Capture with additional context properties + echo "3. Capturing exception with request context...\n"; + try { + throw new \InvalidArgumentException('Invalid email address provided'); + } catch (\InvalidArgumentException $e) { + PostHog::captureException($e, 'user-456', [ + '$current_url' => 'https://example.com/signup', + '$request_method' => 'POST', + 'form_field' => 'email', + ]); + } + echo " -> sent with URL and request context\n\n"; + + // 4. Capture a chained exception (cause + wrapper both appear in \$exception_list) + echo "4. Capturing chained exception...\n"; + try { + try { + throw new \PDOException('SQLSTATE[HY000]: General error: disk full'); + } catch (\PDOException $cause) { + throw new \RuntimeException('Failed to save user record', 0, $cause); + } + } catch (\RuntimeException $e) { + PostHog::captureException($e, 'user-789'); + } + echo " -> sent with 2 entries in \$exception_list (cause + wrapper)\n\n"; + + // 5. Capture a PHP Error (not just Exception) + echo "5. Capturing a TypeError (PHP Error subclass)...\n"; + try { + $result = array_sum('not-an-array'); + } catch (\TypeError $e) { + PostHog::captureException($e, 'user-123'); + } + echo " -> any Throwable (Error or Exception) is accepted\n\n"; + + PostHog::flush(); + echo "Flushed all events.\n"; + echo "Check your PostHog dashboard -> Error Tracking to see the captured exceptions.\n"; +} + function runAllExamples() { identifyAndCaptureExamples(); @@ -510,6 +610,9 @@ function runAllExamples() echo "\n" . str_repeat("-", 60) . "\n"; contextManagementExamples(); + echo "\n" . str_repeat("-", 60) . "\n"; + + errorTrackingExamples(); echo "\n🎉 All examples completed!\n"; echo " (ETag polling skipped - run separately with option 5)\n"; @@ -533,13 +636,16 @@ function runAllExamples() etagPollingExamples(); break; case '6': - runAllExamples(); + errorTrackingExamples(); break; case '7': + runAllExamples(); + break; + case '8': echo "👋 Goodbye!\n"; exit(0); default: - echo "❌ Invalid choice. Please run the script again and choose 1-7.\n"; + echo "❌ Invalid choice. Please run the script again and choose 1-8.\n"; exit(1); } diff --git a/lib/Client.php b/lib/Client.php index 6e1da6a..020579f 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -83,6 +83,11 @@ class Client */ private $debug; + /** + * @var array + */ + private $options; + /** * Create a new posthog object with your app's API key * key @@ -100,6 +105,7 @@ public function __construct( ) { $this->apiKey = $apiKey; $this->personalAPIKey = $personalAPIKey; + $this->options = $options; $this->debug = $options["debug"] ?? false; $Consumer = self::CONSUMERS[$options["consumer"] ?? "lib_curl"]; $this->consumer = new $Consumer($apiKey, $options, $httpClient); @@ -120,6 +126,8 @@ public function __construct( $this->distinctIdsFeatureFlagsReported = new SizeLimitedHash(SIZE_LIMIT); $this->flagsEtag = null; + ExceptionCapture::configure($this, $options['error_tracking'] ?? []); + // Populate featureflags and grouptypemapping if possible if ( count($this->featureFlags) == 0 @@ -155,7 +163,8 @@ public function capture(array $message) $flags = []; if (count($this->featureFlags) != 0) { - # Local evaluation is enabled, flags are loaded, so try and get all flags we can without going to the server + // Local evaluation is enabled, flags are loaded, so try and get all flags + // we can without going to the server. $flags = $this->getAllFlags($message["distinct_id"], $message["groups"], [], [], true); } else { $flags = $this->fetchFeatureVariants($message["distinct_id"], $message["groups"]); @@ -178,6 +187,51 @@ public function capture(array $message) return $this->consumer->capture($message); } + /** + * Captures an exception as a PostHog error tracking event. + * + * @param \Throwable|string $exception The exception to capture or a plain string message + * @param string|null $distinctId User ID; a random UUID is used when omitted (no person profile created) + * @param array $additionalProperties Extra properties merged into the event + * @return bool whether the capture call succeeded + */ + public function captureException( + \Throwable|string $exception, + ?string $distinctId = null, + array $additionalProperties = [] + ): bool { + $noDistinctIdProvided = $distinctId === null; + if ($noDistinctIdProvided) { + $distinctId = Uuid::v4(); + } + + $errorTrackingConfig = $this->options['error_tracking'] ?? []; + $maxFrames = max(0, (int) ($errorTrackingConfig['max_frames'] ?? 20)); + + $exceptionList = ExceptionPayloadBuilder::buildExceptionList($exception, $maxFrames); + if (empty($exceptionList)) { + return false; + } + + $properties = array_merge( + $additionalProperties, + [ + '$exception_list' => $exceptionList, + '$exception_handled' => ExceptionPayloadBuilder::getPrimaryHandled($exceptionList), + ] + ); + + if ($noDistinctIdProvided) { + $properties['$process_person_profile'] = false; + } + + return $this->capture([ + 'distinctId' => $distinctId, + 'event' => '$exception', + 'properties' => $properties, + ]); + } + /** * Tags properties about the user. * diff --git a/lib/ExceptionCapture.php b/lib/ExceptionCapture.php new file mode 100644 index 0000000..26da702 --- /dev/null +++ b/lib/ExceptionCapture.php @@ -0,0 +1,594 @@ + */ + private static array $options = []; + + private static bool $exceptionHandlerInstalled = false; + private static bool $errorHandlerInstalled = false; + private static bool $shutdownHandlerRegistered = false; + // Auto-capture itself can fail or trigger warnings; guard against recursively capturing + // PostHog's own error path. + private static bool $isCapturing = false; + private static bool $throwOnUnhandledInTests = false; + + /** @var callable|null */ + private static $previousExceptionHandler = null; + + /** @var callable|null */ + private static $previousErrorHandler = null; + + /** @var array */ + private static array $fatalErrorSignatures = []; + + /** @var array */ + private static array $delegatedErrorExceptionIds = []; + + /** + * @param array $config Contents of the 'error_tracking' options subkey. + */ + public static function configure(Client $client, array $config): void + { + $normalized = self::normalizeOptions($config); + + if (!$normalized['enabled']) { + return; + } + + if ( + self::hasInstalledHandlers() + && self::$client !== null + && self::$client !== $client + ) { + return; + } + + self::$client = $client; + self::$options = $normalized; + + if (!self::$exceptionHandlerInstalled) { + self::$previousExceptionHandler = set_exception_handler([self::class, 'handleException']); + self::$exceptionHandlerInstalled = true; + } + + if ($normalized['capture_errors'] && !self::$errorHandlerInstalled) { + self::$previousErrorHandler = set_error_handler([self::class, 'handleError']); + self::$errorHandlerInstalled = true; + } + + if ($normalized['capture_errors'] && !self::$shutdownHandlerRegistered) { + register_shutdown_function([self::class, 'handleShutdown']); + self::$shutdownHandlerRegistered = true; + } + } + + public static function handleException(\Throwable $exception): void + { + if (self::consumeDelegatedErrorException($exception)) { + self::finishUnhandledException($exception); + return; + } + + if (!self::shouldCapture()) { + self::finishUnhandledException($exception); + return; + } + + if (!self::shouldCaptureThrowable($exception)) { + self::finishUnhandledException($exception); + return; + } + + self::captureUncaughtException($exception); + self::flushSafely(); + self::finishUnhandledException($exception); + } + + public static function handleError( + int $errno, + string $message, + string $file = '', + int $line = 0 + ): bool { + if (!self::shouldCaptureErrors()) { + return self::delegateError($errno, $message, $file, $line); + } + + if (($errno & error_reporting()) === 0) { + return self::delegateError($errno, $message, $file, $line); + } + + if (in_array($errno, self::ERROR_HANDLER_DEFERRED_FATAL_TYPES, true)) { + // Fatal errors are handled from shutdown so we can flush at process end and avoid + // double-sending the same failure from both the error and shutdown handlers. + return self::delegateError($errno, $message, $file, $line); + } + + $maxFrames = self::$options['max_frames'] ?? 20; + $exception = new \ErrorException($message, 0, $errno, $file, $line); + $exceptionEntry = ExceptionPayloadBuilder::buildFromTrace( + $exception, + self::normalizeErrorHandlerTrace(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)), + $maxFrames + ); + + try { + $delegated = self::delegateError($errno, $message, $file, $line); + } catch (\Throwable $delegatedException) { + if ( + self::matchesErrorException( + $delegatedException, + $errno, + $message, + $file, + $line + ) + && self::shouldCaptureThrowable($exception) + ) { + self::rememberDelegatedErrorException($delegatedException); + self::captureErrorException( + $exception, + $errno, + 'error_handler', + 'php_error_handler', + ['type' => 'auto.error_handler', 'handled' => false], + [$exceptionEntry] + ); + + if ($errno === E_USER_ERROR) { + self::rememberFatalError($errno, $message, $file, $line); + self::flushSafely(); + } + } + + throw $delegatedException; + } + + $handled = $errno === E_USER_ERROR ? $delegated : true; + + if (self::shouldCaptureThrowable($exception)) { + self::captureErrorException( + $exception, + $errno, + 'error_handler', + 'php_error_handler', + ['type' => 'auto.error_handler', 'handled' => $handled], + [$exceptionEntry] + ); + + if (!$handled && $errno === E_USER_ERROR) { + self::rememberFatalError($errno, $message, $file, $line); + self::flushSafely(); + } + } + + return $delegated; + } + + /** + * @param array|null $lastError + */ + public static function handleShutdown(?array $lastError = null): void + { + if (!self::shouldCaptureErrors()) { + return; + } + + $lastError = $lastError ?? error_get_last(); + if (!is_array($lastError)) { + return; + } + + $severity = $lastError['type'] ?? null; + if (!is_int($severity) || !in_array($severity, self::SHUTDOWN_FATAL_ERROR_TYPES, true)) { + return; + } + + $message = is_string($lastError['message'] ?? null) ? $lastError['message'] : ''; + $file = is_string($lastError['file'] ?? null) ? $lastError['file'] : ''; + $line = is_int($lastError['line'] ?? null) ? $lastError['line'] : 0; + + if (self::isDuplicateFatalError($severity, $message, $file, $line)) { + return; + } + + $exception = new \ErrorException($message, 0, $severity, $file, $line); + + if (!self::shouldCaptureThrowable($exception)) { + return; + } + + self::rememberFatalError($severity, $message, $file, $line); + + $maxFrames = self::$options['max_frames'] ?? 20; + // error_get_last() gives us location data but not a useful application backtrace. Build a + // single location frame instead of using a fresh ErrorException trace, which would only + // show handleShutdown(). + $exceptionEntry = ExceptionPayloadBuilder::buildFromLocation( + \ErrorException::class, + $message, + $file !== '' ? $file : null, + $line !== 0 ? $line : null, + $maxFrames + ); + + self::captureErrorException( + $exception, + $severity, + 'shutdown_handler', + 'php_shutdown_handler', + ['type' => 'auto.shutdown_handler', 'handled' => false], + [$exceptionEntry] + ); + + self::flushSafely(); + } + + public static function resetForTests(): void + { + if (self::$exceptionHandlerInstalled) { + restore_exception_handler(); + self::$exceptionHandlerInstalled = false; + } + + if (self::$errorHandlerInstalled) { + restore_error_handler(); + self::$errorHandlerInstalled = false; + } + + self::$client = null; + self::$options = self::normalizeOptions([]); + self::$isCapturing = false; + self::$previousExceptionHandler = null; + self::$previousErrorHandler = null; + self::$fatalErrorSignatures = []; + self::$delegatedErrorExceptionIds = []; + self::$throwOnUnhandledInTests = false; + } + + public static function enableThrowOnUnhandledForTests(): void + { + self::$throwOnUnhandledInTests = true; + } + + private static function shouldCapture(): bool + { + return self::$options['enabled'] && self::$client !== null; + } + + private static function shouldCaptureErrors(): bool + { + return self::shouldCapture() && self::$options['capture_errors']; + } + + private static function shouldCaptureThrowable(\Throwable $exception): bool + { + foreach (self::$options['excluded_exceptions'] as $excludedClass) { + if ($exception instanceof $excludedClass) { + return false; + } + } + + return true; + } + + private static function callPreviousExceptionHandler(\Throwable $exception): bool + { + if (is_callable(self::$previousExceptionHandler)) { + call_user_func(self::$previousExceptionHandler, $exception); + return true; + } + + return false; + } + + private static function delegateError( + int $errno, + string $message, + string $file, + int $line + ): bool { + if (is_callable(self::$previousErrorHandler)) { + return (bool) call_user_func( + self::$previousErrorHandler, + $errno, + $message, + $file, + $line + ); + } + + return false; + } + + private static function captureUncaughtException(\Throwable $exception): void + { + $maxFrames = self::$options['max_frames'] ?? 20; + $exceptionList = ExceptionPayloadBuilder::buildExceptionList($exception, $maxFrames); + $exceptionList = ExceptionPayloadBuilder::overridePrimaryMechanism($exceptionList, [ + 'type' => 'auto.exception_handler', + 'handled' => false, + ]); + + self::sendExceptionEvent($exception, 'exception_handler', 'php_exception_handler', $exceptionList); + } + + /** + * @param array[] $exceptionList Exception entries wrapped in an array. + */ + private static function captureErrorException( + \Throwable $exception, + int $severity, + string $contextSource, + string $eventSource, + array $mechanism, + array $exceptionList + ): void { + $exceptionList = ExceptionPayloadBuilder::overridePrimaryMechanism($exceptionList, $mechanism); + self::sendExceptionEvent($exception, $contextSource, $eventSource, $exceptionList, $severity); + } + + /** + * Common capture path shared by captureUncaughtException and captureErrorException. + * + * @param array[] $exceptionList + */ + private static function sendExceptionEvent( + \Throwable $exception, + string $contextSource, + string $eventSource, + array $exceptionList, + ?int $severity = null + ): void { + if (self::$client === null || self::$isCapturing) { + return; + } + + self::$isCapturing = true; + + try { + $providerContext = self::getProviderContext([ + 'source' => $contextSource, + 'exception' => $exception, + 'severity' => $severity, + 'message' => $exception->getMessage(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + ]); + + $properties = [ + '$exception_list' => $exceptionList, + '$exception_handled' => ExceptionPayloadBuilder::getPrimaryHandled($exceptionList), + '$exception_source' => $eventSource, + ]; + + if ($severity !== null) { + $properties['$php_error_severity'] = $severity; + } + + $properties = array_merge($providerContext['properties'], $properties); + + $distinctId = $providerContext['distinctId']; + if ($distinctId === null) { + $distinctId = Uuid::v4(); + $properties['$process_person_profile'] = false; + } + + self::$client->capture([ + 'distinctId' => $distinctId, + 'event' => '$exception', + 'properties' => $properties, + ]); + } catch (\Throwable $captureError) { + // Ignore auto-capture failures to avoid interfering with app error handling. + } finally { + self::$isCapturing = false; + } + } + + /** + * @param array> $trace + * @return array> + */ + private static function normalizeErrorHandlerTrace(array $trace): array + { + while (!empty($trace)) { + $frame = $trace[0]; + $class = $frame['class'] ?? null; + $function = $frame['function'] ?? null; + + if ($class === self::class || $function === 'handleError') { + // debug_backtrace() starts inside the active error handler. Drop our own + // frames so the first frame shown to PostHog is the user callsite/trigger_error(). + array_shift($trace); + continue; + } + + break; + } + + return array_values(array_map(function (array $frame): array { + return array_filter([ + 'file' => is_string($frame['file'] ?? null) ? $frame['file'] : null, + 'line' => is_int($frame['line'] ?? null) ? $frame['line'] : null, + 'class' => is_string($frame['class'] ?? null) ? $frame['class'] : null, + 'type' => is_string($frame['type'] ?? null) ? $frame['type'] : null, + 'function' => is_string($frame['function'] ?? null) ? $frame['function'] : null, + ], static fn($value) => $value !== null); + }, $trace)); + } + + /** + * @param array $payload + * @return array{distinctId: ?string, properties: array} + */ + private static function getProviderContext(array $payload): array + { + $provider = self::$options['context_provider']; + if (!is_callable($provider)) { + return ['distinctId' => null, 'properties' => []]; + } + + try { + $result = $provider($payload); + } catch (\Throwable $providerError) { + return ['distinctId' => null, 'properties' => []]; + } + + if (!is_array($result)) { + return ['distinctId' => null, 'properties' => []]; + } + + $distinctId = $result['distinctId'] ?? null; + if ($distinctId !== null && !is_scalar($distinctId)) { + $distinctId = null; + } + + $properties = $result['properties'] ?? []; + if (!is_array($properties)) { + $properties = []; + } + + return [ + 'distinctId' => $distinctId !== null && $distinctId !== '' ? (string) $distinctId : null, + 'properties' => $properties, + ]; + } + + private static function flushSafely(): void + { + if (self::$client === null) { + return; + } + + try { + self::$client->flush(); + } catch (\Throwable $flushError) { + // Ignore flush failures during auto-capture. + } + } + + private static function isDuplicateFatalError( + int $severity, + string $message, + string $file, + int $line + ): bool { + $signature = self::errorSignature($severity, $message, $file, $line); + return isset(self::$fatalErrorSignatures[$signature]); + } + + private static function consumeDelegatedErrorException(\Throwable $exception): bool + { + $exceptionId = spl_object_id($exception); + + if (!isset(self::$delegatedErrorExceptionIds[$exceptionId])) { + return false; + } + + unset(self::$delegatedErrorExceptionIds[$exceptionId]); + + return true; + } + + private static function rememberFatalError( + int $severity, + string $message, + string $file, + int $line + ): void { + $signature = self::errorSignature($severity, $message, $file, $line); + self::$fatalErrorSignatures[$signature] = true; + } + + private static function rememberDelegatedErrorException(\Throwable $exception): void + { + self::$delegatedErrorExceptionIds[spl_object_id($exception)] = true; + } + + private static function errorSignature( + int $severity, + string $message, + string $file, + int $line + ): string { + return implode('|', [$severity, $file, $line, $message]); + } + + private static function matchesErrorException( + \Throwable $exception, + int $severity, + string $message, + string $file, + int $line + ): bool { + return $exception instanceof \ErrorException + && $exception->getSeverity() === $severity + && $exception->getMessage() === $message + && $exception->getFile() === $file + && $exception->getLine() === $line; + } + + private static function hasInstalledHandlers(): bool + { + return self::$exceptionHandlerInstalled + || self::$errorHandlerInstalled + || self::$shutdownHandlerRegistered; + } + + private static function finishUnhandledException(\Throwable $exception): void + { + if (self::callPreviousExceptionHandler($exception)) { + return; + } + + if (self::$throwOnUnhandledInTests) { + restore_exception_handler(); + throw $exception; + } + + // Once PHP has entered a user exception handler there is no safe way to resume the + // built-in uncaught-exception flow, so log the throwable and terminate explicitly. + error_log('Uncaught ' . $exception); + exit(255); + } + + /** + * @return array + */ + private static function normalizeOptions(array $config): array + { + return [ + 'enabled' => (bool) ($config['enabled'] ?? false), + 'capture_errors' => (bool) ($config['capture_errors'] ?? true), + 'excluded_exceptions' => array_values(array_filter( + is_array($config['excluded_exceptions'] ?? null) ? $config['excluded_exceptions'] : [], + fn($class) => is_string($class) && $class !== '' + )), + 'max_frames' => max(0, (int) ($config['max_frames'] ?? 20)), + 'context_provider' => is_callable($config['context_provider'] ?? null) + ? $config['context_provider'] + : null, + ]; + } +} diff --git a/lib/ExceptionPayloadBuilder.php b/lib/ExceptionPayloadBuilder.php new file mode 100644 index 0000000..fd57e1b --- /dev/null +++ b/lib/ExceptionPayloadBuilder.php @@ -0,0 +1,301 @@ +getPrevious(); + } + + return $chain; + } + + return []; + } + + /** + * Build a single exception entry from a Throwable using a custom trace. + */ + public static function buildFromTrace( + \Throwable $exception, + array $trace, + int $maxFrames = self::DEFAULT_MAX_FRAMES + ): array { + return self::buildSingleException( + get_class($exception), + $exception->getMessage(), + $trace, + $maxFrames + ); + } + + /** + * Build a single exception entry from type, message, and file/line location. + */ + public static function buildFromLocation( + string $type, + string $message, + ?string $file, + ?int $line, + int $maxFrames = self::DEFAULT_MAX_FRAMES + ): array { + $trace = null; + + if ($file !== null || $line !== null) { + $trace = [[ + 'file' => $file, + 'line' => $line, + ]]; + } + + return self::buildSingleException($type, $message, $trace, $maxFrames); + } + + /** + * Override the mechanism on the primary (first) exception in the list. + * + * @param array[] $exceptionList + * @param array $mechanism + * @return array[] + */ + public static function overridePrimaryMechanism(array $exceptionList, array $mechanism): array + { + if (!isset($exceptionList[0]) || !is_array($exceptionList[0])) { + return $exceptionList; + } + + $exceptionList[0]['mechanism'] = array_merge($exceptionList[0]['mechanism'] ?? [], $mechanism); + + return $exceptionList; + } + + /** + * Get the handled flag from the primary (first) exception. + * + * @param array[] $exceptionList + */ + public static function getPrimaryHandled(array $exceptionList): bool + { + return (bool) (($exceptionList[0]['mechanism']['handled'] ?? false) === true); + } + + private static function buildThrowableException(\Throwable $exception, int $maxFrames): array + { + return self::buildSingleException( + get_class($exception), + $exception->getMessage(), + self::normalizeThrowableTrace($exception), + $maxFrames + ); + } + + private static function normalizeThrowableTrace(\Throwable $exception): array + { + $trace = $exception->getTrace(); + + if (empty($trace)) { + return [[ + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + ]]; + } + + $firstFrameMatchesThrowSite = + ($trace[0]['file'] ?? null) === $exception->getFile() + && ($trace[0]['line'] ?? null) === $exception->getLine(); + + if ( + !$firstFrameMatchesThrowSite + && !self::isDeclarationLineForFirstFrame($exception, $trace[0]) + ) { + // Many PHP exceptions report the throw site in getFile()/getLine() but omit it + // from getTrace()[0]. Prepending a synthetic top frame keeps the first frame aligned + // with the highlighted source location in PostHog. + array_unshift($trace, array_filter([ + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'class' => $trace[0]['class'] ?? null, + 'type' => $trace[0]['type'] ?? null, + 'function' => $trace[0]['function'] ?? null, + ], fn($value) => $value !== null)); + } + + return $trace; + } + + private static function isDeclarationLineForFirstFrame(\Throwable $exception, array $firstFrame): bool + { + $function = $firstFrame['function'] ?? null; + $file = $exception->getFile(); + $line = $exception->getLine(); + + if (!is_string($function) || $function === '' || $file === '' || $line <= 0 || !is_readable($file)) { + return false; + } + + try { + $lines = file($file, FILE_IGNORE_NEW_LINES); + if ($lines === false || !isset($lines[$line - 1])) { + return false; + } + + $sourceLine = trim($lines[$line - 1]); + if ($sourceLine === '') { + return false; + } + + // Strict-types TypeErrors often point getFile()/getLine() at the callee declaration, + // while the trace already contains the real callsite as frame[0]. If we prepend a + // synthetic frame here, the stack looks reversed: declaration first, callsite second. + return (bool) preg_match( + '/\bfunction\b[^(]*\b' . preg_quote($function, '/') . '\s*\(/', + $sourceLine + ); + } catch (\Throwable $e) { + return false; + } + } + + private static function buildSingleException(string $type, string $message, ?array $trace, int $maxFrames): array + { + return [ + 'type' => $type, + 'value' => $message, + 'mechanism' => [ + 'type' => 'generic', + 'handled' => true, + ], + 'stacktrace' => self::buildStacktrace($trace, $maxFrames), + ]; + } + + private static function buildStacktrace(?array $trace, int $maxFrames): ?array + { + if (empty($trace)) { + return null; + } + + $frames = []; + + foreach (array_slice($trace, 0, $maxFrames) as $frame) { + $builtFrame = self::buildFrame($frame); + if ($builtFrame === null) { + continue; + } + + $frames[] = $builtFrame; + } + + $frames = array_values(array_filter($frames)); + + return [ + 'type' => 'raw', + 'frames' => $frames, + ]; + } + + private static function buildFrame(array $frame): ?array + { + $absPath = $frame['file'] ?? null; + $lineno = $frame['line'] ?? null; + $function = self::formatFunction($frame); + $inApp = $absPath !== null && !self::isVendorPath($absPath); + + $result = array_filter([ + 'filename' => $absPath !== null ? basename($absPath) : null, + 'abs_path' => $absPath, + 'lineno' => $lineno, + 'function' => $function, + 'in_app' => $inApp, + 'platform' => 'php', + ], fn($value) => $value !== null); + + if ($inApp && $absPath !== null && $lineno !== null) { + self::addContextLines($result, $absPath, $lineno); + } + + return $result; + } + + private static function formatFunction(array $frame): ?string + { + $function = $frame['function'] ?? null; + if ($function === null) { + return null; + } + + if (isset($frame['class'])) { + $type = $frame['type'] ?? '::'; + return $frame['class'] . $type . $function; + } + + return $function; + } + + private static function isVendorPath(string $path): bool + { + return str_contains($path, DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR) + || str_contains($path, '/vendor/'); + } + + private static function addContextLines(array &$frame, string $filePath, int $lineno): void + { + try { + if (!is_readable($filePath)) { + return; + } + + $lines = file($filePath, FILE_IGNORE_NEW_LINES); + if ($lines === false || empty($lines)) { + return; + } + + $total = count($lines); + $idx = $lineno - 1; // 0-based + + if ($idx < 0 || $idx >= $total) { + return; + } + + $frame['context_line'] = $lines[$idx]; + + $preStart = max(0, $idx - self::CONTEXT_LINES); + if ($preStart < $idx) { + $frame['pre_context'] = array_slice($lines, $preStart, $idx - $preStart); + } + + $postEnd = min($total, $idx + self::CONTEXT_LINES + 1); + if ($postEnd > $idx + 1) { + $frame['post_context'] = array_slice($lines, $idx + 1, $postEnd - $idx - 1); + } + } catch (\Throwable $e) { + // Silently ignore file read errors + } + } +} diff --git a/lib/PostHog.php b/lib/PostHog.php index 38255a8..8e93a54 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -56,6 +56,24 @@ public static function init( } } + /** + * Captures an exception as a PostHog error tracking event. + * + * @param \Throwable|string $exception + * @param string|null $distinctId + * @param array $additionalProperties + * @return bool + * @throws Exception + */ + public static function captureException( + \Throwable|string $exception, + ?string $distinctId = null, + array $additionalProperties = [] + ): bool { + self::checkClient(); + return self::$client->captureException($exception, $distinctId, $additionalProperties); + } + /** * Captures a user action * diff --git a/lib/Uuid.php b/lib/Uuid.php new file mode 100644 index 0000000..63c9fca --- /dev/null +++ b/lib/Uuid.php @@ -0,0 +1,21 @@ + diff --git a/test/ExceptionCaptureTest.php b/test/ExceptionCaptureTest.php new file mode 100644 index 0000000..0e2461b --- /dev/null +++ b/test/ExceptionCaptureTest.php @@ -0,0 +1,667 @@ +buildClient(['error_tracking' => ['enabled' => false]]); + + $this->assertFalse($this->getFlag('exceptionHandlerInstalled')); + $this->assertFalse($this->getFlag('errorHandlerInstalled')); + $this->assertSame($previousExceptionHandler, $this->getCurrentExceptionHandler()); + $this->assertSame($previousErrorHandler, $this->getCurrentErrorHandler()); + } finally { + restore_exception_handler(); + restore_error_handler(); + } + } + + public function testEnabledErrorTrackingRegistersHandlersOnce(): void + { + $previousExceptionHandler = static function (\Throwable $exception): void { + }; + $previousErrorHandler = static function (int $errno, string $message, string $file, int $line): bool { + return true; + }; + + set_exception_handler($previousExceptionHandler); + set_error_handler($previousErrorHandler); + + try { + $shutdownRegisteredBefore = $this->getFlag('shutdownHandlerRegistered'); + + $this->buildClient(['error_tracking' => ['enabled' => true]]); + $this->buildClient(['error_tracking' => ['enabled' => true]]); + + $this->assertTrue($this->getFlag('exceptionHandlerInstalled')); + $this->assertTrue($this->getFlag('errorHandlerInstalled')); + $this->assertSame( + [ExceptionCapture::class, 'handleException'], + $this->getCurrentExceptionHandler() + ); + $this->assertSame( + [ExceptionCapture::class, 'handleError'], + $this->getCurrentErrorHandler() + ); + $this->assertSame( + $previousExceptionHandler, + $this->getProperty('previousExceptionHandler') + ); + $this->assertSame( + $previousErrorHandler, + $this->getProperty('previousErrorHandler') + ); + $this->assertTrue($this->getFlag('shutdownHandlerRegistered')); + $this->assertTrue( + $shutdownRegisteredBefore || $this->getFlag('shutdownHandlerRegistered') + ); + } finally { + ExceptionCapture::resetForTests(); + restore_exception_handler(); + restore_error_handler(); + } + } + + public function testExceptionHandlerCapturesFlushesAndChainsPreviousHandler(): void + { + $previousCalls = 0; + $receivedException = null; + + $previousExceptionHandler = static function ( + \Throwable $exception + ) use ( + &$previousCalls, + &$receivedException + ): void { + $previousCalls++; + $receivedException = $exception; + }; + + set_exception_handler($previousExceptionHandler); + + try { + $this->buildClient(['error_tracking' => ['enabled' => true]]); + + $exception = new \RuntimeException('uncaught boom'); + ExceptionCapture::handleException($exception); + + $this->assertSame(1, $previousCalls); + $this->assertSame($exception, $receivedException); + + $event = $this->findExceptionEvent(); + + $this->assertSame('$exception', $event['event']); + $this->assertFalse($event['properties']['$exception_handled']); + $this->assertSame('php_exception_handler', $event['properties']['$exception_source']); + $this->assertSame( + ['type' => 'auto.exception_handler', 'handled' => false], + $event['properties']['$exception_list'][0]['mechanism'] + ); + $this->assertSame('RuntimeException', $event['properties']['$exception_list'][0]['type']); + $this->assertFalse($event['properties']['$process_person_profile']); + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $event['distinct_id'] + ); + } finally { + ExceptionCapture::resetForTests(); + restore_exception_handler(); + } + } + + public function testExceptionHandlerWithoutPreviousHandlerLogsAndExits(): void + { + $result = $this->runStandaloneScript(<<<'PHP' +$http = new \PostHog\Test\MockedHttpClient("app.posthog.com"); +new \PostHog\Client("key", ["debug" => true, "error_tracking" => ["enabled" => true]], $http, null, false); + +register_shutdown_function(static function () use ($http): void { + global $errorMessages; + + echo json_encode([ + 'calls' => $http->calls, + 'error_messages' => $errorMessages, + ], JSON_THROW_ON_ERROR); +}); + +throw new \RuntimeException('uncaught without previous'); +PHP, 255, false); + + $this->assertCount(1, $result['calls']); + $payload = json_decode($result['calls'][0]['payload'], true); + $event = $payload['batch'][0]; + $this->assertFalse($event['properties']['$exception_handled']); + $this->assertSame('php_exception_handler', $event['properties']['$exception_source']); + $this->assertNotEmpty($result['error_messages']); + $this->assertStringContainsString('uncaught without previous', $result['error_messages'][0]); + } + + public function testErrorHandlerCapturesNonFatalErrorsWithoutCaptureFrames(): void + { + $previousCalls = 0; + $previousErrorHandler = static function ( + int $errno, + string $message, + string $file, + int $line + ) use (&$previousCalls): bool { + $previousCalls++; + return true; + }; + + set_error_handler($previousErrorHandler); + $previousReporting = error_reporting(); + + try { + $this->buildClient(['error_tracking' => ['enabled' => true]]); + error_reporting(E_ALL); + + $triggerLine = 0; + $callSiteLine = __LINE__ + 1; + $this->triggerWarningHelper($triggerLine); + $this->client->flush(); + $event = $this->findExceptionEvent(); + $frames = $event['properties']['$exception_list'][0]['stacktrace']['frames']; + + $this->assertSame(1, $previousCalls); + $this->assertTrue($event['properties']['$exception_handled']); + $this->assertSame('php_error_handler', $event['properties']['$exception_source']); + $this->assertSame(E_USER_WARNING, $event['properties']['$php_error_severity']); + $this->assertSame( + ['type' => 'auto.error_handler', 'handled' => true], + $event['properties']['$exception_list'][0]['mechanism'] + ); + $this->assertSame('ErrorException', $event['properties']['$exception_list'][0]['type']); + $this->assertSame('trigger_error', $frames[0]['function']); + $this->assertSame(__FILE__, $frames[0]['abs_path']); + $this->assertSame($triggerLine, $frames[0]['lineno']); + $this->assertSame(__CLASS__ . '->triggerWarningHelper', $frames[1]['function']); + $this->assertSame(__FILE__, $frames[1]['abs_path']); + $this->assertSame($callSiteLine, $frames[1]['lineno']); + $this->assertFalse($this->framesContainFunction($frames, ExceptionCapture::class . '::handleError')); + } finally { + error_reporting($previousReporting); + ExceptionCapture::resetForTests(); + restore_error_handler(); + } + } + + public function testErrorHandlerRespectsRuntimeSuppression(): void + { + $previousCalls = 0; + $previousErrorHandler = static function ( + int $errno, + string $message, + string $file, + int $line + ) use (&$previousCalls): bool { + $previousCalls++; + return true; + }; + + set_error_handler($previousErrorHandler); + $previousReporting = error_reporting(); + + try { + $this->buildClient(['error_tracking' => ['enabled' => true]]); + + error_reporting(0); + $result = ExceptionCapture::handleError(E_USER_WARNING, 'suppressed', __FILE__, 321); + + $this->assertTrue($result); + $this->assertSame(1, $previousCalls); + $this->assertNull($this->findBatchCall()); + } finally { + error_reporting($previousReporting); + ExceptionCapture::resetForTests(); + restore_error_handler(); + } + } + + public function testShutdownHandlerCapturesFatalsAndFlushes(): void + { + $this->buildClient(['error_tracking' => ['enabled' => true]]); + + ExceptionCapture::handleShutdown([ + 'type' => E_ERROR, + 'message' => 'fatal boom', + 'file' => __FILE__, + 'line' => 456, + ]); + + $event = $this->findExceptionEvent(); + $frames = $event['properties']['$exception_list'][0]['stacktrace']['frames']; + + $this->assertFalse($event['properties']['$exception_handled']); + $this->assertSame('php_shutdown_handler', $event['properties']['$exception_source']); + $this->assertSame(E_ERROR, $event['properties']['$php_error_severity']); + $this->assertSame( + ['type' => 'auto.shutdown_handler', 'handled' => false], + $event['properties']['$exception_list'][0]['mechanism'] + ); + $this->assertCount(1, $frames); + $this->assertSame(__FILE__, $frames[0]['abs_path']); + $this->assertSame(456, $frames[0]['lineno']); + $this->assertArrayNotHasKey('function', $frames[0]); + } + + public function testFatalShutdownCaptureIsDeduplicatedAcrossErrorAndShutdownPaths(): void + { + $result = $this->runStandaloneScript(<<<'PHP' +set_error_handler(static function (int $errno, string $message, string $file, int $line): bool { + return false; +}); + +$http = new \PostHog\Test\MockedHttpClient("app.posthog.com"); +$client = new \PostHog\Client("key", ["debug" => true, "error_tracking" => ["enabled" => true]], $http, null, false); + +\PostHog\ExceptionCapture::handleError(E_USER_ERROR, 'fatal dedupe', __FILE__, 789); +\PostHog\ExceptionCapture::handleShutdown([ + 'type' => E_USER_ERROR, + 'message' => 'fatal dedupe', + 'file' => __FILE__, + 'line' => 789, +]); + +echo json_encode(['calls' => $http->calls], JSON_THROW_ON_ERROR); +PHP); + + $this->assertCount(1, $result['calls']); + $payload = json_decode($result['calls'][0]['payload'], true); + $event = $payload['batch'][0]; + $this->assertSame('php_error_handler', $event['properties']['$exception_source']); + $this->assertFalse($event['properties']['$exception_handled']); + } + + public function testExcludedExceptionsSkipCapture(): void + { + $previousErrorHandler = static function (int $errno, string $message, string $file, int $line): bool { + return true; + }; + + set_error_handler($previousErrorHandler); + + $this->buildClient([ + 'error_tracking' => [ + 'enabled' => true, + 'excluded_exceptions' => [\RuntimeException::class, \ErrorException::class], + ], + ]); + + try { + try { + ExceptionCapture::handleException(new \RuntimeException('skip me')); + $this->fail('Expected the excluded uncaught exception to be rethrown'); + } catch (\RuntimeException $caught) { + $this->assertSame('skip me', $caught->getMessage()); + } + ExceptionCapture::handleError(E_USER_WARNING, 'skip warning', __FILE__, 987); + $this->client->flush(); + + $this->assertNull($this->findBatchCall()); + } finally { + ExceptionCapture::resetForTests(); + restore_error_handler(); + } + } + + public function testContextProviderCanSupplyDistinctIdAndProperties(): void + { + $providerPayload = null; + + $this->buildClient([ + 'error_tracking' => [ + 'enabled' => true, + 'context_provider' => static function (array $payload) use (&$providerPayload): array { + $providerPayload = $payload; + + return [ + 'distinctId' => 'provider-user', + 'properties' => [ + '$current_url' => 'https://example.com/error', + 'job_name' => 'sync-users', + ], + ]; + }, + ], + ]); + + try { + ExceptionCapture::handleException(new \RuntimeException('provider boom')); + $this->fail('Expected the uncaught exception to be rethrown'); + } catch (\RuntimeException $caught) { + $this->assertSame('provider boom', $caught->getMessage()); + } + + $event = $this->findExceptionEvent(); + + $this->assertIsArray($providerPayload); + $this->assertSame('exception_handler', $providerPayload['source']); + $this->assertSame('provider-user', $event['distinct_id']); + $this->assertSame('https://example.com/error', $event['properties']['$current_url']); + $this->assertSame('sync-users', $event['properties']['job_name']); + $this->assertArrayNotHasKey('$process_person_profile', $event['properties']); + } + + public function testAutoCaptureOnlyOverridesPrimaryMechanismForChains(): void + { + $this->buildClient(['error_tracking' => ['enabled' => true]]); + + $exception = new \RuntimeException( + 'outer uncaught', + 0, + new \InvalidArgumentException('inner cause') + ); + + try { + ExceptionCapture::handleException($exception); + $this->fail('Expected the uncaught exception to be rethrown'); + } catch (\RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $event = $this->findExceptionEvent(); + $exceptionList = $event['properties']['$exception_list']; + + $this->assertFalse($event['properties']['$exception_handled']); + $this->assertSame('RuntimeException', $exceptionList[0]['type']); + $this->assertSame( + ['type' => 'auto.exception_handler', 'handled' => false], + $exceptionList[0]['mechanism'] + ); + $this->assertSame('InvalidArgumentException', $exceptionList[1]['type']); + $this->assertSame( + ['type' => 'generic', 'handled' => true], + $exceptionList[1]['mechanism'] + ); + } + + public function testLaterClientsDoNotStealInstalledAutoCaptureHandlers(): void + { + $firstHttpClient = new MockedHttpClient("app.posthog.com"); + $firstClient = new Client( + 'first-key', + ['debug' => true, 'error_tracking' => ['enabled' => true]], + $firstHttpClient, + null, + false + ); + + $secondHttpClient = new MockedHttpClient("eu.posthog.com"); + new Client( + 'second-key', + ['debug' => true, 'error_tracking' => ['enabled' => true], 'host' => 'eu.posthog.com'], + $secondHttpClient, + null, + false + ); + + try { + ExceptionCapture::handleException(new \RuntimeException('owner stays first')); + $this->fail('Expected the uncaught exception to be rethrown'); + } catch (\RuntimeException $caught) { + $this->assertSame('owner stays first', $caught->getMessage()); + } + + $firstBatchCalls = array_values(array_filter( + $firstHttpClient->calls ?? [], + static fn(array $call): bool => $call['path'] === '/batch/' + )); + $secondBatchCalls = array_values(array_filter( + $secondHttpClient->calls ?? [], + static fn(array $call): bool => $call['path'] === '/batch/' + )); + + $this->assertCount(1, $firstBatchCalls); + $this->assertCount(0, $secondBatchCalls); + + $payload = json_decode($firstBatchCalls[0]['payload'], true); + $this->assertSame('$exception', $payload['batch'][0]['event']); + + $firstClient->flush(); + } + + public function testWarningPromotedToErrorExceptionIsCapturedOnlyOnce(): void + { + $result = $this->runStandaloneScript(<<<'PHP' +set_error_handler(static function (int $errno, string $message, string $file, int $line): bool { + throw new \ErrorException($message, 0, $errno, $file, $line); +}); + +$http = new \PostHog\Test\MockedHttpClient("app.posthog.com"); +$client = new \PostHog\Client("key", ["debug" => true, "error_tracking" => ["enabled" => true]], $http, null, false); + +try { + \PostHog\ExceptionCapture::handleError(E_USER_WARNING, 'promoted warning', __FILE__, 612); +} catch (\Throwable $exception) { + try { + \PostHog\ExceptionCapture::handleException($exception); + } catch (\Throwable $ignored) { + } +} + +$client->flush(); +echo json_encode(['calls' => $http->calls], JSON_THROW_ON_ERROR); +PHP); + + $this->assertCount(1, $result['calls']); + $payload = json_decode($result['calls'][0]['payload'], true); + $event = $payload['batch'][0]; + $this->assertSame('php_error_handler', $event['properties']['$exception_source']); + $this->assertFalse($event['properties']['$exception_handled']); + } + + public function testUserErrorCanBeCapturedFromErrorHandlerWhenPreviousHandlerHandlesIt(): void + { + $result = $this->runStandaloneScript(<<<'PHP' +$previousCalls = 0; +set_error_handler(static function (int $errno, string $message, string $file, int $line) use (&$previousCalls): bool { + $previousCalls++; + return true; +}); + +$http = new \PostHog\Test\MockedHttpClient("app.posthog.com"); +$client = new \PostHog\Client("key", ["debug" => true, "error_tracking" => ["enabled" => true]], $http, null, false); +$handled = \PostHog\ExceptionCapture::handleError(E_USER_ERROR, 'handled user fatal', __FILE__, 733); +$client->flush(); + +echo json_encode([ + 'handled' => $handled, + 'previous_calls' => $previousCalls, + 'calls' => $http->calls, +], JSON_THROW_ON_ERROR); +PHP); + + $this->assertTrue($result['handled']); + $this->assertSame(1, $result['previous_calls']); + $this->assertCount(1, $result['calls']); + + $payload = json_decode($result['calls'][0]['payload'], true); + $event = $payload['batch'][0]; + + $this->assertSame('php_error_handler', $event['properties']['$exception_source']); + $this->assertTrue($event['properties']['$exception_handled']); + $this->assertSame( + ['type' => 'auto.error_handler', 'handled' => true], + $event['properties']['$exception_list'][0]['mechanism'] + ); + } + + private function buildClient(array $options): void + { + $this->httpClient = new MockedHttpClient("app.posthog.com"); + $this->client = new Client( + self::FAKE_API_KEY, + array_merge(['debug' => true], $options), + $this->httpClient, + null, + false + ); + } + + private function triggerWarningHelper(int &$triggerLine): void + { + $triggerLine = __LINE__ + 1; + trigger_error('warn', E_USER_WARNING); + } + + private function findBatchCall(): ?array + { + foreach ($this->httpClient->calls ?? [] as $call) { + if ($call['path'] === '/batch/') { + return $call; + } + } + + return null; + } + + /** + * @return array + */ + private function findExceptionEvent(): array + { + $batchCall = $this->findBatchCall(); + $this->assertNotNull($batchCall); + + $payload = json_decode($batchCall['payload'], true); + $this->assertIsArray($payload); + + return $payload['batch'][0]; + } + + private function getCurrentExceptionHandler(): callable|null + { + $probe = static function (\Throwable $exception): void { + }; + + $current = set_exception_handler($probe); + restore_exception_handler(); + + return $current; + } + + private function getCurrentErrorHandler(): callable|null + { + $probe = static function (int $errno, string $message, string $file, int $line): bool { + return true; + }; + + $current = set_error_handler($probe); + restore_error_handler(); + + return $current; + } + + private function getFlag(string $property): bool + { + return (bool) $this->getProperty($property); + } + + private function getProperty(string $property): mixed + { + $reflection = new \ReflectionClass(ExceptionCapture::class); + $propertyReflection = $reflection->getProperty($property); + $propertyReflection->setAccessible(true); + + return $propertyReflection->getValue(); + } + + /** + * @param array> $frames + */ + private function framesContainFunction(array $frames, string $function): bool + { + foreach ($frames as $frame) { + if (($frame['function'] ?? null) === $function) { + return true; + } + } + + return false; + } + + /** + * @return array + */ + private function runStandaloneScript( + string $body, + int $expectedExitCode = 0, + bool $throwOnUnhandledInTests = true + ): array { + $scriptPath = tempnam(sys_get_temp_dir(), 'posthog-error-tracking-'); + $this->assertNotFalse($scriptPath); + + $autoloadPath = var_export(realpath(__DIR__ . '/../vendor/autoload.php'), true); + $errorLogMockPath = var_export(realpath(__DIR__ . '/error_log_mock.php'), true); + $mockedHttpClientPath = var_export(realpath(__DIR__ . '/MockedHttpClient.php'), true); + $throwOnUnhandledBootstrap = $throwOnUnhandledInTests + ? "\n\\PostHog\\ExceptionCapture::enableThrowOnUnhandledForTests();" + : ''; + + $script = <<assertSame($expectedExitCode, $exitCode, implode("\n", $output)); + + $decoded = json_decode(implode("\n", $output), true); + $this->assertIsArray($decoded); + + return $decoded; + } finally { + unlink($scriptPath); + } + } +} diff --git a/test/ExceptionPayloadBuilderTest.php b/test/ExceptionPayloadBuilderTest.php new file mode 100644 index 0000000..3286a8d --- /dev/null +++ b/test/ExceptionPayloadBuilderTest.php @@ -0,0 +1,554 @@ +httpClient = new MockedHttpClient("app.posthog.com"); + $this->client = new Client( + self::FAKE_API_KEY, + ["debug" => true], + $this->httpClient, + "test" + ); + PostHog::init(null, null, $this->client); + + global $errorMessages; + $errorMessages = []; + } + + // ------------------------------------------------------------------------- + // ExceptionPayloadBuilder unit tests + // ------------------------------------------------------------------------- + + public function testBuildExceptionListFromString(): void + { + $result = ExceptionPayloadBuilder::buildExceptionList('something went wrong'); + + $this->assertIsArray($result); + $this->assertCount(1, $result); + $this->assertEquals('Error', $result[0]['type']); + $this->assertEquals('something went wrong', $result[0]['value']); + $this->assertEquals(['type' => 'generic', 'handled' => true], $result[0]['mechanism']); + $this->assertNull($result[0]['stacktrace']); + } + + public function testBuildExceptionListFromThrowable(): void + { + $exception = new \RuntimeException('test error'); + $result = ExceptionPayloadBuilder::buildExceptionList($exception); + + $this->assertIsArray($result); + $this->assertCount(1, $result); + + $entry = $result[0]; + $this->assertEquals('RuntimeException', $entry['type']); + $this->assertEquals('test error', $entry['value']); + $this->assertEquals(['type' => 'generic', 'handled' => true], $entry['mechanism']); + } + + public function testStacktraceFramesArePresent(): void + { + $exception = new \RuntimeException('with trace'); + $result = ExceptionPayloadBuilder::buildExceptionList($exception); + + $entry = $result[0]; + $this->assertNotNull($entry['stacktrace']); + $this->assertEquals('raw', $entry['stacktrace']['type']); + $this->assertNotEmpty($entry['stacktrace']['frames']); + } + + public function testStacktraceFrameStructure(): void + { + $exception = new \RuntimeException('frame check'); + $result = ExceptionPayloadBuilder::buildExceptionList($exception); + + $frames = $result[0]['stacktrace']['frames']; + $frame = $frames[0]; + + $this->assertArrayHasKey('filename', $frame); + $this->assertArrayHasKey('abs_path', $frame); + $this->assertArrayHasKey('lineno', $frame); + $this->assertArrayHasKey('function', $frame); + $this->assertArrayHasKey('in_app', $frame); + $this->assertEquals('php', $frame['platform']); + } + + public function testInAppFalseForVendorFrames(): void + { + $reflector = new \ReflectionClass(ExceptionPayloadBuilder::class); + $method = $reflector->getMethod('buildFrame'); + $method->setAccessible(true); + + $frame = $method->invoke(null, [ + 'file' => '/app/vendor/some/package/Foo.php', + 'line' => 10, + 'function' => 'doSomething', + ]); + + $this->assertFalse($frame['in_app']); + } + + public function testInAppTrueForAppFrames(): void + { + $reflector = new \ReflectionClass(ExceptionPayloadBuilder::class); + $method = $reflector->getMethod('buildFrame'); + $method->setAccessible(true); + + $frame = $method->invoke(null, [ + 'file' => '/app/src/Services/MyService.php', + 'line' => 42, + 'function' => 'handle', + ]); + + $this->assertTrue($frame['in_app']); + } + + public function testChainedExceptionsProduceMultipleEntries(): void + { + $cause = new \InvalidArgumentException('root cause'); + $outer = new \RuntimeException('wrapped', 0, $cause); + $result = ExceptionPayloadBuilder::buildExceptionList($outer); + + $this->assertCount(2, $result); + $this->assertEquals('RuntimeException', $result[0]['type']); + $this->assertEquals('InvalidArgumentException', $result[1]['type']); + } + + public function testReturnsEmptyArrayForInvalidInput(): void + { + $this->expectException(\TypeError::class); + ExceptionPayloadBuilder::buildExceptionList([]); + } + + public function testContextLinesAddedForInAppFrames(): void + { + // Throw inside a helper so the test file appears in getTrace() + $e = $this->throwHelper(); + $result = ExceptionPayloadBuilder::buildExceptionList($e); + + $frames = $result[0]['stacktrace']['frames']; + // Any in-app frame whose source file is readable should have context_line + $testFrames = array_filter($frames, fn($f) => isset($f['context_line'])); + $this->assertNotEmpty($testFrames, 'At least one in-app frame should have context_line'); + } + + public function testStacktraceUsesThrowableFileAndLineForMostRecentFrame(): void + { + [$exception, $throwLine] = $this->throwHelperWithKnownLine(); + $result = ExceptionPayloadBuilder::buildExceptionList($exception); + + $frames = $result[0]['stacktrace']['frames']; + $frame = $frames[0]; + + $this->assertEquals(__FILE__, $frame['abs_path']); + $this->assertEquals($throwLine, $frame['lineno']); + } + + public function testStacktracePreservesOriginalCallerFrame(): void + { + [$exception, $throwLine, $callerLine] = $this->nestedThrowHelperWithKnownLines(); + $result = ExceptionPayloadBuilder::buildExceptionList($exception); + + $frames = array_values($result[0]['stacktrace']['frames']); + $innermostFrame = $frames[0]; + $callerFrame = $frames[1]; + + $this->assertEquals(__FILE__, $innermostFrame['abs_path']); + $this->assertEquals($throwLine, $innermostFrame['lineno']); + $this->assertEquals(__FILE__, $callerFrame['abs_path']); + $this->assertEquals($callerLine, $callerFrame['lineno']); + $this->assertEquals(__CLASS__ . '->throwNestedHelper', $callerFrame['function']); + } + + public function testInternalFunctionErrorDoesNotDuplicateTopFrame(): void + { + [$exception, $arraySumLine, $callerLine] = $this->internalErrorHelperWithKnownLines(); + $result = ExceptionPayloadBuilder::buildExceptionList($exception); + + $frames = array_values($result[0]['stacktrace']['frames']); + + $this->assertEquals('array_sum', $frames[0]['function']); + $this->assertEquals(__FILE__, $frames[0]['abs_path']); + $this->assertEquals($arraySumLine, $frames[0]['lineno']); + $this->assertEquals(__CLASS__ . '->internalErrorLeaf', $frames[1]['function']); + $this->assertEquals(__FILE__, $frames[1]['abs_path']); + $this->assertEquals($callerLine, $frames[1]['lineno']); + $this->assertNotEquals($frames[0], $frames[1]); + } + + public function testStrictTypeErrorUsesCallsiteBeforeDeclaration(): void + { + $scriptPath = tempnam(sys_get_temp_dir(), 'posthog-type-error-'); + $this->assertNotFalse($scriptPath); + + $script = <<<'PHP' +assertInstanceOf(\Throwable::class, $exception); + + $result = ExceptionPayloadBuilder::buildExceptionList($exception); + $frames = array_values($result[0]['stacktrace']['frames']); + + $this->assertSame($scriptPath, $frames[0]['abs_path']); + $this->assertSame('requiresIntForTrace', $frames[0]['function']); + $this->assertSame($callLine, $frames[0]['lineno']); + $this->assertNotSame($declarationLine, $frames[0]['lineno']); + } finally { + unlink($scriptPath); + } + } + + public function testFrameLimitCanBeConfigured(): void + { + $exceptionList = ExceptionPayloadBuilder::buildExceptionList( + $this->nestedExceptionHelper(4), + 2 + ); + + $frames = $exceptionList[0]['stacktrace']['frames']; + + $this->assertCount(2, $frames); + $this->assertArrayHasKey('context_line', $frames[0]); + $this->assertArrayHasKey('context_line', $frames[1]); + } + + private function throwHelper(): \RuntimeException + { + try { + throw new \RuntimeException('context test'); + } catch (\RuntimeException $e) { + return $e; + } + } + + private function throwHelperWithKnownLine(): array + { + try { + $throwLine = __LINE__ + 1; + throw new \RuntimeException('known line'); + } catch (\RuntimeException $e) { + return [$e, $throwLine]; + } + } + + private function nestedThrowHelperWithKnownLines(): array + { + try { + $throwLine = 0; + $callerLine = __LINE__ + 1; + $this->throwNestedHelper($throwLine); + } catch (\RuntimeException $e) { + return [$e, $throwLine, $callerLine]; + } + } + + private function throwNestedHelper(int &$throwLine): never + { + $throwLine = __LINE__ + 1; + throw new \RuntimeException('nested known line'); + } + + private function internalErrorHelperWithKnownLines(): array + { + try { + $arraySumLine = 0; + $callerLine = __LINE__ + 1; + $this->internalErrorLeaf($arraySumLine); + } catch (\TypeError $e) { + return [$e, $arraySumLine, $callerLine]; + } + } + + private function internalErrorLeaf(int &$arraySumLine): void + { + $arraySumLine = __LINE__ + 1; + array_sum('not-an-array'); + } + + private function nestedExceptionHelper(int $depth): \RuntimeException + { + try { + $this->nestedThrow($depth); + } catch (\RuntimeException $exception) { + return $exception; + } + } + + private function nestedThrow(int $depth): never + { + if ($depth === 0) { + throw new \RuntimeException('depth reached'); + } + + $this->nestedThrow($depth - 1); + } + + public function testFunctionIncludesClass(): void + { + $reflector = new \ReflectionClass(ExceptionPayloadBuilder::class); + $method = $reflector->getMethod('buildFrame'); + $method->setAccessible(true); + + $frame = $method->invoke(null, [ + 'file' => '/app/src/Foo.php', + 'line' => 1, + 'class' => 'App\\Foo', + 'type' => '->', + 'function' => 'bar', + ]); + + $this->assertEquals('App\\Foo->bar', $frame['function']); + } + + // ------------------------------------------------------------------------- + // Client::captureException integration tests + // ------------------------------------------------------------------------- + + public function testCaptureExceptionSendsExceptionEvent(): void + { + $this->executeAtFrozenDateTime(new \DateTime('2024-01-01'), function () { + $exception = new \RuntimeException('boom'); + $result = $this->client->captureException($exception, 'user-123'); + + $this->assertTrue($result); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $this->assertNotNull($batchCall); + + $payload = json_decode($batchCall['payload'], true); + $event = $payload['batch'][0]; + + $this->assertEquals('$exception', $event['event']); + $this->assertEquals('user-123', $event['distinct_id']); + $this->assertArrayHasKey('$exception_list', $event['properties']); + $this->assertTrue($event['properties']['$exception_handled']); + $this->assertCount(1, $event['properties']['$exception_list']); + $this->assertEquals('RuntimeException', $event['properties']['$exception_list'][0]['type']); + $this->assertEquals('boom', $event['properties']['$exception_list'][0]['value']); + }); + } + + public function testCaptureExceptionUsesOuterExceptionAsPrimaryForChains(): void + { + $cause = new \InvalidArgumentException('root cause'); + $outer = new \RuntimeException('wrapped', 0, $cause); + + $this->client->captureException($outer, 'user-chain'); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $payload = json_decode($batchCall['payload'], true); + $props = $payload['batch'][0]['properties']; + + $this->assertTrue($props['$exception_handled']); + $this->assertSame('RuntimeException', $props['$exception_list'][0]['type']); + $this->assertSame('wrapped', $props['$exception_list'][0]['value']); + $this->assertSame('InvalidArgumentException', $props['$exception_list'][1]['type']); + $this->assertSame('root cause', $props['$exception_list'][1]['value']); + } + + public function testCaptureExceptionWithoutDistinctIdGeneratesUuidAndSetsNoProfile(): void + { + $this->client->captureException(new \Exception('anon error')); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $payload = json_decode($batchCall['payload'], true); + $event = $payload['batch'][0]; + + // distinct_id should look like a UUID + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $event['distinct_id'] + ); + $this->assertFalse($event['properties']['$process_person_profile']); + } + + public function testCaptureExceptionWithDistinctIdDoesNotSetNoProfile(): void + { + $this->client->captureException(new \Exception('known user'), 'user-456'); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $payload = json_decode($batchCall['payload'], true); + $event = $payload['batch'][0]; + + $this->assertEquals('user-456', $event['distinct_id']); + $this->assertArrayNotHasKey('$process_person_profile', $event['properties']); + } + + public function testCaptureExceptionMergesAdditionalProperties(): void + { + $this->client->captureException( + new \Exception('ctx error'), + 'user-789', + ['$current_url' => 'https://example.com', 'custom_key' => 'custom_value'] + ); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $payload = json_decode($batchCall['payload'], true); + $props = $payload['batch'][0]['properties']; + + $this->assertEquals('https://example.com', $props['$current_url']); + $this->assertEquals('custom_value', $props['custom_key']); + $this->assertArrayHasKey('$exception_list', $props); + } + + public function testCaptureExceptionReservedPropertiesCannotOverrideExceptionPayload(): void + { + $this->client->captureException( + new \RuntimeException('real error'), + 'user-protected', + [ + '$exception_list' => [['type' => 'FakeException', 'value' => 'fake']], + '$exception_handled' => false, + ] + ); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $payload = json_decode($batchCall['payload'], true); + $props = $payload['batch'][0]['properties']; + + $this->assertSame('RuntimeException', $props['$exception_list'][0]['type']); + $this->assertSame('real error', $props['$exception_list'][0]['value']); + $this->assertTrue($props['$exception_handled']); + } + + public function testCaptureExceptionFromString(): void + { + $this->client->captureException('a plain string error', 'user-str'); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $payload = json_decode($batchCall['payload'], true); + $props = $payload['batch'][0]['properties']; + + $this->assertEquals('Error', $props['$exception_list'][0]['type']); + $this->assertEquals('a plain string error', $props['$exception_list'][0]['value']); + } + + public function testCaptureExceptionReturnsFalseForInvalidInput(): void + { + $this->expectException(\TypeError::class); + $this->client->captureException([]); + } + + public function testCaptureExceptionPayloadStaysBelowCurrentTransportLimit(): void + { + $scriptPath = tempnam(sys_get_temp_dir(), 'posthog-exception-'); + $this->assertNotFalse($scriptPath); + + $longLine = '$junk = \'' . str_repeat('x', 2000) . '\';'; + $script = <<assertInstanceOf(\Throwable::class, $exception); + + $exceptionList = ExceptionPayloadBuilder::buildExceptionList($exception); + $payload = json_encode([ + 'batch' => [[ + 'event' => '$exception', + 'properties' => ['$exception_list' => $exceptionList], + 'distinct_id' => 'user-123', + 'library' => 'posthog-php', + 'library_version' => PostHog::VERSION, + 'library_consumer' => 'LibCurl', + 'groups' => [], + 'timestamp' => date('c'), + 'type' => 'capture', + ]], + 'api_key' => self::FAKE_API_KEY, + ]); + + $this->assertNotFalse($payload); + $this->assertLessThan(1024 * 1024, strlen($payload)); + } finally { + unlink($scriptPath); + } + } + + public function testPostHogFacadeCaptureException(): void + { + $result = PostHog::captureException(new \Exception('facade test'), 'facade-user'); + $this->assertTrue($result); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function findBatchCall(): ?array + { + foreach ($this->httpClient->calls as $call) { + if ($call['path'] === '/batch/') { + return $call; + } + } + return null; + } +}