Skip to content

Commit 9b72b25

Browse files
authored
feat(error-tracking): add PHP error tracking support (#109)
* feat(error-tracking): add initial manual PHP error tracking * feat(error-tracking): automatic PHP exception capture * chore(error-tracking): outermost exception first, top-level 'handled' * chore: code style, simplification * chore: lint, lower frame default for libcurl test * chore: drop line truncation, update payload size test * refactor: simplify php error tracking internals * fix: avoid uncaught exception recursion in error tracking
1 parent a337b8a commit 9b72b25

10 files changed

Lines changed: 2362 additions & 10 deletions

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ Specifically, the [PHP integration](https://posthog.com/docs/integrations/php-in
1010
## Features
1111

1212
- ✅ Event capture and user identification
13+
- ✅ Error tracking with manual exception capture
14+
- ✅ Opt-in automatic PHP exception, error, and fatal shutdown capture
1315
- ✅ Feature flag local evaluation
1416
-**Feature flag dependencies** (new!) - Create conditional flags based on other flags
1517
- ✅ Multivariate flags and payloads
@@ -21,6 +23,40 @@ Specifically, the [PHP integration](https://posthog.com/docs/integrations/php-in
2123
1. Copy `.env.example` to `.env` and add your PostHog credentials
2224
2. Run `php example.php` to see interactive examples of all features
2325

26+
## Error Tracking
27+
28+
Manual exception capture:
29+
30+
```php
31+
PostHog::captureException($exception, 'user-123', [
32+
'$current_url' => 'https://example.com/settings',
33+
]);
34+
```
35+
36+
Opt-in automatic capture from the core SDK:
37+
38+
```php
39+
PostHog::init('phc_xxx', [
40+
'error_tracking' => [
41+
'enabled' => true,
42+
'capture_errors' => true,
43+
'excluded_exceptions' => [
44+
\InvalidArgumentException::class,
45+
],
46+
'context_provider' => static function (array $payload): array {
47+
return [
48+
'distinctId' => $_SESSION['user_id'] ?? null,
49+
'properties' => [
50+
'$current_url' => $_SERVER['REQUEST_URI'] ?? null,
51+
],
52+
];
53+
},
54+
],
55+
]);
56+
```
57+
58+
Auto error tracking is off by default. When enabled, the SDK chains existing exception and error handlers instead of replacing app behavior.
59+
2460
## Questions?
2561

2662
### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ)

example.php

Lines changed: 115 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<?php
22

3+
// phpcs:disable PSR1.Files.SideEffects
4+
35
// PostHog PHP library example
46
//
57
// This script demonstrates various PostHog PHP SDK capabilities including:
@@ -89,9 +91,10 @@ function loadEnvFile()
8991
echo "3. Feature flag dependencies examples\n";
9092
echo "4. Context management and tagging examples\n";
9193
echo "5. ETag polling examples (for local evaluation)\n";
92-
echo "6. Run all examples\n";
93-
echo "7. Exit\n";
94-
$choice = trim(readline("\nEnter your choice (1-7): "));
94+
echo "6. Error tracking examples\n";
95+
echo "7. Run all examples\n";
96+
echo "8. Exit\n";
97+
$choice = trim(readline("\nEnter your choice (1-8): "));
9598

9699
function identifyAndCaptureExamples()
97100
{
@@ -266,7 +269,11 @@ function flagDependencyExamples()
266269
[],
267270
true
268271
);
269-
echo "📊 Beta feature comparison - @example.com: " . json_encode($beta1) . ", regular: " . json_encode($beta2) . "\n";
272+
echo "📊 Beta feature comparison - @example.com: "
273+
. json_encode($beta1)
274+
. ", regular: "
275+
. json_encode($beta2)
276+
. "\n";
270277

271278
echo "\n🎯 Results Summary:\n";
272279
echo " - Flag dependencies evaluated locally: " . ($result1 != $result2 ? "✅ YES" : "❌ NO") . "\n";
@@ -301,7 +308,10 @@ function flagDependencyExamples()
301308
true
302309
);
303310
if ($dependentResult3 !== "breaking-bad") {
304-
echo " ❌ Something went wrong evaluating 'multivariate-root-flag' with pineapple@example.com. Expected 'breaking-bad', got '" . json_encode($dependentResult3) . "'\n";
311+
echo " ❌ Something went wrong evaluating 'multivariate-root-flag' with pineapple@example.com. "
312+
. "Expected 'breaking-bad', got '"
313+
. json_encode($dependentResult3)
314+
. "'\n";
305315
} else {
306316
echo "✅ 'multivariate-root-flag' with email pineapple@example.com succeeded\n";
307317
}
@@ -316,7 +326,10 @@ function flagDependencyExamples()
316326
true
317327
);
318328
if ($dependentResult4 !== "the-wire") {
319-
echo " ❌ Something went wrong evaluating multivariate-root-flag with mango@example.com. Expected 'the-wire', got '" . json_encode($dependentResult4) . "'\n";
329+
echo " ❌ Something went wrong evaluating multivariate-root-flag with mango@example.com. "
330+
. "Expected 'the-wire', got '"
331+
. json_encode($dependentResult4)
332+
. "'\n";
320333
} else {
321334
echo "✅ 'multivariate-root-flag' with email mango@example.com succeeded\n";
322335
}
@@ -490,14 +503,101 @@ function etagPollingExamples()
490503
if ($beforeEtag === $afterEtag && $beforeEtag !== null) {
491504
echo "No change (304 Not Modified) - $currentFlagCount flag(s)\n";
492505
} else {
493-
echo "🔄 Flags updated! New ETag: " . ($afterEtag ? substr($afterEtag, 0, 20) . "..." : "none") . " - $currentFlagCount flag(s)\n";
506+
echo "🔄 Flags updated! New ETag: "
507+
. ($afterEtag ? substr($afterEtag, 0, 20) . "..." : "none")
508+
. " - $currentFlagCount flag(s)\n";
494509
}
495510

496511
$iteration++;
497512
sleep(5);
498513
}
499514
}
500515

516+
function errorTrackingExamples()
517+
{
518+
echo "\n" . str_repeat("=", 60) . "\n";
519+
echo "ERROR TRACKING EXAMPLES\n";
520+
echo str_repeat("=", 60) . "\n";
521+
522+
PostHog::init(
523+
$_ENV['POSTHOG_PROJECT_API_KEY'],
524+
[
525+
'host' => $_ENV['POSTHOG_HOST'] ?? 'https://app.posthog.com',
526+
'debug' => true,
527+
'ssl' => !str_starts_with($_ENV['POSTHOG_HOST'] ?? 'https://app.posthog.com', 'http://'),
528+
'error_tracking' => [
529+
'enabled' => true,
530+
'context_provider' => static function (array $payload): array {
531+
return [
532+
'distinctId' => 'sdk-demo-user',
533+
'properties' => [
534+
'$error_source' => $payload['source'],
535+
],
536+
];
537+
},
538+
],
539+
],
540+
null,
541+
$_ENV['POSTHOG_PERSONAL_API_KEY']
542+
);
543+
544+
echo "Auto capture enabled for uncaught exceptions, PHP errors, and fatal shutdown errors.\n";
545+
echo "The demo below still uses manual capture so it can finish without crashing the process.\n\n";
546+
547+
// 1. Capture a plain string error (no user context)
548+
echo "1. Capturing anonymous string error...\n";
549+
PostHog::captureException('Something went wrong during startup');
550+
echo " -> sent with auto-generated distinct_id, \$process_person_profile=false\n\n";
551+
552+
// 2. Capture an exception for a known user
553+
echo "2. Capturing exception for a known user...\n";
554+
try {
555+
throw new \RuntimeException('Database connection failed');
556+
} catch (\RuntimeException $e) {
557+
PostHog::captureException($e, 'user-123');
558+
}
559+
echo " -> sent as \$exception event with stacktrace\n\n";
560+
561+
// 3. Capture with additional context properties
562+
echo "3. Capturing exception with request context...\n";
563+
try {
564+
throw new \InvalidArgumentException('Invalid email address provided');
565+
} catch (\InvalidArgumentException $e) {
566+
PostHog::captureException($e, 'user-456', [
567+
'$current_url' => 'https://example.com/signup',
568+
'$request_method' => 'POST',
569+
'form_field' => 'email',
570+
]);
571+
}
572+
echo " -> sent with URL and request context\n\n";
573+
574+
// 4. Capture a chained exception (cause + wrapper both appear in \$exception_list)
575+
echo "4. Capturing chained exception...\n";
576+
try {
577+
try {
578+
throw new \PDOException('SQLSTATE[HY000]: General error: disk full');
579+
} catch (\PDOException $cause) {
580+
throw new \RuntimeException('Failed to save user record', 0, $cause);
581+
}
582+
} catch (\RuntimeException $e) {
583+
PostHog::captureException($e, 'user-789');
584+
}
585+
echo " -> sent with 2 entries in \$exception_list (cause + wrapper)\n\n";
586+
587+
// 5. Capture a PHP Error (not just Exception)
588+
echo "5. Capturing a TypeError (PHP Error subclass)...\n";
589+
try {
590+
$result = array_sum('not-an-array');
591+
} catch (\TypeError $e) {
592+
PostHog::captureException($e, 'user-123');
593+
}
594+
echo " -> any Throwable (Error or Exception) is accepted\n\n";
595+
596+
PostHog::flush();
597+
echo "Flushed all events.\n";
598+
echo "Check your PostHog dashboard -> Error Tracking to see the captured exceptions.\n";
599+
}
600+
501601
function runAllExamples()
502602
{
503603
identifyAndCaptureExamples();
@@ -510,6 +610,9 @@ function runAllExamples()
510610
echo "\n" . str_repeat("-", 60) . "\n";
511611

512612
contextManagementExamples();
613+
echo "\n" . str_repeat("-", 60) . "\n";
614+
615+
errorTrackingExamples();
513616

514617
echo "\n🎉 All examples completed!\n";
515618
echo " (ETag polling skipped - run separately with option 5)\n";
@@ -533,13 +636,16 @@ function runAllExamples()
533636
etagPollingExamples();
534637
break;
535638
case '6':
536-
runAllExamples();
639+
errorTrackingExamples();
537640
break;
538641
case '7':
642+
runAllExamples();
643+
break;
644+
case '8':
539645
echo "👋 Goodbye!\n";
540646
exit(0);
541647
default:
542-
echo "❌ Invalid choice. Please run the script again and choose 1-7.\n";
648+
echo "❌ Invalid choice. Please run the script again and choose 1-8.\n";
543649
exit(1);
544650
}
545651

lib/Client.php

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ class Client
8383
*/
8484
private $debug;
8585

86+
/**
87+
* @var array<string, mixed>
88+
*/
89+
private $options;
90+
8691
/**
8792
* Create a new posthog object with your app's API key
8893
* key
@@ -100,6 +105,7 @@ public function __construct(
100105
) {
101106
$this->apiKey = $apiKey;
102107
$this->personalAPIKey = $personalAPIKey;
108+
$this->options = $options;
103109
$this->debug = $options["debug"] ?? false;
104110
$Consumer = self::CONSUMERS[$options["consumer"] ?? "lib_curl"];
105111
$this->consumer = new $Consumer($apiKey, $options, $httpClient);
@@ -120,6 +126,8 @@ public function __construct(
120126
$this->distinctIdsFeatureFlagsReported = new SizeLimitedHash(SIZE_LIMIT);
121127
$this->flagsEtag = null;
122128

129+
ExceptionCapture::configure($this, $options['error_tracking'] ?? []);
130+
123131
// Populate featureflags and grouptypemapping if possible
124132
if (
125133
count($this->featureFlags) == 0
@@ -155,7 +163,8 @@ public function capture(array $message)
155163
$flags = [];
156164

157165
if (count($this->featureFlags) != 0) {
158-
# Local evaluation is enabled, flags are loaded, so try and get all flags we can without going to the server
166+
// Local evaluation is enabled, flags are loaded, so try and get all flags
167+
// we can without going to the server.
159168
$flags = $this->getAllFlags($message["distinct_id"], $message["groups"], [], [], true);
160169
} else {
161170
$flags = $this->fetchFeatureVariants($message["distinct_id"], $message["groups"]);
@@ -178,6 +187,51 @@ public function capture(array $message)
178187
return $this->consumer->capture($message);
179188
}
180189

190+
/**
191+
* Captures an exception as a PostHog error tracking event.
192+
*
193+
* @param \Throwable|string $exception The exception to capture or a plain string message
194+
* @param string|null $distinctId User ID; a random UUID is used when omitted (no person profile created)
195+
* @param array $additionalProperties Extra properties merged into the event
196+
* @return bool whether the capture call succeeded
197+
*/
198+
public function captureException(
199+
\Throwable|string $exception,
200+
?string $distinctId = null,
201+
array $additionalProperties = []
202+
): bool {
203+
$noDistinctIdProvided = $distinctId === null;
204+
if ($noDistinctIdProvided) {
205+
$distinctId = Uuid::v4();
206+
}
207+
208+
$errorTrackingConfig = $this->options['error_tracking'] ?? [];
209+
$maxFrames = max(0, (int) ($errorTrackingConfig['max_frames'] ?? 20));
210+
211+
$exceptionList = ExceptionPayloadBuilder::buildExceptionList($exception, $maxFrames);
212+
if (empty($exceptionList)) {
213+
return false;
214+
}
215+
216+
$properties = array_merge(
217+
$additionalProperties,
218+
[
219+
'$exception_list' => $exceptionList,
220+
'$exception_handled' => ExceptionPayloadBuilder::getPrimaryHandled($exceptionList),
221+
]
222+
);
223+
224+
if ($noDistinctIdProvided) {
225+
$properties['$process_person_profile'] = false;
226+
}
227+
228+
return $this->capture([
229+
'distinctId' => $distinctId,
230+
'event' => '$exception',
231+
'properties' => $properties,
232+
]);
233+
}
234+
181235
/**
182236
* Tags properties about the user.
183237
*

0 commit comments

Comments
 (0)