diff --git a/.changeset/brave-turkeys-serve.md b/.changeset/brave-turkeys-serve.md new file mode 100644 index 0000000..2dbf727 --- /dev/null +++ b/.changeset/brave-turkeys-serve.md @@ -0,0 +1,5 @@ +--- +"posthog-php": minor +--- + +Add request context helpers for propagating request metadata and optional PostHog tracing headers. diff --git a/examples/request-context-laravel-middleware.php b/examples/request-context-laravel-middleware.php new file mode 100644 index 0000000..9fa43a2 --- /dev/null +++ b/examples/request-context-laravel-middleware.php @@ -0,0 +1,46 @@ +useTracingHeaders ?? (bool) config('posthog.use_tracing_headers', true); + $context = $useTracingHeaders + ? PostHog::contextFromHeaders($request->headers->all()) + : []; + + $context['properties'] = array_merge( + $context['properties'] ?? [], + array_filter( + [ + '$current_url' => $request->fullUrl(), + '$request_method' => $request->method(), + '$request_path' => $request->getPathInfo(), + '$user_agent' => $request->userAgent(), + '$ip' => $request->ip(), + ], + static fn($value): bool => $value !== null && $value !== '' + ) + ); + + return PostHog::withContext($context, static fn(): Response => $next($request), ['fresh' => true]); + } +} diff --git a/examples/request-context-symfony-kernel.php b/examples/request-context-symfony-kernel.php new file mode 100644 index 0000000..46b49aa --- /dev/null +++ b/examples/request-context-symfony-kernel.php @@ -0,0 +1,63 @@ +useTracingHeaders + ? PostHog::contextFromHeaders($request->headers->all()) + : []; + + $context['properties'] = array_merge( + $context['properties'] ?? [], + array_filter( + [ + '$current_url' => $request->getUri(), + '$request_method' => $request->getMethod(), + '$request_path' => $request->getPathInfo(), + '$user_agent' => $request->headers->get('user-agent'), + '$ip' => $request->getClientIp(), + ], + static fn($value): bool => $value !== null && $value !== '' + ) + ); + + return PostHog::withContext( + $context, + fn(): Response => $this->innerKernel->handle($request, $type, $catch), + ['fresh' => true] + ); + } +} diff --git a/lib/Client.php b/lib/Client.php index f3e075f..768f7a4 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -88,6 +88,11 @@ class Client implements FeatureFlagEvaluationsHost */ private $options; + /** + * @var array + */ + private array $missingDistinctIdWarnings = []; + /** * Create a new posthog object with your app's API key * key @@ -158,6 +163,10 @@ public function capture(array $message) $flagsSnapshot = $message["flags"] ?? null; unset($message["flags"]); + $usedGeneratedPersonlessDistinctId = false; + if ($this->shouldApplyCaptureContext($message)) { + $message = $this->applyCaptureContext($message, $usedGeneratedPersonlessDistinctId); + } $message = $this->message($message); $message["type"] = "capture"; @@ -188,28 +197,30 @@ public function capture(array $message) E_USER_DEPRECATED ); - $extraProperties = []; - $flags = []; + if (!$usedGeneratedPersonlessDistinctId) { + $extraProperties = []; + $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. - $flags = $this->getAllFlags($message["distinct_id"], $message["groups"], [], [], true); - } else { - $flags = $this->fetchFeatureVariants($message["distinct_id"], $message["groups"]); - } + 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. + $flags = $this->getAllFlags($message["distinct_id"], $message["groups"], [], [], true); + } else { + $flags = $this->fetchFeatureVariants($message["distinct_id"], $message["groups"]); + } - // Add all feature variants to event - foreach ($flags as $flagKey => $flagValue) { - $extraProperties[sprintf('$feature/%s', $flagKey)] = $flagValue; - } - // Add all feature flag keys that aren't false to $active_feature_flags - // decide v2 does this automatically, but we need it for when we upgrade to v3 - $extraProperties['$active_feature_flags'] = array_keys(array_filter($flags, function ($flagValue) { - return $flagValue !== false; - })); + // Add all feature variants to event + foreach ($flags as $flagKey => $flagValue) { + $extraProperties[sprintf('$feature/%s', $flagKey)] = $flagValue; + } + // Add all feature flag keys that aren't false to $active_feature_flags + // decide v2 does this automatically, but we need it for when we upgrade to v3 + $extraProperties['$active_feature_flags'] = array_keys(array_filter($flags, function ($flagValue) { + return $flagValue !== false; + })); - $message["properties"] = array_merge($extraProperties, $message["properties"]); + $message["properties"] = array_merge($extraProperties, $message["properties"]); + } } @@ -229,11 +240,6 @@ public function captureException( ?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)); @@ -250,15 +256,16 @@ public function captureException( ] ); - if ($noDistinctIdProvided) { - $properties['$process_person_profile'] = false; - } - - return $this->capture([ - 'distinctId' => $distinctId, + $message = [ 'event' => '$exception', 'properties' => $properties, - ]); + ]; + + if ($distinctId !== null) { + $message['distinctId'] = $distinctId; + } + + return $this->capture($message); } /** @@ -286,7 +293,7 @@ public function identify(array $message) * `/flags` request per incoming request. * * @param string $key - * @param string $distinctId + * @param string|null $distinctId Defaults to the current request context distinctId, when set. * @param array $groups * @param array $personProperties * @param array $groupProperties @@ -295,7 +302,7 @@ public function identify(array $message) */ public function isFeatureEnabled( string $key, - string $distinctId, + ?string $distinctId = null, array $groups = array(), array $personProperties = array(), array $groupProperties = array(), @@ -335,7 +342,7 @@ public function isFeatureEnabled( * `/flags` request per incoming request. * * @param string $key - * @param string $distinctId + * @param string|null $distinctId Defaults to the current request context distinctId, when set. * @param array $groups * @param array $personProperties * @param array $groupProperties @@ -344,7 +351,7 @@ public function isFeatureEnabled( */ public function getFeatureFlag( string $key, - string $distinctId, + ?string $distinctId = null, array $groups = array(), array $personProperties = array(), array $groupProperties = array(), @@ -379,7 +386,7 @@ public function getFeatureFlag( * `/flags` request per incoming request. * * @param string $key - * @param string $distinctId + * @param string|null $distinctId Defaults to the current request context distinctId, when set. * @param array $groups * @param array $personProperties * @param array $groupProperties @@ -390,7 +397,7 @@ public function getFeatureFlag( */ public function getFeatureFlagResult( string $key, - string $distinctId, + ?string $distinctId = null, array $groups = array(), array $personProperties = array(), array $groupProperties = array(), @@ -429,13 +436,19 @@ public function getFeatureFlagResult( */ private function doGetFeatureFlagResult( string $key, - string $distinctId, + ?string $distinctId = null, array $groups = [], array $personProperties = [], array $groupProperties = [], bool $onlyEvaluateLocally = false, bool $sendFeatureFlagEvents = true ): ?FeatureFlagResult { + $distinctId = $this->resolveDistinctId($distinctId); + if ($distinctId === '') { + $this->warnMissingDistinctId('Feature flag evaluation'); + return null; + } + [$personProperties, $groupProperties] = $this->addLocalPersonAndGroupProperties( $distinctId, $groups, @@ -575,7 +588,7 @@ private function doGetFeatureFlagResult( * `/flags` request per incoming request. * * @param string $key - * @param string $distinctId + * @param string|null $distinctId Defaults to the current request context distinctId, when set. * @param array $groups * @param array $personProperties * @param array $groupProperties @@ -583,7 +596,7 @@ private function doGetFeatureFlagResult( */ public function getFeatureFlagPayload( string $key, - string $distinctId, + ?string $distinctId = null, array $groups = array(), array $personProperties = array(), array $groupProperties = array(), @@ -613,7 +626,7 @@ public function getFeatureFlagPayload( /** * get the feature flag value for this distinct id. * - * @param string $distinctId + * @param string|null $distinctId Defaults to the current request context distinctId, when set. * @param array $groups * @param array $personProperties * @param array $groupProperties @@ -621,12 +634,18 @@ public function getFeatureFlagPayload( * @throws Exception */ public function getAllFlags( - string $distinctId, + ?string $distinctId = null, array $groups = array(), array $personProperties = array(), array $groupProperties = array(), bool $onlyEvaluateLocally = false ): array { + $distinctId = $this->resolveDistinctId($distinctId); + if ($distinctId === '') { + $this->warnMissingDistinctId('getAllFlags()'); + return []; + } + [$personProperties, $groupProperties] = $this->addLocalPersonAndGroupProperties( $distinctId, $groups, @@ -673,7 +692,8 @@ public function getAllFlags( /** * Evaluate every feature flag for a distinct id in a single round trip and return a - * FeatureFlagEvaluations snapshot. Reads on the snapshot do not trigger additional /flags + * FeatureFlagEvaluations snapshot. When distinctId is omitted, the current request context + * distinctId is used if available. Reads on the snapshot do not trigger additional /flags * requests; access via isEnabled() or getFlag() fires a deduped $feature_flag_called event the * first time each key is touched. * @@ -687,7 +707,7 @@ public function getAllFlags( * which scopes which already-evaluated flags get attached to a captured event. */ public function evaluateFlags( - string $distinctId, + ?string $distinctId = null, array $groups = [], array $personProperties = [], array $groupProperties = [], @@ -695,7 +715,9 @@ public function evaluateFlags( bool $disableGeoip = false, ?array $flagKeys = null ): FeatureFlagEvaluations { + $distinctId = $this->resolveDistinctId($distinctId); if ($distinctId === '') { + $this->warnMissingDistinctId('evaluateFlags()'); return new FeatureFlagEvaluations( $distinctId, [], @@ -1013,7 +1035,10 @@ public function loadFlags() $responseCode = $response->getResponseCode(); if ($responseCode !== 200) { - error_log("[PostHog][Client] Failed to load feature flags (HTTP $responseCode): " . $response->getResponse()); + error_log( + "[PostHog][Client] Failed to load feature flags (HTTP $responseCode): " + . $response->getResponse() + ); return; } @@ -1314,6 +1339,130 @@ private function formatTime($ts) return date($fmt, (int)$sec); } + /** + * Run a callback with request context applied to all captures in the callback. + * + * @param array $data + * @param callable $fn + * @param array $options + * @return mixed + */ + public function withContext(array $data, callable $fn, array $options = []): mixed + { + return RequestContext::withContext($data, $fn, $options, $this->contextKey()); + } + + /** + * @return array{distinctId?: string|null, sessionId?: string|null, properties: array}|null + */ + public function getContext(): ?array + { + return RequestContext::getContext($this->contextKey()); + } + + /** + * @param array $headers + * @return array{distinctId?: string|null, sessionId?: string|null, properties: array} + */ + public function contextFromHeaders(array $headers): array + { + return RequestContext::contextFromHeaders($headers); + } + + private function contextKey(): int + { + return spl_object_id($this); + } + + private function resolveDistinctId(?string $distinctId): string + { + if ($distinctId !== null && $distinctId !== '') { + return $distinctId; + } + + return RequestContext::getDistinctId($this->contextKey()) ?? ''; + } + + private function warnMissingDistinctId(string $operation): void + { + if (isset($this->missingDistinctIdWarnings[$operation])) { + return; + } + + $this->missingDistinctIdWarnings[$operation] = true; + $this->logWarning( + "$operation requires distinctId — pass it explicitly or use withContext()." + ); + } + + private function shouldApplyCaptureContext(array $msg): bool + { + return ($msg['event'] ?? null) !== '$groupidentify'; + } + + /** + * Apply request context to capture-like events only. Identification, alias, + * and group identify calls must keep explicit identity/properties to avoid + * accidentally mutating the wrong entity from ambient request state. + * + * @param array $msg + * @return array + */ + private function applyCaptureContext(array $msg, bool &$usedGeneratedPersonlessDistinctId): array + { + if (!isset($msg["properties"]) || !is_array($msg["properties"])) { + $msg["properties"] = array(); + } + + $explicitDistinctId = $this->hasExplicitCaptureDistinctId($msg); + + $context = RequestContext::getContext($this->contextKey()); + $msg["properties"] = array_merge($context['properties'] ?? [], $msg["properties"]); + + if ( + isset($context['sessionId']) + && !array_key_exists('$session_id', $msg["properties"]) + ) { + $msg["properties"]['$session_id'] = $context['sessionId']; + } + + if (!$explicitDistinctId && isset($context['distinctId']) && (string) $context['distinctId'] !== '') { + $msg["distinct_id"] = $context['distinctId']; + $explicitDistinctId = true; + } + + if (!$explicitDistinctId) { + $msg["distinct_id"] = Uuid::v4(); + $usedGeneratedPersonlessDistinctId = true; + if (!array_key_exists('$process_person_profile', $msg["properties"])) { + $msg["properties"]['$process_person_profile'] = false; + } + } + + return $msg; + } + + private function hasExplicitCaptureDistinctId(array &$msg): bool + { + if (array_key_exists("distinctId", $msg)) { + if (is_scalar($msg["distinctId"]) && (string) $msg["distinctId"] !== '') { + return true; + } + + unset($msg["distinctId"]); + } + + if (array_key_exists("distinct_id", $msg)) { + if (is_scalar($msg["distinct_id"]) && (string) $msg["distinct_id"] !== '') { + return true; + } + + unset($msg["distinct_id"]); + } + + return false; + } + /** * Add common fields to the given `message` * @@ -1322,7 +1471,7 @@ private function formatTime($ts) */ private function message($msg) { - if (!isset($msg["properties"])) { + if (!isset($msg["properties"]) || !is_array($msg["properties"])) { $msg["properties"] = array(); } diff --git a/lib/ExceptionCapture.php b/lib/ExceptionCapture.php index 26da702..d1ba197 100644 --- a/lib/ExceptionCapture.php +++ b/lib/ExceptionCapture.php @@ -444,34 +444,47 @@ private static function normalizeErrorHandlerTrace(array $trace): array */ private static function getProviderContext(array $payload): array { + $requestContext = self::$client?->getContext() ?? ['distinctId' => null, 'properties' => []]; + $distinctId = $requestContext['distinctId'] ?? null; + $properties = $requestContext['properties'] ?? []; + $provider = self::$options['context_provider']; if (!is_callable($provider)) { - return ['distinctId' => null, 'properties' => []]; + return [ + 'distinctId' => $distinctId !== null && $distinctId !== '' ? (string) $distinctId : null, + 'properties' => is_array($properties) ? $properties : [], + ]; } try { $result = $provider($payload); } catch (\Throwable $providerError) { - return ['distinctId' => null, 'properties' => []]; + return [ + 'distinctId' => $distinctId !== null && $distinctId !== '' ? (string) $distinctId : null, + 'properties' => is_array($properties) ? $properties : [], + ]; } if (!is_array($result)) { - return ['distinctId' => null, 'properties' => []]; + return [ + 'distinctId' => $distinctId !== null && $distinctId !== '' ? (string) $distinctId : null, + 'properties' => is_array($properties) ? $properties : [], + ]; } - $distinctId = $result['distinctId'] ?? null; - if ($distinctId !== null && !is_scalar($distinctId)) { - $distinctId = null; + $providerDistinctId = $result['distinctId'] ?? null; + if ($providerDistinctId !== null && is_scalar($providerDistinctId) && (string) $providerDistinctId !== '') { + $distinctId = (string) $providerDistinctId; } - $properties = $result['properties'] ?? []; - if (!is_array($properties)) { - $properties = []; + $providerProperties = $result['properties'] ?? []; + if (!is_array($providerProperties)) { + $providerProperties = []; } return [ 'distinctId' => $distinctId !== null && $distinctId !== '' ? (string) $distinctId : null, - 'properties' => $properties, + 'properties' => array_merge(is_array($properties) ? $properties : [], $providerProperties), ]; } diff --git a/lib/PostHog.php b/lib/PostHog.php index 4c63fcf..6dbe3fc 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -87,7 +87,6 @@ public static function capture(array $message) self::checkClient(); $event = !empty($message["event"]); self::assert($event, "PostHog::capture() expects an event"); - self::validate($message, "capture"); return self::$client->capture($message); } @@ -143,7 +142,7 @@ public static function groupIdentify(array $message) * `/flags` request per incoming request. * * @param string $key - * @param string $distinctId + * @param string|null $distinctId Defaults to the current request context distinctId, when set. * @param array $groups * @param array $personProperties * @param array $groupProperties @@ -152,7 +151,7 @@ public static function groupIdentify(array $message) */ public static function isFeatureEnabled( string $key, - string $distinctId, + ?string $distinctId = null, array $groups = array(), array $personProperties = array(), array $groupProperties = array(), @@ -177,7 +176,7 @@ public static function isFeatureEnabled( * `/flags` request per incoming request. * * @param string $key - * @param string $distinctId + * @param string|null $distinctId Defaults to the current request context distinctId, when set. * @param array $groups * @param array $personProperties * @param array $groupProperties @@ -186,7 +185,7 @@ public static function isFeatureEnabled( */ public static function getFeatureFlag( string $key, - string $distinctId, + ?string $distinctId = null, array $groups = array(), array $personProperties = array(), array $groupProperties = array(), @@ -211,7 +210,7 @@ public static function getFeatureFlag( * `/flags` request per incoming request. * * @param string $key - * @param string $distinctId + * @param string|null $distinctId Defaults to the current request context distinctId, when set. * @param array $groups * @param array $personProperties * @param array $groupProperties @@ -222,7 +221,7 @@ public static function getFeatureFlag( */ public static function getFeatureFlagResult( string $key, - string $distinctId, + ?string $distinctId = null, array $groups = array(), array $personProperties = array(), array $groupProperties = array(), @@ -247,7 +246,7 @@ public static function getFeatureFlagResult( * `/flags` request per incoming request. * * @param string $key - * @param string $distinctId + * @param string|null $distinctId Defaults to the current request context distinctId, when set. * @param array $groups * @param array $personProperties * @param array $groupProperties @@ -255,7 +254,7 @@ public static function getFeatureFlagResult( */ public static function getFeatureFlagPayload( string $key, - string $distinctId, + ?string $distinctId = null, array $groups = array(), array $personProperties = array(), array $groupProperties = array(), @@ -271,6 +270,7 @@ public static function getFeatureFlagPayload( /** * Evaluate every feature flag for a distinct id in a single round trip and return a snapshot. + * When distinctId is omitted, the current request context distinctId is used if available. * Pass the snapshot to capture() via the `flags` key to attach $feature/ properties * without making another /flags request. * @@ -285,7 +285,7 @@ public static function getFeatureFlagPayload( * @throws Exception */ public static function evaluateFlags( - string $distinctId, + ?string $distinctId = null, array $groups = array(), array $personProperties = array(), array $groupProperties = array(), @@ -308,7 +308,7 @@ public static function evaluateFlags( /** * get all enabled flags for distinct_id * - * @param string $distinctId + * @param string|null $distinctId Defaults to the current request context distinctId, when set. * @param array $groups * @param array $personProperties * @param array $groupProperties @@ -316,7 +316,7 @@ public static function evaluateFlags( * @throws Exception */ public static function getAllFlags( - string $distinctId, + ?string $distinctId = null, array $groups = array(), array $personProperties = array(), array $groupProperties = array(), @@ -362,6 +362,38 @@ public static function alias(array $message) return self::$client->alias($message); } + /** + * Run a callback with request context applied to all captures in the callback. + * + * @param array $data + * @param callable $fn + * @param array $options + * @return mixed + */ + public static function withContext(array $data, callable $fn, array $options = []): mixed + { + self::checkClient(); + return self::$client->withContext($data, $fn, $options); + } + + /** + * @return array{distinctId?: string|null, sessionId?: string|null, properties: array}|null + */ + public static function getContext(): ?array + { + self::checkClient(); + return self::$client->getContext(); + } + + /** + * @param array $headers + * @return array{distinctId?: string|null, sessionId?: string|null, properties: array} + */ + public static function contextFromHeaders(array $headers): array + { + return RequestContext::contextFromHeaders($headers); + } + /** * Send a raw (prepared) message * @@ -383,7 +415,7 @@ public static function raw(array $message) */ public static function validate($msg, $type) { - $distinctId = !empty($msg["distinctId"]); + $distinctId = !empty($msg["distinctId"]) || !empty($msg["distinct_id"]); self::assert($distinctId, "PostHog::{$type}() requires distinctId"); } @@ -438,7 +470,7 @@ private static function cleanHost(?string $host): string */ private static function checkClient() { - if (null != self::$client) { + if (isset(self::$client)) { return; } diff --git a/lib/RequestContext.php b/lib/RequestContext.php new file mode 100644 index 0000000..b731a12 --- /dev/null +++ b/lib/RequestContext.php @@ -0,0 +1,281 @@ +}>>> + */ + private static array $stacks = []; + + private static int $nextScopeId = 1; + + /** + * Run a callback with context that is restored automatically afterwards. + * + * @param array $data + * @param callable $fn + * @param array $options Use ['fresh' => true] to avoid inheriting the current context. + * @param int|null $contextKey Internal Client scope key. Null keeps backwards-compatible global scope. + * @return mixed + */ + public static function withContext( + array $data, + callable $fn, + array $options = [], + ?int $contextKey = null + ): mixed { + $contextKey = self::contextKey($contextKey); + $scopeId = self::push($data, (bool) ($options['fresh'] ?? false), $contextKey); + + try { + return $fn(); + } finally { + self::pop($scopeId, $contextKey); + } + } + + /** + * @return array{distinctId?: string|null, sessionId?: string|null, properties: array}|null + */ + public static function getContext(?int $contextKey = null): ?array + { + $contextKey = self::contextKey($contextKey); + $fiberKey = self::fiberKey(); + + if (empty(self::$stacks[$contextKey][$fiberKey])) { + return null; + } + + $stack = self::$stacks[$contextKey][$fiberKey]; + return $stack[array_key_last($stack)]['context']; + } + + public static function getDistinctId(?int $contextKey = null): ?string + { + $context = self::getContext($contextKey); + $distinctId = $context['distinctId'] ?? null; + + return $distinctId !== null && $distinctId !== '' ? (string) $distinctId : null; + } + + /** + * @internal Test helper for clearing leaked context between tests/processes. + */ + public static function reset(): void + { + self::$stacks = []; + self::$nextScopeId = 1; + } + + /** + * Extract PostHog frontend tracing context from HTTP headers. + * + * Header names are matched case-insensitively and support $_SERVER-style HTTP_* keys. + * This helper intentionally only reads X-PostHog-Distinct-Id and + * X-PostHog-Session-Id. Framework integrations should attach request metadata + * such as URL, IP address, and user agent from trusted + * request APIs via the returned properties array. + * + * @param array $headers + * @return array{distinctId?: string|null, sessionId?: string|null, properties: array} + */ + public static function contextFromHeaders(array $headers): array + { + $distinctId = self::sanitizeHeaderValue(self::getHeader($headers, 'x-posthog-distinct-id')); + $sessionId = self::sanitizeHeaderValue(self::getHeader($headers, 'x-posthog-session-id')); + + $properties = []; + if ($sessionId !== null) { + $properties['$session_id'] = $sessionId; + } + + return [ + 'distinctId' => $distinctId, + 'sessionId' => $sessionId, + 'properties' => $properties, + ]; + } + + private static function sanitizeHeaderValue(mixed $value): ?string + { + if (is_array($value)) { + $value = reset($value); + } + + if (!is_string($value)) { + return null; + } + + $value = trim($value); + $value = preg_replace('/[\x00-\x1F\x7F]/', '', $value) ?? ''; + $value = trim($value); + + if ($value === '') { + return null; + } + + return substr($value, 0, self::MAX_HEADER_LENGTH); + } + + /** + * @param array $data + */ + private static function push(array $data, bool $fresh, int $contextKey): int + { + $current = $fresh ? ['properties' => []] : (self::getContext($contextKey) ?? ['properties' => []]); + $scopeId = self::$nextScopeId++; + $fiberKey = self::fiberKey(); + + if (!array_key_exists($contextKey, self::$stacks)) { + self::$stacks[$contextKey] = []; + } + if (!array_key_exists($fiberKey, self::$stacks[$contextKey])) { + self::$stacks[$contextKey][$fiberKey] = []; + } + + self::$stacks[$contextKey][$fiberKey][] = [ + 'scopeId' => $scopeId, + 'context' => self::mergeContext($current, $data), + ]; + + return $scopeId; + } + + private static function pop(int $scopeId, int $contextKey): void + { + $fiberKey = self::fiberKey(); + if (empty(self::$stacks[$contextKey][$fiberKey])) { + return; + } + + $lastKey = array_key_last(self::$stacks[$contextKey][$fiberKey]); + if (self::$stacks[$contextKey][$fiberKey][$lastKey]['scopeId'] === $scopeId) { + array_pop(self::$stacks[$contextKey][$fiberKey]); + self::unsetStackIfEmpty($contextKey, $fiberKey); + return; + } + + foreach (self::$stacks[$contextKey][$fiberKey] as $index => $frame) { + if ($frame['scopeId'] === $scopeId) { + self::$stacks[$contextKey][$fiberKey] = array_slice( + self::$stacks[$contextKey][$fiberKey], + 0, + $index + ); + self::unsetStackIfEmpty($contextKey, $fiberKey); + return; + } + } + } + + private static function contextKey(?int $contextKey): int + { + return $contextKey ?? self::DEFAULT_CONTEXT_KEY; + } + + private static function fiberKey(): int + { + $fiber = \Fiber::getCurrent(); + return $fiber === null ? 0 : spl_object_id($fiber); + } + + private static function unsetStackIfEmpty(int $contextKey, int $fiberKey): void + { + if (empty(self::$stacks[$contextKey][$fiberKey])) { + unset(self::$stacks[$contextKey][$fiberKey]); + } + + if (empty(self::$stacks[$contextKey])) { + unset(self::$stacks[$contextKey]); + } + } + + /** + * @param array $current + * @param array $data + * @return array{distinctId?: string|null, sessionId?: string|null, properties: array} + */ + private static function mergeContext(array $current, array $data): array + { + $data = self::normalizeContextData($data); + + return [ + 'distinctId' => $data['distinctId'] ?? ($current['distinctId'] ?? null), + 'sessionId' => $data['sessionId'] ?? ($current['sessionId'] ?? null), + 'properties' => array_merge($current['properties'] ?? [], $data['properties'] ?? []), + ]; + } + + /** + * @param array $data + * @return array{distinctId?: string|null, sessionId?: string|null, properties: array} + */ + private static function normalizeContextData(array $data): array + { + if (array_key_exists('distinct_id', $data) && !array_key_exists('distinctId', $data)) { + $data['distinctId'] = $data['distinct_id']; + } + if (array_key_exists('session_id', $data) && !array_key_exists('sessionId', $data)) { + $data['sessionId'] = $data['session_id']; + } + + $context = ['properties' => []]; + + if ( + array_key_exists('distinctId', $data) + && is_scalar($data['distinctId']) + && (string) $data['distinctId'] !== '' + ) { + $context['distinctId'] = (string) $data['distinctId']; + } + + if ( + array_key_exists('sessionId', $data) + && is_scalar($data['sessionId']) + && (string) $data['sessionId'] !== '' + ) { + $context['sessionId'] = (string) $data['sessionId']; + $context['properties']['$session_id'] = (string) $data['sessionId']; + } + + if (isset($data['properties']) && is_array($data['properties'])) { + $context['properties'] = array_merge($context['properties'], $data['properties']); + } + + return $context; + } + + /** + * @param array $headers + */ + private static function getHeader(array $headers, string $name): mixed + { + $normalizedName = strtolower(str_replace('_', '-', $name)); + foreach ($headers as $key => $value) { + $headerName = strtolower(str_replace('_', '-', (string) $key)); + if (str_starts_with($headerName, 'http-')) { + $headerName = substr($headerName, 5); + } + if ($headerName === $normalizedName) { + return $value; + } + } + + return null; + } +} diff --git a/test/ExceptionCaptureTest.php b/test/ExceptionCaptureTest.php index 0e2461b..d31bec1 100644 --- a/test/ExceptionCaptureTest.php +++ b/test/ExceptionCaptureTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\TestCase; use PostHog\Client; use PostHog\ExceptionCapture; +use PostHog\RequestContext; class ExceptionCaptureTest extends TestCase { @@ -16,6 +17,7 @@ class ExceptionCaptureTest extends TestCase public function setUp(): void { date_default_timezone_set("UTC"); + RequestContext::reset(); ExceptionCapture::resetForTests(); ExceptionCapture::enableThrowOnUnhandledForTests(); @@ -26,6 +28,7 @@ public function setUp(): void public function tearDown(): void { ExceptionCapture::resetForTests(); + RequestContext::reset(); } public function testDisabledErrorTrackingDoesNotRegisterHandlers(): void @@ -379,6 +382,38 @@ public function testContextProviderCanSupplyDistinctIdAndProperties(): void $this->assertArrayNotHasKey('$process_person_profile', $event['properties']); } + public function testExceptionHandlerUsesCurrentClientContextProperties(): void + { + $this->buildClient(['error_tracking' => ['enabled' => true]]); + + try { + $this->client->withContext([ + 'distinctId' => 'context-user', + 'sessionId' => 'context-session', + 'properties' => [ + '$request_path' => '/api/context', + '$exception_source' => 'context-source', + 'context_property' => 'context-value', + ], + ], function (): void { + ExceptionCapture::handleException(new \RuntimeException('context boom')); + }); + $this->fail('Expected the uncaught exception to be rethrown'); + } catch (\RuntimeException $caught) { + $this->assertSame('context boom', $caught->getMessage()); + } + + $event = $this->findExceptionEvent(); + + $this->assertSame('context-user', $event['distinct_id']); + $this->assertSame('context-session', $event['properties']['$session_id']); + $this->assertSame('/api/context', $event['properties']['$request_path']); + $this->assertSame('context-value', $event['properties']['context_property']); + $this->assertSame('php_exception_handler', $event['properties']['$exception_source']); + $this->assertFalse($event['properties']['$exception_handled']); + $this->assertArrayNotHasKey('$process_person_profile', $event['properties']); + } + public function testAutoCaptureOnlyOverridesPrimaryMechanismForChains(): void { $this->buildClient(['error_tracking' => ['enabled' => true]]); diff --git a/test/RequestContextTest.php b/test/RequestContextTest.php new file mode 100644 index 0000000..5b21af6 --- /dev/null +++ b/test/RequestContextTest.php @@ -0,0 +1,580 @@ +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 = []; + } + + public function tearDown(): void + { + RequestContext::reset(); + } + + public function testCaptureUsesContextDistinctSessionAndProperties(): void + { + PostHog::withContext([ + 'distinctId' => 'context-user', + 'sessionId' => 'context-session', + 'properties' => ['plan' => 'pro'], + ], function (): void { + PostHog::capture([ + 'event' => 'context event', + 'properties' => ['source' => 'test'], + ]); + }); + + $event = $this->flushAndGetEvents()[0]; + + $this->assertSame('context-user', $event['distinct_id']); + $this->assertSame('context-session', $event['properties']['$session_id']); + $this->assertSame('pro', $event['properties']['plan']); + $this->assertSame('test', $event['properties']['source']); + $this->assertArrayNotHasKey('$process_person_profile', $event['properties']); + } + + public function testExplicitCaptureValuesOverrideContext(): void + { + PostHog::withContext([ + 'distinctId' => 'context-user', + 'sessionId' => 'context-session', + 'properties' => ['plan' => 'free', 'region' => 'us'], + ], function (): void { + PostHog::capture([ + 'distinctId' => 'explicit-user', + 'event' => 'override event', + 'properties' => [ + '$session_id' => 'explicit-session', + 'plan' => 'paid', + ], + ]); + }); + + $event = $this->flushAndGetEvents()[0]; + + $this->assertSame('explicit-user', $event['distinct_id']); + $this->assertSame('explicit-session', $event['properties']['$session_id']); + $this->assertSame('paid', $event['properties']['plan']); + $this->assertSame('us', $event['properties']['region']); + } + + public function testInvalidCamelDistinctIdFallsBackToValidSnakeDistinctId(): void + { + PostHog::withContext(['distinctId' => 'context-user'], function (): void { + PostHog::capture([ + 'distinctId' => '', + 'distinct_id' => 'snake-user', + 'event' => 'snake fallback event', + ]); + }); + + $event = $this->flushAndGetEvents()[0]; + + $this->assertSame('snake-user', $event['distinct_id']); + $this->assertArrayNotHasKey('$process_person_profile', $event['properties']); + } + + public function testMissingDistinctIdCreatesPersonlessEvent(): void + { + PostHog::capture([ + 'event' => 'personless event', + 'properties' => ['plan' => 'free'], + ]); + + $event = $this->flushAndGetEvents()[0]; + + $this->assertIsString($event['distinct_id']); + $this->assertNotSame('', $event['distinct_id']); + $this->assertFalse($event['properties']['$process_person_profile']); + $this->assertSame('free', $event['properties']['plan']); + } + + public function testNestedContextInheritanceFreshContextAndRestoration(): void + { + PostHog::withContext([ + 'distinctId' => 'outer-user', + 'sessionId' => 'outer-session', + 'properties' => ['outer' => true, 'shared' => 'outer'], + ], function (): void { + PostHog::withContext([ + 'properties' => ['inner' => true, 'shared' => 'inner'], + ], function (): void { + PostHog::capture(['event' => 'inherited event']); + }); + + PostHog::withContext([ + 'properties' => ['fresh' => true], + ], function (): void { + PostHog::capture(['event' => 'fresh event']); + }, ['fresh' => true]); + + PostHog::capture(['event' => 'restored event']); + }); + + $events = $this->flushAndGetEvents(); + [$inherited, $fresh, $restored] = $events; + + $this->assertSame('outer-user', $inherited['distinct_id']); + $this->assertSame('outer-session', $inherited['properties']['$session_id']); + $this->assertTrue($inherited['properties']['outer']); + $this->assertTrue($inherited['properties']['inner']); + $this->assertSame('inner', $inherited['properties']['shared']); + + $this->assertNotSame('outer-user', $fresh['distinct_id']); + $this->assertArrayNotHasKey('$session_id', $fresh['properties']); + $this->assertArrayNotHasKey('outer', $fresh['properties']); + $this->assertTrue($fresh['properties']['fresh']); + $this->assertFalse($fresh['properties']['$process_person_profile']); + + $this->assertSame('outer-user', $restored['distinct_id']); + $this->assertSame('outer-session', $restored['properties']['$session_id']); + $this->assertTrue($restored['properties']['outer']); + $this->assertArrayNotHasKey('inner', $restored['properties']); + } + + public function testWithContextRestoresAfterException(): void + { + try { + PostHog::withContext(['distinctId' => 'leaky-user'], function (): void { + throw new \RuntimeException('boom'); + }); + } catch (\RuntimeException) { + } + + $this->assertNull(PostHog::getContext()); + + PostHog::capture(['event' => 'after exception']); + $event = $this->flushAndGetEvents()[0]; + + $this->assertNotSame('leaky-user', $event['distinct_id']); + $this->assertFalse($event['properties']['$process_person_profile']); + } + + public function testContextIsFiberScoped(): void + { + $seen = []; + + $fiber = new \Fiber(function () use (&$seen): void { + PostHog::withContext(['distinctId' => 'fiber-user'], function () use (&$seen): void { + $seen['fiber-before'] = PostHog::getContext()['distinctId'] ?? null; + \Fiber::suspend(); + $seen['fiber-after'] = PostHog::getContext()['distinctId'] ?? null; + }); + }); + + $fiber->start(); + + PostHog::withContext(['distinctId' => 'main-user'], function () use (&$seen, $fiber): void { + $seen['main-before'] = PostHog::getContext()['distinctId'] ?? null; + $fiber->resume(); + $seen['main-after'] = PostHog::getContext()['distinctId'] ?? null; + }); + + $this->assertSame('fiber-user', $seen['fiber-before']); + $this->assertSame('fiber-user', $seen['fiber-after']); + $this->assertSame('main-user', $seen['main-before']); + $this->assertSame('main-user', $seen['main-after']); + $this->assertNull(PostHog::getContext()); + } + + public function testCaptureContextIsFiberScoped(): void + { + $fiber = new \Fiber(function (): void { + $this->client->withContext([ + 'distinctId' => 'fiber-user', + 'properties' => ['scope' => 'fiber'], + ], function (): void { + $this->client->capture(['event' => 'fiber before']); + \Fiber::suspend(); + $this->client->capture(['event' => 'fiber after']); + }); + }); + + $fiber->start(); + + $this->client->withContext([ + 'distinctId' => 'main-user', + 'properties' => ['scope' => 'main'], + ], function () use ($fiber): void { + $this->client->capture(['event' => 'main before']); + $fiber->resume(); + $this->client->capture(['event' => 'main after']); + }); + + $events = []; + foreach ($this->flushAndGetEvents() as $event) { + $events[$event['event']] = $event; + } + + $this->assertSame('fiber-user', $events['fiber before']['distinct_id']); + $this->assertSame('fiber', $events['fiber before']['properties']['scope']); + $this->assertSame('fiber-user', $events['fiber after']['distinct_id']); + $this->assertSame('fiber', $events['fiber after']['properties']['scope']); + + $this->assertSame('main-user', $events['main before']['distinct_id']); + $this->assertSame('main', $events['main before']['properties']['scope']); + $this->assertSame('main-user', $events['main after']['distinct_id']); + $this->assertSame('main', $events['main after']['properties']['scope']); + + $this->assertNull($this->client->getContext()); + } + + public function testContextFromHeadersSanitizesFrontendTracingHeadersOnly(): void + { + $longDistinctId = str_repeat('a', 1200); + + $context = PostHog::contextFromHeaders([ + 'x-posthog-distinct-id' => " {$longDistinctId}\nignored ", + 'X-POSTHOG-SESSION-ID' => "\t session-123 \r\n", + 'USER_AGENT' => "Agent\x00Name", + 'X-Forwarded-For' => '203.0.113.10, 10.0.0.1', + ]); + + $this->assertSame(str_repeat('a', 1000), $context['distinctId']); + $this->assertSame('session-123', $context['sessionId']); + $this->assertSame('session-123', $context['properties']['$session_id']); + $this->assertArrayNotHasKey('$current_url', $context['properties']); + $this->assertArrayNotHasKey('$user_agent', $context['properties']); + $this->assertArrayNotHasKey('$ip', $context['properties']); + + PostHog::withContext($context, function (): void { + PostHog::capture(['event' => 'header event']); + }); + + $event = $this->flushAndGetEvents()[0]; + + $this->assertSame(str_repeat('a', 1000), $event['distinct_id']); + $this->assertSame('session-123', $event['properties']['$session_id']); + } + + public function testContextCanBeCombinedWithTrustedFrameworkMetadata(): void + { + $context = PostHog::contextFromHeaders([ + 'x-posthog-distinct-id' => 'framework-user', + 'x-posthog-session-id' => 'framework-session', + ]); + $context['properties'] = array_merge($context['properties'], [ + '$current_url' => 'https://example.com/api/projects?limit=1', + '$request_method' => 'GET', + '$request_path' => '/api/projects', + '$user_agent' => 'AgentName', + '$ip' => '203.0.113.10', + ]); + + PostHog::withContext($context, function (): void { + PostHog::capture(['event' => 'framework event']); + }); + + $event = $this->flushAndGetEvents()[0]; + + $this->assertSame('framework-user', $event['distinct_id']); + $this->assertSame('framework-session', $event['properties']['$session_id']); + $this->assertSame('https://example.com/api/projects?limit=1', $event['properties']['$current_url']); + $this->assertSame('GET', $event['properties']['$request_method']); + $this->assertSame('/api/projects', $event['properties']['$request_path']); + $this->assertSame('AgentName', $event['properties']['$user_agent']); + $this->assertSame('203.0.113.10', $event['properties']['$ip']); + } + + public function testIntegrationCanSkipTracingHeadersAndStillWrapRequestMetadata(): void + { + PostHog::withContext([ + 'properties' => [ + '$request_path' => '/api/disabled-tracing', + ], + ], function (): void { + PostHog::capture(['event' => 'disabled tracing headers event']); + }); + + $event = $this->flushAndGetEvents()[0]; + + $this->assertFalse($event['properties']['$process_person_profile']); + $this->assertSame('/api/disabled-tracing', $event['properties']['$request_path']); + $this->assertArrayNotHasKey('$session_id', $event['properties']); + } + + public function testEmptyAndNonStringHeaderValuesAreIgnored(): void + { + $context = PostHog::contextFromHeaders([ + 'X-POSTHOG-DISTINCT-ID' => " \n\r\t ", + 'X-POSTHOG-SESSION-ID' => "\x00\x7F", + ]); + + $this->assertNull($context['distinctId']); + $this->assertNull($context['sessionId']); + $this->assertArrayNotHasKey('$session_id', $context['properties']); + } + + public function testPhpServerNormalizedPostHogHeadersAreRecognized(): void + { + $context = PostHog::contextFromHeaders([ + 'HTTP_X_POSTHOG_DISTINCT_ID' => 'server-user', + 'HTTP_X_POSTHOG_SESSION_ID' => 'server-session', + ]); + + $this->assertSame('server-user', $context['distinctId']); + $this->assertSame('server-session', $context['sessionId']); + $this->assertSame('server-session', $context['properties']['$session_id']); + } + + public function testSymfonyAndLaravelHeaderBagArraysAreRecognized(): void + { + $context = PostHog::contextFromHeaders([ + 'x-posthog-distinct-id' => ['bag-user'], + 'x-posthog-session-id' => ['bag-session'], + ]); + + $this->assertSame('bag-user', $context['distinctId']); + $this->assertSame('bag-session', $context['sessionId']); + $this->assertSame('bag-session', $context['properties']['$session_id']); + } + + public function testCaptureExceptionUsesContextAndExplicitDistinctOverride(): void + { + PostHog::withContext([ + 'distinctId' => 'context-user', + 'sessionId' => 'context-session', + 'properties' => ['$request_path' => '/api/context'], + ], function (): void { + PostHog::captureException(new \RuntimeException('context exception')); + PostHog::captureException(new \RuntimeException('explicit exception'), 'explicit-user', [ + '$session_id' => 'explicit-session', + ]); + }); + + $events = $this->flushAndGetEvents(); + + $this->assertSame('context-user', $events[0]['distinct_id']); + $this->assertSame('context-session', $events[0]['properties']['$session_id']); + $this->assertSame('/api/context', $events[0]['properties']['$request_path']); + $this->assertArrayHasKey('$exception_list', $events[0]['properties']); + $this->assertArrayNotHasKey('$process_person_profile', $events[0]['properties']); + + $this->assertSame('explicit-user', $events[1]['distinct_id']); + $this->assertSame('explicit-session', $events[1]['properties']['$session_id']); + } + + public function testContextDoesNotMutateIdentifyOrAliasIdentity(): void + { + PostHog::withContext([ + 'distinctId' => 'context-user', + 'sessionId' => 'context-session', + 'properties' => ['context_property' => 'context-value'], + ], function (): void { + $this->client->identify([ + 'distinctId' => 'identified-user', + 'properties' => ['email' => 'max@example.com'], + ]); + $this->client->alias([ + 'distinctId' => 'previous-user', + 'alias' => 'next-user', + ]); + }); + + $events = $this->flushAndGetEvents(); + + $this->assertSame('identified-user', $events[0]['distinct_id']); + $this->assertArrayNotHasKey('context_property', $events[0]['properties']); + $this->assertArrayNotHasKey('$session_id', $events[0]['properties']); + + $this->assertNull($events[1]['distinct_id']); + $this->assertSame('previous-user', $events[1]['properties']['distinct_id']); + $this->assertSame('next-user', $events[1]['properties']['alias']); + $this->assertArrayNotHasKey('context_property', $events[1]['properties']); + $this->assertArrayNotHasKey('$session_id', $events[1]['properties']); + } + + public function testContextDoesNotMutateGroupIdentifyProperties(): void + { + PostHog::withContext([ + 'distinctId' => 'context-user', + 'sessionId' => 'context-session', + 'properties' => ['context_property' => 'context-value'], + ], function (): void { + PostHog::groupIdentify([ + 'groupType' => 'organization', + 'groupKey' => 'acme', + 'properties' => ['name' => 'Acme Inc.'], + ]); + }); + + $event = $this->flushAndGetEvents()[0]; + + $this->assertSame('$organization_acme', $event['distinct_id']); + $this->assertSame('$groupidentify', $event['event']); + $this->assertSame('organization', $event['properties']['$group_type']); + $this->assertSame('acme', $event['properties']['$group_key']); + $this->assertSame(['name' => 'Acme Inc.'], $event['properties']['$group_set']); + $this->assertArrayNotHasKey('context_property', $event['properties']); + $this->assertArrayNotHasKey('$session_id', $event['properties']); + } + + public function testEvaluateFlagsUsesContextDistinctIdWhenOmitted(): void + { + PostHog::withContext(['distinctId' => 'context-user'], function (): void { + PostHog::evaluateFlags(); + }); + + $calls = $this->httpClient->calls ?? []; + $this->assertNotEmpty($calls); + $payload = json_decode($calls[array_key_last($calls)]['payload'], true); + + $this->assertSame('context-user', $payload['distinct_id']); + } + + public function testEvaluateFlagsWithoutDistinctIdLogsWarningAndReturnsEmptySnapshot(): void + { + $flags = PostHog::evaluateFlags(); + + $this->assertSame([], $flags->getKeys()); + + global $errorMessages; + $this->assertContains( + '[PostHog][Client] evaluateFlags() requires distinctId — pass it explicitly or use withContext().', + $errorMessages + ); + } + + public function testPersonlessCaptureDoesNotEvaluateFeatureFlagsFromGeneratedDistinctId(): void + { + $deprecations = $this->captureDeprecations(fn() => PostHog::capture([ + 'event' => 'personless flags event', + 'send_feature_flags' => true, + ])); + + $this->assertCount(1, $deprecations); + + $event = $this->flushAndGetEvents()[0]; + + $this->assertFalse($event['properties']['$process_person_profile']); + $this->assertArrayNotHasKey('$active_feature_flags', $event['properties']); + $this->assertArrayNotHasKey('$feature/true-flag', $event['properties']); + + $flagRequests = array_values(array_filter( + $this->httpClient->calls ?? [], + static fn(array $call): bool => str_starts_with($call['path'], '/flags/?') + )); + $this->assertCount(0, $flagRequests); + } + + public function testClientContextIsScopedPerClient(): void + { + $otherHttpClient = new MockedHttpClient('app.posthog.com'); + $otherClient = new Client( + self::FAKE_API_KEY, + ['debug' => true], + $otherHttpClient, + null, + false + ); + + $this->client->withContext([ + 'distinctId' => 'client-a-user', + 'sessionId' => 'client-a-session', + 'properties' => ['client_property' => 'client-a'], + ], function () use ($otherClient): void { + $this->client->capture(['event' => 'client a event']); + $otherClient->capture(['event' => 'client b event']); + }); + + $this->client->flush(); + $otherClient->flush(); + + $clientAEvent = $this->eventsFromLastBatchCall($this->httpClient)[0]; + $clientBEvent = $this->eventsFromLastBatchCall($otherHttpClient)[0]; + + $this->assertSame('client-a-user', $clientAEvent['distinct_id']); + $this->assertSame('client-a-session', $clientAEvent['properties']['$session_id']); + $this->assertSame('client-a', $clientAEvent['properties']['client_property']); + + $this->assertNotSame('client-a-user', $clientBEvent['distinct_id']); + $this->assertFalse($clientBEvent['properties']['$process_person_profile']); + $this->assertArrayNotHasKey('$session_id', $clientBEvent['properties']); + $this->assertArrayNotHasKey('client_property', $clientBEvent['properties']); + } + + /** + * @return array> + */ + private function flushAndGetEvents(): array + { + PostHog::flush(); + return $this->eventsFromLastBatchCall($this->httpClient); + } + + /** + * @return array> + */ + private function eventsFromLastBatchCall(MockedHttpClient $httpClient): array + { + $batchCalls = array_values(array_filter( + $httpClient->calls ?? [], + static fn(array $call): bool => $call['path'] === '/batch/' + )); + $this->assertNotEmpty($batchCalls); + + $lastCall = $batchCalls[array_key_last($batchCalls)]; + $payload = json_decode($lastCall['payload'], true); + $this->assertIsArray($payload); + + return $payload['batch']; + } + + /** + * @return list + */ + private function captureDeprecations(callable $callable): array + { + $messages = []; + $previous = set_error_handler( + function (int $errno, string $errstr) use (&$messages, &$previous) { + if ($errno === E_USER_DEPRECATED) { + $messages[] = $errstr; + return true; + } + if ($previous !== null) { + return ($previous)($errno, $errstr); + } + return false; + }, + E_USER_DEPRECATED + ); + + try { + $callable(); + } finally { + restore_error_handler(); + } + + return $messages; + } +}