Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions lib/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ class Client
*/
private $flagsEtag;

/**
* @var int
*/
private $exceptionSourceContextLines;

/**
* @var bool
*/
Expand Down Expand Up @@ -113,6 +118,7 @@ public function __construct(
(int) ($options['timeout'] ?? 10000)
);
$this->featureFlagsRequestTimeout = (int) ($options['feature_flag_request_timeout_ms'] ?? 3000);
$this->exceptionSourceContextLines = (int) ($options['exception_source_context_lines'] ?? 5);
$this->featureFlags = [];
$this->groupTypeMapping = [];
$this->cohorts = [];
Expand Down Expand Up @@ -178,6 +184,62 @@ public function capture(array $message)
return $this->consumer->capture($message);
}

/**
* Captures an exception as a PostHog error tracking event.
*
* @param \Throwable $exception The exception to capture
* @param string|null $distinctId User distinct ID. If null, generates a UUID and disables person processing.
* @param array $properties Additional properties to include with the event
* @param bool $handled Whether the exception was handled by the application
* @return bool whether the capture call succeeded
*/
public function captureException(
\Throwable $exception,
?string $distinctId = null,
array $properties = [],
bool $handled = true
): bool {
$exceptionList = ExceptionUtils::buildExceptionList($exception, $this->exceptionSourceContextLines);

// Set handled flag on the outermost exception (first entry)
if (!empty($exceptionList)) {
$exceptionList[0]['mechanism']['handled'] = $handled;
}

$properties = array_merge($properties, [
'$exception_list' => $exceptionList,
]);

$captureMessage = [
'event' => '$exception',
'properties' => $properties,
];

if ($distinctId !== null) {
$captureMessage['distinct_id'] = $distinctId;
} else {
// Generate anonymous distinct ID and disable person processing
$captureMessage['distinct_id'] = self::generateUuidV4();
$captureMessage['properties']['$process_person_profile'] = false;
}

return $this->capture($captureMessage);
}

/**
* Generate a UUID v4 string.
*
* @return string
*/
private static function generateUuidV4(): string
{
$data = random_bytes(16);
$data[6] = chr(ord($data[6]) & 0x0f | 0x40); // Version 4
$data[8] = chr(ord($data[8]) & 0x3f | 0x80); // Variant 1

return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}

/**
* Tags properties about the user.
*
Expand Down
174 changes: 174 additions & 0 deletions lib/ExceptionUtils.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<?php

namespace PostHog;

class ExceptionUtils
{
/**
* Build the $exception_list payload from a Throwable.
*
* Walks the getPrevious() chain to produce a flat list
* (outermost first, root cause last).
*
* @param \Throwable $exception
* @param int $sourceContextLines Number of source lines to include around each frame (0 to disable)
* @return array
*/
public static function buildExceptionList(\Throwable $exception, int $sourceContextLines = 5): array
{
// Collect all exceptions in the chain (outermost first)
$exceptions = [];
$current = $exception;
while ($current !== null) {
$exceptions[] = $current;
$current = $current->getPrevious();
}

$isChained = count($exceptions) > 1;
$result = [];

foreach ($exceptions as $index => $exc) {
$entry = [
'type' => get_class($exc),
'value' => $exc->getMessage(),
'mechanism' => [
'type' => $isChained && $index > 0 ? 'chained' : 'generic',
'handled' => true,
],
'stacktrace' => [
'type' => 'raw',
'frames' => self::buildFrames($exc, $sourceContextLines),
],
];

if ($isChained) {
$entry['mechanism']['exception_id'] = $index;
if ($index > 0) {
$entry['mechanism']['parent_id'] = $index - 1;
$entry['mechanism']['source'] = 'getPrevious()';
}
}

$result[] = $entry;
}

return $result;
}

/**
* Build frames from a Throwable's trace.
*
* Returns frames in oldest-first order. Appends the throw location
* as the final frame since PHP's getTrace() omits it.
*
* @param \Throwable $exception
* @param int $sourceContextLines
* @return array
*/
public static function buildFrames(\Throwable $exception, int $sourceContextLines): array
{
$trace = $exception->getTrace();
$frames = [];

// getTrace() returns newest-first; reverse to get oldest-first
foreach (array_reverse($trace) as $traceEntry) {
$frames[] = self::buildFrame($traceEntry, $sourceContextLines);
}

// Append the throw location as the final frame
if ($exception->getFile()) {
$frames[] = self::buildFrame([
'file' => $exception->getFile(),
'line' => $exception->getLine(),
], $sourceContextLines);
}

return $frames;
}

/**
* Build a single frame from a trace entry.
*
* @param array $frame A single trace entry from getTrace() or a synthetic entry
* @param int $sourceContextLines
* @return array
*/
public static function buildFrame(array $frame, int $sourceContextLines): array
{
$filename = $frame['file'] ?? '[internal]';
$lineno = $frame['line'] ?? 0;

// Build function name, prefixing with class if present
$function = $frame['function'] ?? null;
if (!empty($frame['class'])) {
$type = $frame['type'] ?? '->'; // -> for instance, :: for static
$function = $frame['class'] . $type . $function;
}

$result = [
'filename' => $filename,
'lineno' => $lineno,
'platform' => 'php',
'in_app' => self::isInApp($filename),
];

if ($function !== null) {
$result['function'] = $function;
}

// Add source context if enabled and file is readable
if ($sourceContextLines > 0 && $filename !== '[internal]' && $lineno > 0) {
$lines = @file($filename);
if ($lines !== false) {
$lineIndex = $lineno - 1; // Convert to 0-indexed

if (isset($lines[$lineIndex])) {
$result['context_line'] = rtrim($lines[$lineIndex], "\r\n");
}

// pre_context
$preStart = max(0, $lineIndex - $sourceContextLines);
$preContext = [];
for ($i = $preStart; $i < $lineIndex; $i++) {
if (isset($lines[$i])) {
$preContext[] = rtrim($lines[$i], "\r\n");
}
}
$result['pre_context'] = $preContext;

// post_context
$postEnd = min(count($lines), $lineIndex + 1 + $sourceContextLines);
$postContext = [];
for ($i = $lineIndex + 1; $i < $postEnd; $i++) {
if (isset($lines[$i])) {
$postContext[] = rtrim($lines[$i], "\r\n");
}
}
$result['post_context'] = $postContext;
}
}

return $result;
}

/**
* Determine if a frame is "in app" code.
*
* Returns false for [internal] and paths containing /vendor/.
*
* @param string $filename
* @return bool
*/
public static function isInApp(string $filename): bool
{
if ($filename === '[internal]') {
return false;
}

if (str_contains($filename, '/vendor/')) {
return false;
}

return true;
}
}
21 changes: 21 additions & 0 deletions lib/PostHog.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,27 @@ public static function capture(array $message)
return self::$client->capture($message);
}

/**
* Captures an exception as a PostHog error tracking event.
*
* @param \Throwable $exception The exception to capture
* @param string|null $distinctId User distinct ID. If null, generates a UUID and disables person processing.
* @param array $properties Additional properties to include with the event
* @param bool $handled Whether the exception was handled by the application
* @return bool whether the capture call succeeded
* @throws Exception
*/
public static function captureException(
\Throwable $exception,
?string $distinctId = null,
array $properties = [],
bool $handled = true
): bool {
self::checkClient();

return self::$client->captureException($exception, $distinctId, $properties, $handled);
}

/**
* Tags properties about the user.
*
Expand Down
Loading