diff --git a/.laravel-changes/eb4dfed4373d440294ff58970818d3e7.json b/.laravel-changes/eb4dfed4373d440294ff58970818d3e7.json new file mode 100644 index 0000000..0721ba9 --- /dev/null +++ b/.laravel-changes/eb4dfed4373d440294ff58970818d3e7.json @@ -0,0 +1,5 @@ +{ + "package": "posthog-laravel", + "bump": "initial", + "summary": "Add opt-in Laravel request, auth, domain, model and worker event collectors." +} diff --git a/packages/laravel/AUTOCAPTURE.md b/packages/laravel/AUTOCAPTURE.md new file mode 100644 index 0000000..da0f0ab --- /dev/null +++ b/packages/laravel/AUTOCAPTURE.md @@ -0,0 +1,216 @@ +# Optional server event collectors + +These are **server events**, not UI interaction autocapture. The package does not +observe clicks or emit `$autocapture`. All collectors and Blade flag conditionals +are off by default. The main `PostHogServiceProvider` discovers the separate +`PostHog\Laravel\AutocaptureServiceProvider` automatically. Register only the main +provider; registering the collector provider again does not add duplicate listeners. + +The collector classes and their service provider are internal implementation details, +not supported extension points. The documented configuration, publish tag and +`@posthogFeature` Blade conditional are the supported integration surface. + +Collectors skip context resolution and model buffering when PostHog is disabled or +its API key is empty. `PostHog::fake()` still enables collection for assertions. + +With `posthog.sdk.debug=true`, collector failures log at most once per collector +site per Laravel container scope through Laravel's logger. Diagnostics include +only the collector site and exception class, not exception messages or event data. +Logger failures never escape into the application. + +## Configuration + +Publish the optional configuration: + +```sh +php artisan vendor:publish --tag=posthog-autocapture-config +``` + +Edit `config/posthog-autocapture.php`. The complete defaults are in +[`config/autocapture.php`](config/autocapture.php). These settings are merged into +`posthog.autocapture`; values explicitly set under `autocapture` in +`config/posthog.php` take precedence **per top-level section**. When overriding a +section there, include its complete settings. Both approaches support config caching. +Use class names, not closures, in configuration. + +```php +return [ + 'requests' => [ + 'enabled' => true, + 'exclude' => ['up', 'health', 'health/*', 'assets/*', 'build/*', '*.css', '*.js'], + 'sample_rate' => 1.0, + ], + 'pageviews' => ['enabled' => false], + 'auth' => [ + 'enabled' => true, + 'events' => ['registered', 'login', 'logout'], + ], + 'models' => [ + 'enabled' => true, + 'allow' => [App\Models\Order::class => ['state']], + ], + 'domain_events' => [ + App\Events\OrderCompleted::class => [ + 'event' => 'order_completed', + 'properties' => ['amount' => 'amount', 'currency' => 'details.currency'], + ], + ], + 'jobs' => ['enabled' => true, 'exclude' => [App\Jobs\InternalMaintenance::class]], + 'commands' => ['enabled' => false, 'exclude' => ['posthog:*']], + 'scheduler' => ['enabled' => false, 'exclude' => []], + 'blade' => ['enabled' => false], +]; +``` + +Every `enabled` default is `false`. `domain_events` and `models.allow` default to +empty arrays. Auth defaults include only registered/login/logout; add `failed` and +`lockout` explicitly to capture those outcomes. `exclude` patterns use Laravel's +case-sensitive `Str::is` wildcard matching. Request exclusions match the route +and concrete path **only for filtering**, without capturing that concrete path. +Defaults exclude `up`, `health`, `health/*`, `build/*`, `assets/*`, `vendor/*`, +`favicon.ico`, `robots.txt`, and common asset extensions; add your application's +other health/static routes. Unmatched routes are never captured. Sampling clamps +`requests.sample_rate` to 0–1 and makes one decision per request for both summary +and pageview events. Zero drops both; the default 1 captures every eligible request. + +### Privacy and identity + +Collectors emit only the properties listed below, plus normal SDK metadata and +explicit identity, session correlation and groups. Arbitrary context properties are not copied. Collection +never reads request bodies, query values, cookies, credentials, email, IP addresses, +job arguments or full model/event payloads. Auth uses `getAuthIdentifier()`; configure +an opaque identifier rather than email if your application customizes that method. +Application-selected attributes/property mappings may contain PII: allowlist only +fields you intend to send and use `sdk.before_send` for additional redaction. + +Events require an existing analytics identity; collectors do not invent anonymous +IDs. HTTP uses the runtime's lazy authenticated identity/header fallback. Queue jobs +use the runtime's bounded queue context propagation or explicit job context. Console +and scheduler events need explicit context or a configured identity resolver. Failed +login/lockout events may use existing anonymous context but **never** the attempted +user identifier. Missing credentials or disabled SDK configuration cannot send traffic. +Failures in analytics do not change application responses or replace original errors. + +## Exact event schemas + +The following properties are collector-owned. `duration_ms` is a nonnegative float +measured by a monotonic clock, not a trace/span. No request IDs or parameters are +added implicitly. Each event passes through the ordinary PHP capture pipeline. + +| Event | Properties | +| --- | --- | +| `laravel_request` | `route: string`, `method: string`, `status_code: int`, `duration_ms: float` | +| `$pageview` | Request properties plus `$current_url: string`, `$pathname: string` | +| `laravel_auth_registered` | Empty properties | +| `laravel_auth_login` | `guard: string` when supplied | +| `laravel_auth_logout` | `guard: string` when supplied | +| `laravel_auth_failed` | `guard: string` when supplied; no credentials or attempted ID | +| `laravel_auth_lockout` | Empty properties; no request/credentials/attempted ID | +| `laravel_model_created` | `model: string` (exact class), `attributes: array` (only allowlisted JSON-safe values) | +| `laravel_model_updated` | Same as created; selected values at update time, not a before/after diff | +| `laravel_model_deleted` | Same as created; selected values at deletion time | +| Configured domain event name | Only explicitly mapped output names and JSON-safe values | +| `laravel_job` | `name: string` (queued job class, not display name), `outcome: 'success'\|'failed'`, `attempts: int`, `duration_ms: float` | +| `laravel_command` | `name: string` (first command token only), `outcome: 'success'\|'failed'`, `exit_code: int`, `duration_ms: float` | +| `laravel_scheduled_task` | `name: string` (task PHP class only), `outcome: 'success'\|'failed'\|'background_started'`, `exit_code: int\|null`, `duration_ms: float` | + +### Requests and server pageviews + +`route` and `$pathname` are normalized matched route templates with a leading `/`, +for example `/orders/{order}`, never `/orders/123`. Request summaries include all +methods and status codes for eligible matched routes. Escaping middleware errors +are summarized as 500 and rethrown unchanged; framework-rendered errors use the +response status. Streaming duration measures response creation, not body delivery. + +Pageviews are a **separate opt-in** and require a matched route, HTML Content-Type, +GET method, and 2xx status. They can be enabled without request summaries. The +`$current_url` is the sanitized configured `app.url` origin (HTTP/HTTPS scheme, +host and optional port only) followed by the route template. Credentials, paths +and queries in `app.url` are discarded; the request Host header is not trusted. +Invalid configured origins suppress pageviews. + +**Browser duplication warning:** disable server pageviews if a browser SDK already +captures the same pageviews, or disable equivalent browser pageviews. No automatic +cross-SDK deduplication is provided. Server pageviews do not track client-side +navigation, browser lifecycle or actual browser rendering. + +### Models and transactions + +Only exact allowlisted Eloquent classes are collected for created/updated/deleted +lifecycle events. An empty attribute list emits class metadata with an empty +`attributes` array—no implicit primary key, relation, dirty attributes or `toArray()`. +Only existing attributes are read, with their normal casts/accessors; JSON-safe +scalar/null/array values are accepted, objects/resources/invalid JSON values are +omitted. Do not allowlist a sensitive accessor or JSON column. + +Values, identity, session and groups are snapshotted at the change, then emitted **after the +model connection's outer transaction commits**. Rollback (including a nested +savepoint rollback) discards the corresponding pending events. No model or request +object is retained. Outside a transaction, collection is immediate, even when a +different connection has an open transaction. + +In tests using Laravel's `DatabaseTransactions` or `RefreshDatabase`, collection +follows Laravel's after-commit rules: the test-wrapper transaction alone does not +delay capture, while application-owned nested transactions still defer until +commit and discard rolled-back changes. + +All connections share an internal pending budget of **1,000 events and 1 MiB of +JSON-encoded event names, properties and correlation context**. If either limit +would be exceeded, the newest event is dropped (including an oversized allowlisted +attribute); there are no additional configuration options. Events without a usable +identity consume no pending capacity. Commit and rollback release capacity; +abandoned/disconnected transactions are also discarded before the next admission. +Callback registration happens only after admission, once per transaction record, +and is also capped at 1,000 records (including stale records retained by Laravel +after a connection reset). Admitted events retain their original identity and +operation order within each connection's outer transaction. + +This is not a durable outbox; process crashes or transport failures can lose events. +Each admitted model lifecycle event generates one event: multiple saves are not +coalesced. Bulk query-builder updates/deletes do not produce model events. Soft +deletes produce the normal deleted event; restores are not collected. + +### Domain events + +Mappings select exact event classes and named public property/array-key paths. +No wildcard subscriptions, getter calls, magic model attribute access, model +serialization or automatic inheritance matching are performed. Missing paths and +non-JSON-safe values are omitted. Domain collection is immediate, including inside +a transaction; use Laravel's own after-commit event dispatch when the domain event +must reflect committed state. Mapping is read at provider boot, so rebuild cached +config/restart long-running workers after changing it. + +### Worker outcomes and duplication + +Jobs emit once per started attempt; a retry is another attempt, not deduplicated +against the previous failure. Repeated completion events for an already completed +attempt are ignored. Command metadata excludes all arguments/options. Scheduler +names deliberately use task PHP classes, not descriptions or command lines. A +foreground task emits one outcome even when Laravel emits Finished then Failed for +a nonzero exit. Background launch emits `background_started`, **not completion**; +there is no cross-process completion/heartbeat guarantee. Skipped scheduled tasks +and hard-killed jobs/commands without a terminal framework event are not captured. +Weak object keys retain neither worker payloads nor completed execution objects. + +Enabling several collectors intentionally produces different events for different +boundaries: a request that logs in a user and saves a model can produce request, +auth, and model events. Explicit application captures remain independent. The +package does not deduplicate these against your manual instrumentation. + +## Optional Blade flag conditionals + +Enable `blade.enabled`, then use: + +```blade +@posthogFeature('new-checkout') + +@else + +@endposthogFeature +``` + +This reads `evaluateFlags()->isEnabled()` using the runtime's existing context +snapshot. Repeated checks share evaluation and core exposure deduplication; a new +identity/context evaluates separately. Disabled/unavailable flags fail closed. +There is no boot-time evaluation, payload read, independent flag cache, or custom +exposure event. This conditional is a presentation helper, **not authorization**. diff --git a/packages/laravel/README.md b/packages/laravel/README.md index 4a2915c..2a083a2 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -298,8 +298,13 @@ The diagnostic event excludes ambient identity, session, groups and request/user properties without changing the caller's context. Application-owned `before_send` callbacks still run and may deliberately enrich or drop it. -No collectors are enabled by this foundation package. Optional collectors can be -registered by a separate `AutocaptureServiceProvider` when installed. +Optional server event collectors are included but **all are off by default**. +The main provider discovers the separate `AutocaptureServiceProvider` automatically. +See [collector configuration and exact event schemas](AUTOCAPTURE.md) for request +summaries, separately enabled server HTML pageviews, auth outcomes, explicit domain +mappings, after-commit model events, worker outcomes, and Blade flag conditionals. +Server pageviews can duplicate browser SDK pageviews: enable only one equivalent +source. These collectors do not observe UI interactions or emit `$autocapture`. ## Testing diff --git a/packages/laravel/config/autocapture.php b/packages/laravel/config/autocapture.php new file mode 100644 index 0000000..17d3636 --- /dev/null +++ b/packages/laravel/config/autocapture.php @@ -0,0 +1,23 @@ + [ + 'enabled' => false, + 'exclude' => [ + 'up', 'health', 'health/*', 'build/*', 'assets/*', 'vendor/*', 'favicon.ico', 'robots.txt', + '*.css', '*.js', '*.map', '*.png', '*.jpg', '*.svg', '*.ico', '*.woff*', + ], + 'sample_rate' => 1.0, + ], + // Server HTML pageviews can duplicate browser SDK pageviews. Enable only one source. + 'pageviews' => ['enabled' => false], + 'auth' => ['enabled' => false, 'events' => ['registered', 'login', 'logout']], + // Exact model class => approved attribute names. Empty lists capture class metadata only. + 'models' => ['enabled' => false, 'allow' => []], + // Exact event class => ['event' => 'name', 'properties' => ['output' => 'public.property.path']]. + 'domain_events' => [], + 'jobs' => ['enabled' => false, 'exclude' => []], + 'commands' => ['enabled' => false, 'exclude' => []], + 'scheduler' => ['enabled' => false, 'exclude' => []], + 'blade' => ['enabled' => false], +]; diff --git a/packages/laravel/src/Autocapture/AuthEvents.php b/packages/laravel/src/Autocapture/AuthEvents.php new file mode 100644 index 0000000..f7eeaa0 --- /dev/null +++ b/packages/laravel/src/Autocapture/AuthEvents.php @@ -0,0 +1,35 @@ +get( + 'posthog.autocapture.auth.events', + [], + ), true) + ) { + return; + } + try { + $snapshot = Collector::snapshot(); + if (!in_array($name, ['failed', 'lockout'], true)) { + $id = $event->user?->getAuthIdentifier(); + if (is_scalar($id) && (string) $id !== '') { + $snapshot['distinctId'] = (string) $id; + } + } + $properties = isset($event->guard) && is_string($event->guard) ? ['guard' => $event->guard] : []; + Collector::capture('laravel_auth_' . $name, $properties, $snapshot); + } catch (Throwable $exception) { + Collector::logFailure(__METHOD__, $exception); + } + } +} diff --git a/packages/laravel/src/Autocapture/Collector.php b/packages/laravel/src/Autocapture/Collector.php new file mode 100644 index 0000000..c0e5389 --- /dev/null +++ b/packages/laravel/src/Autocapture/Collector.php @@ -0,0 +1,89 @@ +get('posthog.autocapture.' . $section . '.enabled', false)) + && $app->make(PostHog::class)->enabled(); + } + + public static function snapshot(): array + { + return array_intersect_key(Container::getInstance()->make(Context::class)->snapshot(), [ + 'distinctId' => true, 'sessionId' => true, 'groups' => true, + ]); + } + + public static function capture(string $event, array $properties, ?array $snapshot = null): void + { + $context = null; + try { + $app = Container::getInstance(); + if (!self::enabled()) { + return; + } + $snapshot ??= self::snapshot(); + $id = $snapshot['distinctId'] ?? null; + if (!is_scalar($id) || (string) $id === '') { + return; + } + $context = $app->make(Context::class); + $context->enter(); + $context->begin($snapshot); + $app->make(PostHog::class)->capture([ + 'event' => $event, 'properties' => $properties, + 'distinctId' => (string) $id, 'groups' => $snapshot['groups'] ?? [], + ]); + } catch (Throwable $exception) { + self::logFailure(__METHOD__, $exception); + } finally { + $context?->leave(); + } + } + + public static function logFailure(string $collector, Throwable $exception): void + { + try { + $app = Container::getInstance(); + if (!$app['config']->get('posthog.sdk.debug', false)) { + return; + } + $instance = $app->make(self::class); + if (isset($instance->loggedFailures[$collector])) { + return; + } + $instance->loggedFailures[$collector] = true; + $app->make('log')->debug('[PostHog] Automatic event collection failed.', [ + 'collector' => $collector, 'exception_class' => $exception::class, + ]); + } catch (Throwable) { + } + } + + public static function jsonSafe(mixed $value, int $depth = 0): bool + { + if ($depth > 16 || is_object($value) || is_resource($value)) { + return false; + } + if (is_array($value)) { + foreach ($value as $item) { + if (!self::jsonSafe($item, $depth + 1)) { + return false; + } + } + } + return json_encode($value) !== false; + } +} diff --git a/packages/laravel/src/Autocapture/DeferredModelEvents.php b/packages/laravel/src/Autocapture/DeferredModelEvents.php new file mode 100644 index 0000000..13cbba5 --- /dev/null +++ b/packages/laravel/src/Autocapture/DeferredModelEvents.php @@ -0,0 +1,235 @@ + + */ + private array $pending = []; + private int $bytes = 0; + /** @var WeakMap Number of pending events per registered record. */ + private WeakMap $records; + /** @var WeakMap Only connections with admitted events. */ + private WeakMap $connections; + + public function __construct(private DatabaseTransactionsManager $transactions) + { + $this->records = new WeakMap(); + $this->connections = new WeakMap(); + } + + public function defer(Connection $connection, string $event, array $properties, array $snapshot): void + { + $this->prune(); + // Connection::afterCommit forwards to a shared manager without a connection name. + $matches = fn ($record) => $record->connection === $connection->getName() + && $record->level === $connection->transactionLevel(); + $record = $this->transactions->callbackApplicableTransactions()->last($matches); + if ($record === null) { + // Laravel's testing manager excludes test-wrapper transactions from commit callbacks. + if ($this->transactions->getPendingTransactions()->contains($matches)) { + Collector::capture($event, $properties, $snapshot); + } + return; + } + if (count($this->pending) >= self::MAX_EVENTS) { + return; + } + $encoded = json_encode([$event, $properties, $snapshot], JSON_PRESERVE_ZERO_FRACTION); + if ($encoded === false || strlen($encoded) > self::MAX_BYTES - $this->bytes) { + return; + } + // Laravel 12 can retain old records after a PDO reset, even after their payloads are discarded. + if (!isset($this->records[$record]) && count($this->records) >= self::MAX_EVENTS) { + return; + } + $root = $record; + // A reset can leave an older level-1 record as parent; it is not this transaction's root. + while ($root->parent !== null && $root->parent->level < $root->level) { + $root = $root->parent; + } + $rootReference = WeakReference::create($root); + if (!isset($this->records[$record])) { + // Admission precedes registration: rejected writes cannot grow callback bookkeeping. + // Flush this root's batch even if an earlier application callback starts another transaction. + $record->addCallback(function () use ($rootReference) { + if (($root = $rootReference->get()) !== null) { + $this->flush($root); + } + }); + $this->records[$record] = 0; + } + $this->records[$record]++; + $this->connections[$connection] = [ + 'root' => $rootReference, + 'count' => ($this->connections[$connection]['count'] ?? 0) + 1, + ]; + $this->pending[] = [ + 'connection' => WeakReference::create($connection), + 'pdo' => WeakReference::create($connection->getRawPdo()), + 'transaction' => WeakReference::create($record), + 'root' => $rootReference, + 'encoded' => $encoded, + ]; + $this->bytes += strlen($encoded); + } + + public function beginning(TransactionBeginning $event): void + { + $this->prune(); + $connection = $event->connection; + if (!isset($this->connections[$connection])) { + return; + } + $root = $this->transactions->getPendingTransactions()->last( + fn ($record) => $record->connection === $connection->getName(), + ); + if ($root === null) { + return; + } + while ($root->parent !== null && $root->parent->level < $root->level) { + $root = $root->parent; + } + if ($connection->transactionLevel() === 1) { + $managed = $this->managedTransactions(); + foreach ($this->pending as $key => $entry) { + $olderRoot = $entry['root']->get(); + if ( + $entry['connection']->get() === $connection && $olderRoot !== $root + && $olderRoot !== null && isset($managed[$olderRoot]) + ) { + // Laravel 12 commit retries can leave rolled-back roots managed on the same PDO. + // Unmanaged roots may still be delivering successful commit callbacks; keep them. + $this->remove($key); + } + } + } + if (!isset($this->connections[$connection])) { + return; + } + // A callback may start/roll back a new root while an older committed root is awaiting its callback. + $state = $this->connections[$connection]; + $state['root'] = WeakReference::create($root); + $this->connections[$connection] = $state; + } + + public function committing(TransactionCommitting $event): void + { + $this->prune(); + } + + public function rolledBack(TransactionRolledBack $event): void + { + $state = $this->connections[$event->connection] ?? null; + $this->prune($state === null ? null : $state['root']->get()); + } + + /** @return array{count: int, bytes: int} */ + public function pendingUsage(): array + { + return ['count' => count($this->pending), 'bytes' => $this->bytes]; + } + + private function flush(DatabaseTransactionRecord $root): void + { + $ready = []; + foreach ($this->pending as $key => $entry) { + if ($entry['root']->get() === $root) { + $ready[] = $entry['encoded']; + $this->remove($key); + } + } + $this->prune(); + // Release capacity before capture and preserve operation order across nested commits. + foreach ($ready as $encoded) { + [$name, $properties, $snapshot] = json_decode($encoded, true); + Collector::capture($name, $properties, $snapshot); + } + } + + private function managedTransactions(): WeakMap + { + $managed = new WeakMap(); + foreach ( + [$this->transactions->getPendingTransactions(), $this->transactions->getCommittedTransactions()] as $set + ) { + foreach ($set as $record) { + $managed[$record] = true; + } + } + return $managed; + } + + private function prune(?DatabaseTransactionRecord $rolledBackRoot = null): void + { + if ($this->pending === [] && count($this->records) === 0) { + return; + } + $managed = $this->managedTransactions(); + foreach ($this->pending as $key => $entry) { + $connection = $entry['connection']->get(); + $record = $entry['transaction']->get(); + $root = $entry['root']->get(); + if ( + $connection === null || $connection->getRawPdo() === null + || $entry['pdo']->get() !== $connection->getRawPdo() + || $record === null || $root === null + || ($rolledBackRoot !== null && $root === $rolledBackRoot && !isset($managed[$record])) + || (!isset($managed[$record]) && isset($managed[$root])) + || ($connection->transactionLevel() === 0 && isset($managed[$record])) + ) { + $this->remove($key); + } + // Missing records with a live root can be in Laravel's commit-callback stack. + // Only their own record callback may publish them; another connection cannot discard them. + } + // Keep zero-payload registrations counted until Laravel releases their records. + foreach ($this->records as $record => $count) { + if ($count === 0 && !isset($managed[$record])) { + unset($this->records[$record]); + } + } + } + + private function remove(int $key): void + { + $entry = $this->pending[$key]; + $this->bytes -= strlen($entry['encoded']); + $record = $entry['transaction']->get(); + if ($record !== null && isset($this->records[$record])) { + --$this->records[$record]; + } + $connection = $entry['connection']->get(); + if ($connection !== null && isset($this->connections[$connection])) { + $state = $this->connections[$connection]; + if (--$state['count'] === 0) { + unset($this->connections[$connection]); + } else { + $this->connections[$connection] = $state; + } + } + unset($this->pending[$key]); + if ($this->pending === []) { + // Reset PHP's next numeric index when a long-lived worker drains a batch. + $this->pending = []; + } + } +} diff --git a/packages/laravel/src/Autocapture/DomainEvents.php b/packages/laravel/src/Autocapture/DomainEvents.php new file mode 100644 index 0000000..6283ace --- /dev/null +++ b/packages/laravel/src/Autocapture/DomainEvents.php @@ -0,0 +1,42 @@ + $path) { + if (!is_string($name) || !is_string($path)) { + continue; + } + $value = $event; + foreach (explode('.', $path) as $part) { + // Public properties and array keys only: never invoke model magic or getters. + $values = is_object($value) ? get_object_vars($value) : $value; + if (!is_array($values) || !array_key_exists($part, $values)) { + continue 2; + } + $value = $values[$part]; + } + if (Collector::jsonSafe($value)) { + $properties[$name] = $value; + } + } + Collector::capture($mapping['event'], $properties); + } catch (Throwable $exception) { + Collector::logFailure(__METHOD__, $exception); + } + } +} diff --git a/packages/laravel/src/Autocapture/ModelEvents.php b/packages/laravel/src/Autocapture/ModelEvents.php new file mode 100644 index 0000000..6824644 --- /dev/null +++ b/packages/laravel/src/Autocapture/ModelEvents.php @@ -0,0 +1,54 @@ +get('posthog.autocapture.models.allow', []); + if (!array_key_exists($model::class, $models) || !is_array($models[$model::class])) { + return; + } + $snapshot = Collector::snapshot(); + $id = $snapshot['distinctId'] ?? null; + if (!is_scalar($id) || (string) $id === '' || !Collector::jsonSafe($snapshot)) { + return; + } + $attributes = []; + $stored = $model->getAttributes(); + foreach ($models[$model::class] as $key) { + if (is_string($key) && array_key_exists($key, $stored)) { + $value = $model->getAttribute($key); + if (Collector::jsonSafe($value)) { + $attributes[$key] = $value; + } + } + } + $properties = ['model' => $model::class, 'attributes' => $attributes]; + $connection = $model->getConnection(); + $event = 'laravel_model_' . $operation; + if ($connection->transactionLevel() === 0) { + Collector::capture($event, $properties, $snapshot); + return; + } + Container::getInstance()->make(DeferredModelEvents::class)->defer( + $connection, + $event, + $properties, + $snapshot, + ); + } catch (Throwable $exception) { + Collector::logFailure(__METHOD__, $exception); + } + } +} diff --git a/packages/laravel/src/Autocapture/Outcomes.php b/packages/laravel/src/Autocapture/Outcomes.php new file mode 100644 index 0000000..2684d8d --- /dev/null +++ b/packages/laravel/src/Autocapture/Outcomes.php @@ -0,0 +1,95 @@ +starts = new WeakMap(); + $this->scheduled = new WeakMap(); + $this->executed = new WeakMap(); + } + + public function start(string $section, object $key): void + { + if (Collector::enabled($section)) { + $this->starts[$key] = hrtime(true); + } + } + + public function startScheduler(Event $task): void + { + if (!Collector::enabled('scheduler')) { + return; + } + $this->start('scheduler', $task); + $this->executed[$task] = false; + if (!isset($this->scheduled[$task])) { + $this->scheduled[$task] = true; + // The task owns this callback, so it must not capture the task strongly. + $reference = WeakReference::create($task); + $executed = $this->executed; + $task->before(static function () use ($reference, $executed): void { + if (($task = $reference->get()) !== null) { + $executed[$task] = true; + } + }); + } + } + + public function finishScheduler(Event $task, bool $failed): void + { + $executed = $this->executed[$task] ?? false; + unset($this->executed[$task]); + $this->finish('scheduler', $task, static fn () => $failed || $executed ? [ + 'name' => $task::class, + 'outcome' => $failed ? 'failed' : ($task->runInBackground ? 'background_started' + : ($task->exitCode === 0 ? 'success' : 'failed')), + 'exit_code' => $task->exitCode, + ] : []); + } + + public function finish(string $section, object $key, callable $metadata): void + { + if (!isset($this->starts[$key])) { + return; + } + $started = $this->starts[$key]; + unset($this->starts[$key]); + try { + if (!Collector::enabled($section)) { + return; + } + $properties = $metadata(); + if (!is_string($properties['name'] ?? null) || $properties['name'] === '') { + return; + } + $excluded = Container::getInstance()['config']->get('posthog.autocapture.' . $section . '.exclude', []); + foreach ($excluded as $pattern) { + if (Str::is($pattern, $properties['name'])) { + return; + } + } + $properties['duration_ms'] = (hrtime(true) - $started) / 1e6; + $event = [ + 'jobs' => 'laravel_job', 'commands' => 'laravel_command', 'scheduler' => 'laravel_scheduled_task', + ][$section]; + Collector::capture($event, $properties); + } catch (Throwable $exception) { + Collector::logFailure(__METHOD__, $exception); + } + } +} diff --git a/packages/laravel/src/Autocapture/RequestSummary.php b/packages/laravel/src/Autocapture/RequestSummary.php new file mode 100644 index 0000000..72c33da --- /dev/null +++ b/packages/laravel/src/Autocapture/RequestSummary.php @@ -0,0 +1,82 @@ +collect($request, $response, $started); + } catch (Throwable $exception) { + Collector::logFailure(__METHOD__, $exception); + } + } + } + + private function collect(Request $request, mixed $response, int $started): void + { + if (!Collector::enabled('requests') && !Collector::enabled('pageviews')) { + return; + } + $route = $request->route(); + if (!$route instanceof Route) { + return; + } + $template = '/' . ltrim($route->uri(), '/'); + $config = Container::getInstance()['config']; + foreach ($config->get('posthog.autocapture.requests.exclude', []) as $pattern) { + $pattern = ltrim($pattern, '/'); + if (Str::is($pattern, ltrim($template, '/')) || Str::is($pattern, $request->path())) { + return; + } + } + $rate = max(0.0, min(1.0, (float) $config->get('posthog.autocapture.requests.sample_rate', 1.0))); + if ($rate === 0.0 || ($rate < 1.0 && random_int(1, 1000000) / 1000000 > $rate)) { + return; + } + $properties = [ + 'route' => $template, + 'method' => $request->method(), + 'status_code' => $response?->getStatusCode() ?? 500, + 'duration_ms' => (hrtime(true) - $started) / 1e6, + ]; + if (Collector::enabled('requests')) { + Collector::capture('laravel_request', $properties); + } + if ( + Collector::enabled('pageviews') && $request->isMethod('GET') + && $properties['status_code'] >= 200 && $properties['status_code'] < 300 + && str_starts_with(strtolower($response?->headers->get('Content-Type', '') ?? ''), 'text/html') + ) { + // A configured origin avoids trusting a request Host header or exposing URL credentials. + $url = parse_url((string) $config->get('app.url')); + if ( + !is_array($url) || !isset($url['scheme'], $url['host']) + || !in_array($url['scheme'], ['http', 'https'], true) + ) { + return; + } + $origin = $url['scheme'] . '://' . $url['host'] . (isset($url['port']) ? ':' . $url['port'] : ''); + Collector::capture('$pageview', $properties + [ + '$current_url' => $origin . $template, '$pathname' => $template, + ]); + } + } +} diff --git a/packages/laravel/src/AutocaptureServiceProvider.php b/packages/laravel/src/AutocaptureServiceProvider.php new file mode 100644 index 0000000..7e2312a --- /dev/null +++ b/packages/laravel/src/AutocaptureServiceProvider.php @@ -0,0 +1,133 @@ +mergeConfigFrom(__DIR__ . '/../config/autocapture.php', 'posthog-autocapture'); + $this->app->scoped(Collector::class); + $config = $this->app['config']; + $settings = $config->get('posthog-autocapture', []); + foreach ($config->get('posthog.autocapture', []) as $section => $values) { + $settings[$section] = $section !== 'domain_events' && is_array($values) + ? array_replace($settings[$section] ?? [], $values) : $values; + } + $config->set('posthog.autocapture', $settings); + $this->app->singleton( + DeferredModelEvents::class, + fn ($app) => new DeferredModelEvents($app['db.transactions']), + ); + } + + public function boot(): void + { + $this->publishes([ + __DIR__ . '/../config/autocapture.php' => config_path('posthog-autocapture.php'), + ], 'posthog-autocapture-config'); + $this->callAfterResolving(Kernel::class, fn ($kernel) => $kernel->prependMiddleware(RequestSummary::class)); + $events = $this->app['events']; + foreach (['Registered', 'Login', 'Logout', 'Failed', 'Lockout'] as $name) { + $events->listen('Illuminate\\Auth\\Events\\' . $name, static function ($event) use ($name) { + (new AuthEvents())->collect(strtolower($name), $event); + }); + } + foreach ( + [ + 'TransactionBeginning' => 'beginning', + 'TransactionCommitting' => 'committing', + 'TransactionRolledBack' => 'rolledBack', + ] as $event => $method + ) { + $events->listen('Illuminate\\Database\\Events\\' . $event, [DeferredModelEvents::class, $method]); + } + foreach (['created', 'updated', 'deleted'] as $operation) { + $events->listen('eloquent.' . $operation . ': *', static function ($name, $payload) use ($operation) { + (new ModelEvents())->collect($operation, $payload[0]); + }); + } + foreach ($this->app['config']->get('posthog.autocapture.domain_events', []) as $class => $mapping) { + if (is_string($class) && is_array($mapping)) { + $events->listen($class, static function ($event) use ($class, $mapping) { + if (is_object($event) && $event::class === $class) { + (new DomainEvents())->collect($event, $mapping); + } + }); + } + } + $this->registerOutcomes($events); + $this->callAfterResolving('blade.compiler', static function ($blade) { + $blade->if('posthogFeature', static function (string $key): bool { + if (!Collector::enabled('blade')) { + return false; + } + try { + return Container::getInstance()->make(PostHog::class)->evaluateFlags()->isEnabled($key); + } catch (Throwable $exception) { + Collector::logFailure(self::class . '::posthogFeature', $exception); + return false; + } + }); + }); + } + + private function registerOutcomes(Dispatcher $events): void + { + $outcomes = new Outcomes(); + $events->listen(\Illuminate\Queue\Events\JobProcessing::class, static function ($event) use ($outcomes) { + $outcomes->start('jobs', $event->job); + }); + foreach ( + [ + 'JobProcessed' => 'success', + 'JobExceptionOccurred' => 'failed', + 'JobFailed' => 'failed', + ] as $event => $outcome + ) { + $events->listen('Illuminate\\Queue\\Events\\' . $event, static function ($event) use ($outcomes, $outcome) { + $outcomes->finish('jobs', $event->job, static fn () => [ + 'name' => $event->job->resolveQueuedJobClass(), + 'outcome' => $outcome, 'attempts' => $event->job->attempts(), + ]); + }); + } + $events->listen(\Illuminate\Console\Events\CommandStarting::class, static function ($event) use ($outcomes) { + $outcomes->start('commands', $event->input); + }); + $events->listen(\Illuminate\Console\Events\CommandFinished::class, static function ($event) use ($outcomes) { + $outcomes->finish('commands', $event->input, static fn () => [ + 'name' => preg_split('/\s+/', trim($event->command ?? ''))[0], + 'outcome' => $event->exitCode === 0 ? 'success' : 'failed', 'exit_code' => $event->exitCode, + ]); + }); + $events->listen( + \Illuminate\Console\Events\ScheduledTaskStarting::class, + static function ($event) use ($outcomes) { + $outcomes->startScheduler($event->task); + }, + ); + foreach (['ScheduledTaskFinished' => false, 'ScheduledTaskFailed' => true] as $event => $failed) { + $events->listen( + 'Illuminate\\Console\\Events\\' . $event, + static function ($event) use ($outcomes, $failed) { + $outcomes->finishScheduler($event->task, $failed); + }, + ); + } + } +} diff --git a/packages/laravel/src/Testing/PostHogFake.php b/packages/laravel/src/Testing/PostHogFake.php index 9f45b62..d555c60 100644 --- a/packages/laravel/src/Testing/PostHogFake.php +++ b/packages/laravel/src/Testing/PostHogFake.php @@ -32,6 +32,9 @@ protected function message(string $method, array $message): bool $message['distinctId'] ??= $data['distinctId'] ?? null; $message['groups'] ??= $data['groups'] ?? []; $message['properties'] = array_replace($data['properties'] ?? [], $message['properties'] ?? []); + if (isset($data['sessionId']) && !array_key_exists('$session_id', $message['properties'])) { + $message['properties']['$session_id'] = $data['sessionId']; + } $this->recorded[] = ['method' => $method, 'message' => $message]; return true; } diff --git a/packages/laravel/tests/ApiSurfaceTest.php b/packages/laravel/tests/ApiSurfaceTest.php index 3974051..153b0c5 100644 --- a/packages/laravel/tests/ApiSurfaceTest.php +++ b/packages/laravel/tests/ApiSurfaceTest.php @@ -183,6 +183,20 @@ public function testPublicApiMatchesItsSnapshot(): void require_once $root . '/scripts/PublicApiSnapshot.php'; $builder = new ReflectionMethod(PublicApiSnapshot::class, 'buildPublicApi'); $api = $builder->invoke(null, dirname(__DIR__) . '/src', 'PostHog\\Laravel\\', true); + foreach ( + [ + \PostHog\Laravel\AutocaptureServiceProvider::class, + \PostHog\Laravel\Autocapture\AuthEvents::class, + \PostHog\Laravel\Autocapture\Collector::class, + \PostHog\Laravel\Autocapture\DeferredModelEvents::class, + \PostHog\Laravel\Autocapture\DomainEvents::class, + \PostHog\Laravel\Autocapture\ModelEvents::class, + \PostHog\Laravel\Autocapture\Outcomes::class, + \PostHog\Laravel\Autocapture\RequestSummary::class, + ] as $internal + ) { + $this->assertArrayNotHasKey($internal, $api['symbols']); + } $this->assertArrayNotHasKey(\PostHog\Laravel\ExceptionReporter::class, $api['symbols']); $this->assertArrayNotHasKey('fromRequest', $api['symbols'][Context::class]['methods']); $this->assertArrayHasKey('getFeatureFlagResult', $api['symbols'][PostHog::class]['methods']); diff --git a/packages/laravel/tests/AutocaptureTest.php b/packages/laravel/tests/AutocaptureTest.php new file mode 100644 index 0000000..b19bd88 --- /dev/null +++ b/packages/laravel/tests/AutocaptureTest.php @@ -0,0 +1,513 @@ +set('posthog.api_key', 'project-key'); + $app['config']->set('posthog.sdk.consumer', 'noop'); + $app['config']->set('queue.default', 'sync'); + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', ['driver' => 'sqlite', 'database' => ':memory:']); + $app['config']->set('posthog.autocapture.domain_events', [OrderCompleted::class => [ + 'event' => 'order_completed', + 'properties' => [ + 'amount' => 'amount', 'currency' => 'details.currency', 'unsafe' => 'model', 'absent' => 'absent', + ], + ]]); + } + + private function identity(string $id = 'actor'): void + { + $this->app->make(Context::class)->begin(['distinctId' => $id, 'groups' => ['team' => $id]]); + } + + private function enable(string $section): void + { + $this->app['config']->set('posthog.autocapture.' . $section . '.enabled', true); + } + + private function events(): array + { + return array_column(PostHog::getFacadeRoot()->recorded('capture'), 'message'); + } + + private function modelTable(): void + { + Schema::create('collected_records', function (Blueprint $table) { + $table->id(); + $table->string('state'); + $table->string('email'); + }); + } + + public function testDefaultCollectorsAreOffAndProviderRegistrationIsIdempotent(): void + { + $fake = PostHog::fake(); + $this->app->register(AutocaptureServiceProvider::class); + $this->identity(); + Route::get('/off', fn () => response('ok')->header('Content-Type', 'text/html')); + $this->get('/off', ['X-PostHog-Distinct-Id' => 'actor'])->assertOk(); + $this->app['events']->dispatch(new \Illuminate\Auth\Events\Login( + 'web', + new GenericUser(['id' => 'actor']), + false, + )); + $this->modelTable(); + CollectedRecord::create(['state' => 'new', 'email' => 'secret@example.test']); + $input = new ArrayInput([]); + $this->app['events']->dispatch(new CommandStarting('safe', $input, new NullOutput())); + $this->app['events']->dispatch(new CommandFinished('safe', $input, new NullOutput(), 0)); + $fake->assertNothingCaptured(); + $this->assertSame('off', trim(Blade::render("@posthogFeature('flag')\n on\n@else\n off\n@endposthogFeature"))); + } + + public function testDisabledRequestCollectionBypassesPostResponseChecks(): void + { + $request = Request::create('/off'); + $response = response('ok'); + $original = $this->app['config']; + $nextCalled = false; + $checksAfterNext = []; + $config = $this->createMock(\Illuminate\Config\Repository::class); + $config->expects($this->exactly(2))->method('get')->willReturnCallback( + function () use (&$nextCalled, &$checksAfterNext) { + $checksAfterNext[] = $nextCalled; + return false; + }, + ); + $this->app->instance('config', $config); + try { + $this->assertSame($response, (new RequestSummary())->handle( + $request, + function ($actual) use ($request, $response, &$nextCalled) { + $this->assertSame($request, $actual); + $nextCalled = true; + return $response; + }, + )); + } finally { + $this->app->instance('config', $original); + } + $this->assertTrue($nextCalled); + $this->assertSame([false, false], $checksAfterNext, 'Disabled collection must bypass post-response work.'); + } + + public function testPublishedCollectorConfigAndNestedOverridesAreHonored(): void + { + $path = $this->app->configPath('posthog-autocapture.php'); + $original = is_file($path) ? file_get_contents($path) : null; + @unlink($path); + try { + $this->artisan('vendor:publish', ['--tag' => 'posthog-autocapture-config'])->assertSuccessful(); + $this->assertFileExists($path); + $defaults = require $path; + $this->assertFalse($defaults['requests']['enabled']); + $this->assertSame([], $defaults['domain_events']); + $config = $this->app['config']; + $config->set('posthog.autocapture', []); + $config->set('posthog-autocapture.requests.enabled', true); + (new AutocaptureServiceProvider($this->app))->register(); + $this->assertTrue($config->get('posthog.autocapture.requests.enabled')); + $config->set('posthog.autocapture.requests', ['enabled' => false]); + (new AutocaptureServiceProvider($this->app))->register(); + $this->assertFalse($config->get('posthog.autocapture.requests.enabled')); + $this->assertSame($defaults['requests']['exclude'], $config->get('posthog.autocapture.requests.exclude')); + $config->set('posthog.autocapture.requests.exclude', []); + (new AutocaptureServiceProvider($this->app))->register(); + $this->assertSame([], $config->get('posthog.autocapture.requests.exclude')); + } finally { + if ($original === null) { + @unlink($path); + } else { + file_put_contents($path, $original); + } + } + } + + public function testRequestSummaryAndPageviewUseTemplatesAndConfiguredOriginOnly(): void + { + $fake = PostHog::fake(); + $this->enable('requests'); + $this->enable('pageviews'); + $this->app->register(AutocaptureServiceProvider::class); + $origin = implode('', ['https://', 'user', ':', 'secret', '@', 'app.example:8443/base?secret=hidden']); + $this->app['config']->set('app.url', $origin); + Route::get('/orders/{order}', fn () => response('

secret

')->header('Content-Type', 'text/html')); + foreach (['first-private', 'second-private'] as $id) { + $this->get('/orders/' . $id . '?token=secret', ['X-PostHog-Distinct-Id' => $id])->assertOk(); + } + $fake->assertCapturedCount(4); + $messages = $this->events(); + $this->assertSame( + ['first-private', 'first-private', 'second-private', 'second-private'], + array_column($messages, 'distinctId'), + ); + $this->assertSame(['route', 'method', 'status_code', 'duration_ms'], array_keys($messages[0]['properties'])); + $this->assertSame('/orders/{order}', $messages[0]['properties']['route']); + $this->assertSame('https://app.example:8443/orders/{order}', $messages[1]['properties']['$current_url']); + $this->assertSame('/orders/{order}', $messages[1]['properties']['$pathname']); + $this->assertGreaterThanOrEqual(0, $messages[0]['properties']['duration_ms']); + $this->assertStringNotContainsString('secret', json_encode(array_column($messages, 'properties'))); + $this->assertStringNotContainsString('private', json_encode(array_column($messages, 'properties'))); + $this->assertSame([], $this->app->make(Context::class)->snapshot()); + } + + public function testAutomaticRequestsKeepSessionCorrelationWithoutArbitraryProperties(): void + { + $fake = PostHog::fake(); + $this->enable('requests'); + Route::get('/session', fn () => 'ok'); + $this->get('/session', [ + 'X-PostHog-Distinct-Id' => 'person', 'X-PostHog-Session-Id' => 'session', + ])->assertOk(); + $fake->assertCaptured('laravel_request', fn ($event) => $event['properties']['$session_id'] === 'session'); + } + + public function testHealthAssetsUnmatchedAndSamplingDoNotCollectAndPageviewIsSeparate(): void + { + $fake = PostHog::fake(); + $this->enable('requests'); + $this->enable('pageviews'); + foreach (['/up', '/health/check', '/assets/{file}', '/bundle.js'] as $uri) { + Route::get($uri, fn () => response('ok')->header('Content-Type', 'text/html')); + } + foreach (['/up', '/health/check', '/assets/private', '/bundle.js', '/unmatched'] as $uri) { + $this->get($uri, ['X-PostHog-Distinct-Id' => 'actor']); + } + $fake->assertNothingCaptured(); + Route::get('/json', fn () => response()->json(['ok' => true])); + Route::post('/html', fn () => response('ok')->header('Content-Type', 'text/html')); + Route::get('/redirect', fn () => redirect('/json')); + Route::get('/error', fn () => response('error', 503)->header('Content-Type', 'text/html')); + $this->get('/json', ['X-PostHog-Distinct-Id' => 'actor']); + $this->post('/html', [], ['X-PostHog-Distinct-Id' => 'actor']); + $this->get('/redirect', ['X-PostHog-Distinct-Id' => 'actor']); + $this->get('/error', ['X-PostHog-Distinct-Id' => 'actor']); + $fake->assertCapturedCount(4); + $fake->assertNotCaptured('$pageview'); + $this->app['config']->set('posthog.autocapture.requests.sample_rate', 0); + $this->get('/json', ['X-PostHog-Distinct-Id' => 'actor']); + $fake->assertCapturedCount(4); + } + + public function testPageviewCanBeEnabledWithoutRequestSummary(): void + { + $fake = PostHog::fake(); + $this->enable('pageviews'); + Route::get('/page', fn () => response('ok')->header('Content-Type', 'text/html')); + $this->get('/page', ['X-PostHog-Distinct-Id' => 'actor']); + $fake->assertCapturedCount(1); + $fake->assertCaptured('$pageview'); + $fake->assertNotCaptured('laravel_request'); + } + + public function testAuthUsesOnlyIdentifierAndGuardAndFailedLockoutNeedExplicitOptIn(): void + { + $fake = PostHog::fake(); + $this->enable('auth'); + $this->identity('anonymous'); + $user = new GenericUser(['id' => 123, 'email' => 'secret@example.test', 'password' => 'secret']); + $events = $this->app['events']; + $events->dispatch(new \Illuminate\Auth\Events\Registered($user)); + $events->dispatch(new \Illuminate\Auth\Events\Login('web', $user, true)); + $events->dispatch(new \Illuminate\Auth\Events\Logout('web', $user)); + $failed = new \Illuminate\Auth\Events\Failed( + 'web', + $user, + ['email' => 'secret@example.test', 'password' => 'secret'], + ); + $lockout = new \Illuminate\Auth\Events\Lockout(Request::create('/?password=secret')); + $events->dispatch($failed); + $events->dispatch($lockout); + $fake->assertCapturedCount(3); + $this->app['config']->set('posthog.autocapture.auth.events', ['failed', 'lockout']); + $events->dispatch($failed); + $events->dispatch($lockout); + $this->assertSame(['123', '123', '123', 'anonymous', 'anonymous'], array_column($this->events(), 'distinctId')); + $this->assertSame( + [[], ['guard' => 'web'], ['guard' => 'web'], ['guard' => 'web'], []], + array_column($this->events(), 'properties'), + ); + $this->assertStringNotContainsString('secret', json_encode($this->events())); + } + + public function testDomainMapsOnlyExactClassAndPublicJsonSafeProperties(): void + { + $fake = PostHog::fake(); + $this->identity(); + $this->app['events']->dispatch(new OrderCompleted(model: new GenericUser(['email' => 'secret']))); + $this->assertSame(['amount' => 42, 'currency' => 'USD'], $this->events()[0]['properties']); + $this->app['events']->dispatch(new class extends OrderCompleted { + }); + $fake->assertCapturedCount(1); + } + + public function testModelsCaptureAfterCommitWithOriginalIdentityNoPayloadAndRollbackDrops(): void + { + $fake = PostHog::fake(); + $this->modelTable(); + $this->enable('models'); + $this->app['config']->set('posthog.autocapture.models.allow', [CollectedRecord::class => ['state']]); + $this->identity('original'); + DB::beginTransaction(); + $record = CollectedRecord::create(['state' => 'created', 'email' => 'secret@example.test']); + $record->update(['state' => 'updated']); + $record->delete(); + $fake->assertNothingCaptured(); + $this->identity('next'); + DB::commit(); + $fake->assertCapturedCount(3); + $this->assertSame(['original', 'original', 'original'], array_column($this->events(), 'distinctId')); + $this->assertSame( + ['created', 'updated', 'updated'], + array_map(fn ($event) => $event['properties']['attributes']['state'], $this->events()), + ); + $this->assertSame(['team' => 'original'], $this->events()[0]['groups']); + $this->assertStringNotContainsString('secret', json_encode($this->events())); + $this->assertSame('next', $this->app->make(Context::class)->data()['distinctId']); + $exception = new RuntimeException('original failure'); + try { + DB::transaction(function () use ($exception) { + CollectedRecord::create(['state' => 'rolled back', 'email' => 'secret']); + throw $exception; + }); + $this->fail('Transaction must throw'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + $fake->assertCapturedCount(3); + $this->assertSame(0, CollectedRecord::count()); + $this->app['config']->set('posthog.autocapture.models.allow', [CollectedRecord::class => []]); + CollectedRecord::create(['state' => 'safe', 'email' => 'secret']); + $this->assertSame(['model' => CollectedRecord::class, 'attributes' => []], $this->events()[3]['properties']); + } + + public function testNestedRollbackDropsOnlyRolledBackModelEventsAndUnallowlistedModelsDoNotCollect(): void + { + $fake = PostHog::fake(); + $this->modelTable(); + $this->enable('models'); + $this->identity(); + CollectedRecord::create(['state' => 'not allowed', 'email' => 'secret']); + $fake->assertNothingCaptured(); + $this->app['config']->set('posthog.autocapture.models.allow', [CollectedRecord::class => []]); + DB::beginTransaction(); + CollectedRecord::create(['state' => 'outer', 'email' => 'secret']); + DB::beginTransaction(); + CollectedRecord::create(['state' => 'inner', 'email' => 'secret']); + DB::rollBack(); + $fake->assertNothingCaptured(); + DB::commit(); + $fake->assertCapturedCount(1); + } + + public function testQueueOutcomesAreOncePerAttemptAndNeverContainJobPayloadOrException(): void + { + $fake = PostHog::fake(); + $this->enable('jobs'); + $job = $this->createStub(\Illuminate\Contracts\Queue\Job::class); + $job->method('resolveQueuedJobClass')->willReturn('App\\Jobs\\Import'); + $job->method('resolveName')->willReturn('secret display name'); + $job->method('attempts')->willReturn(2); + $events = $this->app['events']; + foreach (['first', 'second'] as $id) { + $events->dispatch(new JobProcessing('sync', $job)); + $this->identity($id); + $event = $id === 'first' ? new JobProcessed('sync', $job) + : new JobExceptionOccurred('sync', $job, new RuntimeException('secret failure')); + $events->dispatch($event); + $events->dispatch($event); + } + $fake->assertCapturedCount(2); + $this->assertSame(['first', 'second'], array_column($this->events(), 'distinctId')); + $this->assertSame( + ['success', 'failed'], + array_map(fn ($event) => $event['properties']['outcome'], $this->events()), + ); + $this->assertSame(['name', 'outcome', 'attempts', 'duration_ms'], array_keys($this->events()[0]['properties'])); + $this->assertStringNotContainsString('secret', json_encode($this->events())); + $this->assertSame([], $this->app->make(Context::class)->data()); + } + + public function testRealSyncJobSuccessAndFailureKeepOriginalExceptionAndOuterWorkerContext(): void + { + $fake = PostHog::fake(); + $this->enable('jobs'); + $this->identity('outer'); + \Illuminate\Support\Facades\Bus::dispatchSync(new Fixtures\CaptureJob('job-user')); + try { + \Illuminate\Support\Facades\Bus::dispatchSync(new Fixtures\FailingCaptureJob('failed-user')); + $this->fail('Job must fail'); + } catch (RuntimeException $exception) { + $this->assertSame('failed job', $exception->getMessage()); + } + $fake->assertCaptured('laravel_job', fn ($event) => $event['distinctId'] === 'job-user' + && $event['properties']['name'] === Fixtures\CaptureJob::class + && $event['properties']['outcome'] === 'success'); + $fake->assertCaptured('laravel_job', fn ($event) => $event['distinctId'] === 'failed-user' + && $event['properties']['name'] === Fixtures\FailingCaptureJob::class + && $event['properties']['outcome'] === 'failed'); + $this->assertSame('outer', $this->app->make(Context::class)->data()['distinctId']); + } + + public function testExplicitlyFailedJobIsNotReportedAsSuccessfulWhenItReturns(): void + { + $fake = PostHog::fake(); + $this->enable('jobs'); + $this->identity('actor'); + \Illuminate\Support\Facades\Bus::dispatchSync(new Fixtures\ExplicitlyFailedJob()); + $fake->assertCapturedCount(1); + $fake->assertCaptured('laravel_job', fn ($event) => $event['properties']['outcome'] === 'failed' + && $event['properties']['name'] === Fixtures\ExplicitlyFailedJob::class); + $this->assertSame('actor', $this->app->make(Context::class)->data()['distinctId']); + } + + public function testCommandAndSchedulerMetadataExcludeArgumentsAndDeduplicateFailure(): void + { + $fake = PostHog::fake(); + $this->enable('commands'); + $this->enable('scheduler'); + $input = new ArrayInput(['secret' => 'password']); + $output = new NullOutput(); + $events = $this->app['events']; + $events->dispatch(new CommandStarting('import --secret=password', $input, $output)); + $this->identity(); + $events->dispatch(new CommandFinished('import --secret=password', $input, $output, 7)); + $this->identity(); + $task = new Event($this->createStub(EventMutex::class), 'php artisan import --secret=password'); + $task->exitCode = 1; + $events->dispatch(new ScheduledTaskStarting($task)); + $task->callBeforeCallbacks($this->app); + $events->dispatch(new ScheduledTaskFinished($task, 0.1)); + $events->dispatch(new ScheduledTaskFailed($task, new RuntimeException('secret'), 0.1)); + $task->runInBackground = true; + $task->exitCode = null; + $events->dispatch(new ScheduledTaskStarting($task)); + $task->callBeforeCallbacks($this->app); + $events->dispatch(new ScheduledTaskFinished($task, 0.1)); + $fake->assertCapturedCount(3); + $this->assertSame('import', $this->events()[0]['properties']['name']); + $this->assertSame('failed', $this->events()[1]['properties']['outcome']); + $this->assertSame('background_started', $this->events()[2]['properties']['outcome']); + $this->assertStringNotContainsString('password', json_encode($this->events())); + $this->assertStringNotContainsString('secret', json_encode($this->events())); + } + + public function testBladeUsesSharedFlagSnapshotsAndCoreExposureDeduplicationAcrossScopes(): void + { + $this->enable('blade'); + $http = $this->createMock(\PostHog\HttpClient::class); + $http->expects($this->exactly(2))->method('sendRequest')->willReturn(new \PostHog\HttpResponse( + json_encode(['flags' => ['checkout' => ['enabled' => true, 'variant' => null]]]), + 200, + )); + $captures = []; + $sdk = new \PostHog\Client('project-key', [ + 'consumer' => 'noop', + 'before_send' => static function ($event) use (&$captures) { + $captures[] = $event; + return $event; + }, + ], $http, loadFeatureFlags: false); + $manager = $this->app->make(\PostHog\Laravel\PostHog::class); + (new \ReflectionProperty(\PostHog\Laravel\PostHog::class, 'client'))->setValue($manager, $sdk); + foreach (['first', 'second'] as $id) { + $this->identity($id); + $snapshot = $manager->evaluateFlags(); + \PostHog\Laravel\Autocapture\Collector::capture('between reads', []); + $this->assertSame($snapshot, $manager->evaluateFlags()); + for ($i = 0; $i < 2; $i++) { + $template = "@posthogFeature('checkout')\n on\n@else\n off\n@endposthogFeature"; + $this->assertSame('on', trim(Blade::render($template))); + } + } + $exposures = array_values(array_filter($captures, fn ($event) => $event['event'] === '$feature_flag_called')); + $this->assertCount(2, $exposures); + $this->assertSame(['first', 'second'], array_column($exposures, 'distinct_id')); + } + + public function testOutcomeExclusionsAndNoIdentityDoNotInventAnonymousEvents(): void + { + $fake = PostHog::fake(); + $this->enable('jobs'); + $this->app['config']->set('posthog.autocapture.jobs.exclude', ['App\\Jobs\\*']); + $job = $this->createStub(\Illuminate\Contracts\Queue\Job::class); + $job->method('resolveQueuedJobClass')->willReturn('App\\Jobs\\Import'); + $job->method('attempts')->willReturn(1); + $this->app['events']->dispatch(new JobProcessing('sync', $job)); + $this->identity(); + $this->app['events']->dispatch(new JobProcessed('sync', $job)); + $this->app['events']->dispatch(new OrderCompleted()); + $fake->assertNothingCaptured(); + } + + public function testCollectorFailureNeverMasksOriginalExceptionOrLeaksContextProperties(): void + { + $this->enable('requests'); + $this->app->instance(\PostHog\Laravel\PostHog::class, new class ($this->app) extends \PostHog\Laravel\PostHog { + public function capture( + string|array $event, + array $properties = [], + ?string $distinctId = null, + array $groups = [], + ): bool { + throw new RuntimeException('collector failed'); + } + }); + $this->identity(); + $request = Request::create('/test?secret=password'); + $route = new \Illuminate\Routing\Route('GET', 'test', fn () => null); + $request->setRouteResolver(fn () => $route); + $exception = new RuntimeException('original'); + try { + (new RequestSummary())->handle($request, fn () => throw $exception); + $this->fail('Original exception must be thrown'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + $fake = PostHog::fake(); + $this->app->make(Context::class)->begin(['distinctId' => 'actor', 'properties' => ['password' => 'secret']]); + $this->app['events']->dispatch(new OrderCompleted()); + $this->assertStringNotContainsString('secret', json_encode($this->events())); + $this->assertSame(['password' => 'secret'], $this->app->make(Context::class)->data()['properties']); + $fake->assertCapturedCount(1); + } +} diff --git a/packages/laravel/tests/CollectorDiagnosticsTest.php b/packages/laravel/tests/CollectorDiagnosticsTest.php new file mode 100644 index 0000000..a2134ee --- /dev/null +++ b/packages/laravel/tests/CollectorDiagnosticsTest.php @@ -0,0 +1,139 @@ +set('posthog.api_key', 'project-key'); + $app['config']->set('posthog.sdk.consumer', 'noop'); + foreach (['auth', 'models', 'jobs', 'requests', 'blade'] as $section) { + $app['config']->set('posthog.autocapture.' . $section . '.enabled', true); + } + } + + private function failingManager(TypeError $failure): PostHog + { + $manager = $this->getMockBuilder(PostHog::class) + ->setConstructorArgs([$this->app]) + ->onlyMethods(['capture', 'evaluateFlags']) + ->getMock(); + $manager->expects($this->atLeastOnce())->method('capture')->willThrowException($failure); + $manager->method('evaluateFlags')->willThrowException($failure); + $this->app->instance(PostHog::class, $manager); + return $manager; + } + + public static function diagnosticModes(): array + { + return [ + 'quiet by default' => [false, false], + 'debug' => [true, false], + 'broken logger' => [true, true], + ]; + } + + #[DataProvider('diagnosticModes')] + public function testCollectorFailuresAreDeduplicatedRedactedAndNeverEscape(bool $debug, bool $loggerThrows): void + { + $this->app['config']->set('posthog.sdk.debug', $debug); + $failure = new TypeError('private credentials and event data'); + $this->failingManager($failure); + $this->app->make(Context::class)->begin(['distinctId' => 'private actor']); + $logs = []; + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->exactly($debug ? 7 : 0))->method('debug')->willReturnCallback( + function ($message, $context) use (&$logs, $loggerThrows) { + $logs[] = [$message, $context]; + $this->assertSame(['collector', 'exception_class'], array_keys($context)); + $this->assertSame(TypeError::class, $context['exception_class']); + if ($loggerThrows) { + throw new RuntimeException('logger failed'); + } + }, + ); + $this->app->instance('log', $logger); + $model = $this->createStub(CollectedRecord::class); + $model->method('getAttributes')->willThrowException($failure); + $this->app['config']->set('posthog.autocapture.models.allow', [$model::class => ['state']]); + $user = new class (['id' => 1]) extends GenericUser { + public function getAuthIdentifier() + { + throw new TypeError('private user data'); + } + }; + $request = Request::create('/private'); + $request->setRouteResolver(fn () => throw $failure); + $applicationFailure = new RuntimeException('original application failure'); + for ($i = 0; $i < 2; $i++) { + Collector::capture('private event', ['private' => 'data']); + (new AuthEvents())->collect('login', (object) ['user' => $user, 'guard' => 'web']); + (new DomainEvents())->collect(new \stdClass(), [ + 'event' => 'private event', + 'properties' => (function () use ($failure) { + throw $failure; + yield; + })(), + ]); + (new ModelEvents())->collect('created', $model); + $outcomes = new Outcomes(); + $job = new \stdClass(); + $outcomes->start('jobs', $job); + $outcomes->finish('jobs', $job, fn () => throw $failure); + try { + (new RequestSummary())->handle($request, fn () => throw $applicationFailure); + $this->fail('The application exception must escape unchanged.'); + } catch (RuntimeException $caught) { + $this->assertSame($applicationFailure, $caught); + } + $this->assertSame('off', trim(Blade::render("@posthogFeature('flag') on @else off @endposthogFeature"))); + } + $this->assertCount($debug ? 7 : 0, $logs); + $this->assertStringNotContainsString('private', json_encode($logs)); + if ($debug) { + $this->assertCount(7, array_unique(array_column(array_column($logs, 1), 'collector'))); + } + $this->assertSame('private actor', $this->app->make(Context::class)->data()['distinctId']); + } + + public function testDiagnosticDeduplicationResetsWithTheApplicationScope(): void + { + $this->app['config']->set('posthog.sdk.debug', true); + $manager = $this->failingManager(new TypeError('private')); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->exactly(2))->method('debug'); + $this->app->instance('log', $logger); + for ($scope = 0; $scope < 2; $scope++) { + for ($i = 0; $i < 2; $i++) { + Collector::capture('event', [], ['distinctId' => 'actor']); + } + $this->app->forgetScopedInstances(); + $this->app->instance(PostHog::class, $manager); + } + } +} diff --git a/packages/laravel/tests/CollectorDisabledTest.php b/packages/laravel/tests/CollectorDisabledTest.php new file mode 100644 index 0000000..dba70b5 --- /dev/null +++ b/packages/laravel/tests/CollectorDisabledTest.php @@ -0,0 +1,108 @@ +set('posthog.api_key', 'project-key'); + $app['config']->set('posthog.sdk.consumer', 'noop'); + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', ['driver' => 'sqlite', 'database' => ':memory:']); + foreach (['requests', 'auth', 'models', 'jobs', 'blade'] as $section) { + $app['config']->set('posthog.autocapture.' . $section . '.enabled', true); + } + $app['config']->set('posthog.autocapture.models.allow', [CollectedRecord::class => ['state']]); + $app['config']->set('posthog.autocapture.domain_events', [OrderCompleted::class => [ + 'event' => 'order_completed', 'properties' => ['amount' => 'amount'], + ]]); + } + + public static function disabledConfigurations(): array + { + return [ + 'disabled' => [false, 'project-key'], + 'empty key' => [true, ''], + 'whitespace key' => [true, ' '], + ]; + } + + #[DataProvider('disabledConfigurations')] + public function testDisabledCollectorsDoNotResolveContextOrBufferModels(bool $enabled, string $key): void + { + $this->app['config']->set('posthog.enabled', $enabled); + $this->app['config']->set('posthog.api_key', $key); + $calls = ['identity' => 0, 'groups' => 0, 'user' => 0]; + foreach (['identity' => 'distinct_id', 'groups' => 'groups'] as $name => $option) { + $this->app->instance('collector.' . $name, function () use (&$calls, $name) { + $calls[$name]++; + return $name === 'groups' ? [] : null; + }); + $this->app['config']->set('posthog.' . $option, 'collector.' . $name); + } + $request = Request::create('/disabled'); + $request->setUserResolver(function () use (&$calls) { + $calls['user']++; + return new GenericUser(['id' => 'actor']); + }); + $this->app->make(Context::class)->fromRequest($request); + DB::getSchemaBuilder()->create('collected_records', function (Blueprint $table) { + $table->id(); + $table->string('state'); + $table->string('email'); + }); + DB::beginTransaction(); + try { + for ($i = 0; $i < 50; $i++) { + CollectedRecord::create(['state' => 'new', 'email' => 'private']); + } + $this->assertSame( + ['count' => 0, 'bytes' => 0], + $this->app->make(DeferredModelEvents::class)->pendingUsage(), + ); + $this->app['events']->dispatch(new OrderCompleted()); + $this->app['events']->dispatch(new \Illuminate\Auth\Events\Login( + 'web', + new GenericUser(['id' => 1]), + false, + )); + $response = response('ok'); + $this->assertSame($response, (new RequestSummary())->handle($request, fn () => $response)); + Collector::capture('direct collector call', []); + $this->assertSame(['identity' => 0, 'groups' => 0, 'user' => 0], $calls); + } finally { + DB::rollBack(); + } + } + + public function testFakeStillCollectsWithDisabledConfigurationAndNoKey(): void + { + $this->app['config']->set('posthog.enabled', false); + $this->app['config']->set('posthog.api_key', ''); + $fake = PostHog::fake(); + $this->app->make(Context::class)->begin(['distinctId' => 'actor']); + $this->app['events']->dispatch(new OrderCompleted()); + $fake->assertCaptured('order_completed'); + } +} diff --git a/packages/laravel/tests/Fixtures/CollectedRecord.php b/packages/laravel/tests/Fixtures/CollectedRecord.php new file mode 100644 index 0000000..4d40bd4 --- /dev/null +++ b/packages/laravel/tests/Fixtures/CollectedRecord.php @@ -0,0 +1,12 @@ +items as $key => $record) { + $this->inspections++; + yield $key => $record; + } + } +} diff --git a/packages/laravel/tests/Fixtures/ExplicitlyFailedJob.php b/packages/laravel/tests/Fixtures/ExplicitlyFailedJob.php new file mode 100644 index 0000000..e902ccd --- /dev/null +++ b/packages/laravel/tests/Fixtures/ExplicitlyFailedJob.php @@ -0,0 +1,19 @@ +fail(new RuntimeException('explicit job failure')); + } +} diff --git a/packages/laravel/tests/Fixtures/OrderCompleted.php b/packages/laravel/tests/Fixtures/OrderCompleted.php new file mode 100644 index 0000000..de75085 --- /dev/null +++ b/packages/laravel/tests/Fixtures/OrderCompleted.php @@ -0,0 +1,14 @@ + 'USD'], + public mixed $model = null, + ) { + } +} diff --git a/packages/laravel/tests/ModelEventsTest.php b/packages/laravel/tests/ModelEventsTest.php new file mode 100644 index 0000000..efa5b7a --- /dev/null +++ b/packages/laravel/tests/ModelEventsTest.php @@ -0,0 +1,430 @@ +set('posthog.api_key', 'project-key'); + $app['config']->set('posthog.sdk.consumer', 'noop'); + $app['config']->set('posthog.autocapture.models', [ + 'enabled' => true, 'allow' => [CollectedRecord::class => ['state']], + ]); + $app['config']->set('database.default', 'a'); + foreach (['a', 'b'] as $name) { + $app['config']->set('database.connections.' . $name, ['driver' => 'sqlite', 'database' => ':memory:']); + } + } + + protected function setUp(): void + { + parent::setUp(); + PostHog::fake(); + foreach (['a', 'b'] as $name) { + DB::connection($name)->getSchemaBuilder()->create('collected_records', function (Blueprint $table) { + $table->id(); + $table->text('state'); + $table->text('email'); + }); + } + $this->identity('original'); + } + + private function identity(string $id): void + { + $this->app->make(Context::class)->begin(['distinctId' => $id, 'groups' => ['team' => $id]]); + } + + private function record(string $state, string $connection = 'a'): CollectedRecord + { + return CollectedRecord::on($connection)->create(['state' => $state, 'email' => 'private']); + } + + private function messages(): array + { + return array_column(PostHog::getFacadeRoot()->recorded('capture'), 'message'); + } + + private function states(): array + { + return array_map(fn ($event) => $event['properties']['attributes']['state'], $this->messages()); + } + + private function buffer(): DeferredModelEvents + { + return $this->app->make(DeferredModelEvents::class); + } + + private function assertUsage(int $count, int $bytes): void + { + $this->assertSame(['count' => $count, 'bytes' => $bytes], $this->buffer()->pendingUsage()); + } + + private function encodedSize(string $state): int + { + return strlen(json_encode([ + 'laravel_model_created', + ['model' => CollectedRecord::class, 'attributes' => ['state' => $state]], + ['distinctId' => 'original', 'groups' => ['team' => 'original']], + ])); + } + + private function assertTransactionCallbackCount(int $expected): void + { + $transactions = $this->app['db.transactions']; + $records = $transactions->getPendingTransactions()->merge($transactions->getCommittedTransactions()); + $this->assertSame($expected, $records->sum(fn ($record) => count($record->getCallbacks()))); + } + + public function testOtherConnectionCommitCannotPublishRolledBackEvents(): void + { + DB::connection('a')->beginTransaction(); + DB::connection('b')->beginTransaction(); + $this->record('rolled back'); + DB::connection('b')->commit(); + PostHog::assertNothingCaptured(); + DB::connection('a')->rollBack(); + PostHog::assertNothingCaptured(); + $this->assertUsage(0, 0); + } + + public function testOwningConnectionCommitAndNontransactionalWritesDoNotWaitForOtherConnection(): void + { + DB::connection('a')->beginTransaction(); + DB::connection('b')->beginTransaction(); + $this->record('committed'); + $this->identity('later'); + DB::connection('a')->commit(); + $this->assertSame(['committed'], $this->states()); + $this->assertSame('original', $this->messages()[0]['distinctId']); + $this->record('immediate'); + $this->assertSame(['committed', 'immediate'], $this->states()); + DB::connection('b')->rollBack(); + $this->assertUsage(0, 0); + } + + public function testAfterCommitCallbackCanCommitOtherConnectionWithoutLosingOriginalBatch(): void + { + DB::connection('a')->beginTransaction(); + DB::connection('a')->afterCommit(fn () => DB::connection('b')->commit()); + DB::connection('b')->beginTransaction(); + $this->record('a first'); + $this->record('b', 'b'); + $this->record('a second'); + $this->identity('later'); + DB::connection('a')->commit(); + $this->assertSame(['b', 'a first', 'a second'], $this->states()); + $this->assertSame(['original', 'original', 'original'], array_column($this->messages(), 'distinctId')); + $this->assertUsage(0, 0); + } + + public function testAfterCommitCallbackCanStartAnotherTransactionWithoutPublishingItsEvents(): void + { + DB::beginTransaction(); + DB::afterCommit(function () { + DB::beginTransaction(); + $this->record('next transaction'); + }); + $this->record('original transaction'); + $this->identity('later'); + DB::commit(); + $this->assertSame(['original transaction'], $this->states()); + PostHog::assertCapturedCount(1); + DB::commit(); + $this->assertSame(['original transaction', 'next transaction'], $this->states()); + $this->assertSame(['original', 'later'], array_column($this->messages(), 'distinctId')); + $this->assertUsage(0, 0); + } + + public function testAfterCommitCallbackRollbackCannotDiscardTheCommittedRoot(): void + { + DB::beginTransaction(); + DB::afterCommit(function () { + DB::beginTransaction(); + $this->record('rolled back callback transaction'); + DB::rollBack(); + }); + $this->record('committed original'); + DB::commit(); + $this->assertSame(['committed original'], $this->states()); + $this->assertUsage(0, 0); + } + + public function testRollbackReleasesBudgetEvenWhenApplicationRetainsTransactionRecords(): void + { + DB::beginTransaction(); + $this->record('rolled back'); + $records = $this->app['db.transactions']->getPendingTransactions()->all(); + DB::rollBack(); + $this->assertUsage(0, 0); + $this->assertCount(1, $records); + PostHog::assertNothingCaptured(); + } + + public function testNestedCommitsPreserveEventOrderAndRollbackReleasesSavepointBudget(): void + { + DB::beginTransaction(); + $this->record('first'); + DB::beginTransaction(); + $this->record('discarded'); + DB::beginTransaction(); + $this->record('discarded committed child'); + DB::commit(); + DB::rollBack(); + $this->assertUsage(1, $this->encodedSize('first')); + DB::beginTransaction(); + $this->record('second'); + DB::commit(); + $this->record('third'); + PostHog::assertNothingCaptured(); + $this->identity('later'); + DB::commit(); + $this->assertSame(['first', 'second', 'third'], $this->states()); + $this->assertSame(['original', 'original', 'original'], array_column($this->messages(), 'distinctId')); + $this->assertUsage(0, 0); + DB::beginTransaction(); + DB::beginTransaction(); + $this->record('committed child of rolled back parent'); + DB::commit(); + DB::rollBack(); + $this->assertSame(['first', 'second', 'third'], $this->states()); + $this->assertUsage(0, 0); + } + + public function testCountAdmissionIsAggregateDropNewestAndBoundsCallbackBookkeeping(): void + { + DB::connection('a')->beginTransaction(); + DB::connection('b')->beginTransaction(); + for ($i = 0; $i < 1100; $i++) { + $this->record(sprintf('%04d', $i), $i % 2 === 0 ? 'a' : 'b'); + } + $this->assertTransactionCallbackCount(2); + $this->assertUsage(1000, 1000 * $this->encodedSize('0000')); + PostHog::assertNothingCaptured(); + DB::connection('a')->rollBack(); + $this->assertUsage(500, 500 * $this->encodedSize('0000')); + $this->record('new after release', 'b'); + DB::connection('b')->commit(); + PostHog::assertCapturedCount(501); + $expected = array_map(fn ($i) => sprintf('%04d', $i), range(1, 999, 2)); + $this->assertSame([...$expected, 'new after release'], $this->states()); + $this->assertUsage(0, 0); + } + + public function testDistinctTransactionRecordsCannotGrowCallbacksBeyondAdmissionBudget(): void + { + DB::beginTransaction(); + for ($i = 0; $i < 1100; $i++) { + DB::beginTransaction(); + $this->record('same'); + DB::commit(); + } + $this->assertUsage(1000, 1000 * $this->encodedSize('same')); + $this->assertTransactionCallbackCount(1000); + PostHog::assertNothingCaptured(); + DB::commit(); + PostHog::assertCapturedCount(1000); + $this->assertUsage(0, 0); + } + + public function testTransactionMembershipWorkStaysLinearAtCapacity(): void + { + $root = new DatabaseTransactionRecord('a', 1); + $pending = new CountedTransactionCollection([$root]); + $committed = new CountedTransactionCollection(); + $transactions = $this->createStub(DatabaseTransactionsManager::class); + $transactions->method('getPendingTransactions')->willReturn($pending); + $transactions->method('callbackApplicableTransactions')->willReturn($pending); + $transactions->method('getCommittedTransactions')->willReturn($committed); + $connection = new SQLiteConnection(new \PDO('sqlite::memory:'), '', '', ['name' => 'a']); + $connection->beginTransaction(); + $connection->beginTransaction(); + $buffer = new DeferredModelEvents($transactions); + $buffer->committing(new TransactionCommitting($connection)); + $this->assertSame(0, $pending->inspections + $committed->inspections); + for ($i = 0; $i < 1000; $i++) { + $pending->push(new DatabaseTransactionRecord('a', 2, $root)); + $buffer->defer($connection, 'model changed', [], ['distinctId' => 'actor']); + $committed->push($pending->pop()); + } + $this->assertSame(1000, $buffer->pendingUsage()['count']); + foreach (['overflow', 'commit', 'begin'] as $operation) { + $pending->inspections = $committed->inspections = 0; + if ($operation === 'overflow') { + $buffer->defer($connection, 'drop newest', [], ['distinctId' => 'actor']); + } elseif ($operation === 'commit') { + $buffer->committing(new TransactionCommitting($connection)); + } else { + $pending->push(new DatabaseTransactionRecord('a', 1)); + $connection->rollBack(0); + $connection->beginTransaction(); + $buffer->beginning(new TransactionBeginning($connection)); + } + $this->assertLessThanOrEqual( + 4 * ($pending->count() + $committed->count()), + $pending->inspections + $committed->inspections, + $operation . ' must not rescan transaction collections for each buffered event.', + ); + $this->assertSame($operation === 'begin' ? 0 : 1000, $buffer->pendingUsage()['count']); + } + PostHog::assertNothingCaptured(); + } + + public function testConnectionResetsCannotAccumulateUnboundedOrphanedCallbacks(): void + { + $connection = DB::connection('a'); + for ($i = 0; $i < 1100; $i++) { + if ($i !== 0) { + // Laravel 12 disconnect resets PDO/depth without clearing manager records. + $connection->setPdo(new \PDO('sqlite::memory:')); + $connection->getSchemaBuilder()->create('collected_records', function (Blueprint $table) { + $table->id(); + $table->text('state'); + $table->text('email'); + }); + } + $connection->beginTransaction(); + $this->record('abandoned'); + } + $this->assertUsage(0, 0); + $this->assertTransactionCallbackCount(1000); + $connection->commit(); + PostHog::assertNothingCaptured(); + DB::transaction(fn () => $this->record('fresh')); + $this->assertSame(['fresh'], $this->states()); + $this->assertUsage(0, 0); + } + + public function testByteAdmissionIncludesContextAndDropsOversizedApprovedAttributes(): void + { + DB::beginTransaction(); + $this->record(str_repeat('x', 1048576)); + $this->assertTransactionCallbackCount(0); + $this->assertUsage(0, 0); + $state = str_repeat('x', 1048576 - $this->encodedSize('')); + $this->record($state); + $this->assertUsage(1, 1048576); + $this->record('drop newest'); + $this->assertUsage(1, 1048576); + PostHog::assertNothingCaptured(); + DB::commit(); + $this->assertSame([$state], $this->states()); + $this->assertUsage(0, 0); + DB::beginTransaction(); + $this->record('fits after commit'); + DB::commit(); + $this->assertSame([$state, 'fits after commit'], $this->states()); + } + + public function testCommitFailureRetryDiscardsFailedAttemptBeforeReadmittingEvents(): void + { + $commits = 0; + $this->app['events']->listen(TransactionCommitting::class, function ($event) use (&$commits) { + if ($event->connectionName === 'a' && ++$commits === 1) { + throw new RuntimeException('database is locked'); + } + }); + $pdo = DB::connection('a')->getPdo(); + $attempt = 0; + $usageOnRetry = null; + DB::transaction(function () use (&$attempt, &$usageOnRetry) { + if (++$attempt === 1) { + for ($i = 0; $i < 1000; $i++) { + $this->record('failed attempt'); + } + $this->assertUsage(1000, 1000 * $this->encodedSize('failed attempt')); + } else { + $usageOnRetry = $this->buffer()->pendingUsage(); + $this->record('committed retry'); + } + PostHog::assertNothingCaptured(); + }, 2); + $this->assertSame(2, $attempt); + $this->assertSame($pdo, DB::connection('a')->getPdo()); + $this->assertSame(['count' => 0, 'bytes' => 0], $usageOnRetry); + $this->assertSame(['committed retry'], $this->states()); + $this->assertSame(1, CollectedRecord::count()); + $this->assertUsage(0, 0); + $this->assertTransactionCallbackCount(0); + DB::transaction(fn () => $this->record('next transaction')); + $this->assertSame(['committed retry', 'next transaction'], $this->states()); + $this->assertUsage(0, 0); + } + + public function testRetryAndRepeatedSavepointRollbackReleaseCapacityWithoutCallbackAccumulation(): void + { + $attempt = 0; + DB::transaction(function () use (&$attempt) { + $this->record('attempt ' . ++$attempt); + if ($attempt === 1) { + throw new RuntimeException('database is locked'); + } + }, 2); + $this->assertSame(['attempt 2'], $this->states()); + $this->assertUsage(0, 0); + DB::beginTransaction(); + for ($i = 0; $i < 1100; $i++) { + DB::beginTransaction(); + $this->record('discarded'); + DB::rollBack(); + } + $this->assertUsage(0, 0); + $this->assertTransactionCallbackCount(0); + $this->record('final'); + DB::commit(); + $this->assertSame(['attempt 2', 'final'], $this->states()); + } + + public function testNoIdentityConsumesNoPendingCapacityAndModelIsNotRetained(): void + { + $this->app->make(Context::class)->begin([]); + DB::beginTransaction(); + $this->record('no identity'); + $this->assertUsage(0, 0); + $this->assertTransactionCallbackCount(0); + $this->identity('original'); + $model = $this->record('admitted'); + $reference = \WeakReference::create($model); + unset($model); + gc_collect_cycles(); + $this->assertNull($reference->get()); + DB::rollBack(); + $this->assertUsage(0, 0); + PostHog::assertNothingCaptured(); + } + + public function testDisconnectReleasesAbandonedTransactionBudgetBeforeNextAdmission(): void + { + DB::beginTransaction(); + $this->record('abandoned'); + DB::disconnect('a'); + DB::connection('b')->beginTransaction(); + $this->record('fresh', 'b'); + $this->assertUsage(1, $this->encodedSize('fresh')); + DB::connection('b')->commit(); + $this->assertUsage(0, 0); + $this->assertSame(['fresh'], $this->states()); + } +} diff --git a/packages/laravel/tests/SchedulerOutcomeTest.php b/packages/laravel/tests/SchedulerOutcomeTest.php new file mode 100644 index 0000000..7da3f70 --- /dev/null +++ b/packages/laravel/tests/SchedulerOutcomeTest.php @@ -0,0 +1,56 @@ +set('posthog.api_key', 'test-key'); + $app['config']->set('posthog.autocapture.scheduler.enabled', true); + } + + public static function taskModes(): array + { + return ['foreground' => [false], 'background' => [true]]; + } + + #[DataProvider('taskModes')] + public function testOverlapLockRacesDoNotProduceAnOutcome(bool $background): void + { + $fake = PostHog::fake(); + $this->app->make(Context::class)->begin(['distinctId' => 'scheduler-actor']); + $mutex = $this->createMock(EventMutex::class); + $mutex->method('exists')->willReturn(false); + $mutex->expects($this->exactly(2))->method('create')->willReturnOnConsecutiveCalls(false, true); + $this->app->instance(EventMutex::class, $mutex); + $schedule = $this->app->make(Schedule::class); + $task = $schedule->exec(PHP_BINARY, ['-r', 'exit(0);'])->withoutOverlapping(); + $task->runInBackground = $background; + + $this->assertSame(0, Artisan::call('schedule:run')); + $fake->assertNotCaptured('laravel_scheduled_task'); + + $task->runInBackground = false; + $this->assertSame(0, Artisan::call('schedule:run')); + $fake->assertCapturedCount(1); + $fake->assertCaptured('laravel_scheduled_task', fn ($event) => + $event['distinctId'] === 'scheduler-actor' + && $event['properties']['outcome'] === 'success' + && $event['properties']['exit_code'] === 0); + } +} diff --git a/packages/laravel/tests/TransactionalModelEventsTest.php b/packages/laravel/tests/TransactionalModelEventsTest.php new file mode 100644 index 0000000..2a85c63 --- /dev/null +++ b/packages/laravel/tests/TransactionalModelEventsTest.php @@ -0,0 +1,126 @@ +set('posthog.api_key', 'project-key'); + $app['config']->set('posthog.autocapture.models', [ + 'enabled' => true, 'allow' => [CollectedRecord::class => ['state']], + ]); + $app['config']->set('database.default', 'a'); + foreach (['a', 'b'] as $name) { + $app['config']->set('database.connections.' . $name, ['driver' => 'sqlite', 'database' => ':memory:']); + } + } + + protected function setUp(): void + { + parent::setUp(); + PostHog::fake(); + $this->app->make(Context::class)->begin(['distinctId' => 'actor']); + foreach (['a', 'b'] as $name) { + DB::connection($name)->getSchemaBuilder()->create('collected_records', function (Blueprint $table) { + $table->id(); + $table->string('state'); + $table->string('email'); + }); + } + } + + private function record(string $state, string $connection = 'a'): void + { + CollectedRecord::on($connection)->create(['state' => $state, 'email' => 'private']); + } + + private function states(): array + { + return array_map( + fn ($call) => $call['message']['properties']['attributes']['state'], + PostHog::getFacadeRoot()->recorded('capture'), + ); + } + + public function testWrapperOnlyWritesCaptureImmediatelyLikeAfterCommit(): void + { + $afterCommitRan = false; + DB::afterCommit(function () use (&$afterCommitRan) { + $afterCommitRan = true; + }); + $this->assertTrue($afterCommitRan); + $this->record('wrapper only'); + $this->assertSame(['wrapper only'], $this->states()); + $this->assertSame(['count' => 0, 'bytes' => 0], $this->app->make(DeferredModelEvents::class)->pendingUsage()); + } + + public function testApplicationTransactionsStillDeferAndRemainConnectionCorrect(): void + { + DB::connection('a')->beginTransaction(); + $this->record('a committed'); + $this->record('b wrapper only', 'b'); + $this->assertSame(['b wrapper only'], $this->states()); + DB::connection('b')->beginTransaction(); + $this->record('b rolled back', 'b'); + DB::connection('a')->commit(); + $this->assertSame(['b wrapper only', 'a committed'], $this->states()); + DB::connection('b')->rollBack(); + $this->assertSame(['b wrapper only', 'a committed'], $this->states()); + $this->assertSame(['count' => 0, 'bytes' => 0], $this->app->make(DeferredModelEvents::class)->pendingUsage()); + } + + public function testWrapperOnlyWritesDoNotWaitForAnotherConnectionsFullBuffer(): void + { + DB::connection('a')->beginTransaction(); + for ($i = 0; $i < 1000; $i++) { + $this->record('pending a'); + } + $this->assertSame(1000, $this->app->make(DeferredModelEvents::class)->pendingUsage()['count']); + $this->record('b wrapper only', 'b'); + $this->assertSame(['b wrapper only'], $this->states()); + DB::connection('a')->rollBack(); + $this->assertSame(['b wrapper only'], $this->states()); + $this->assertSame(['count' => 0, 'bytes' => 0], $this->app->make(DeferredModelEvents::class)->pendingUsage()); + } + + public function testNestedCommitAndRollbackFollowApplicationTransactionBoundaries(): void + { + DB::beginTransaction(); + $this->record('outer'); + DB::beginTransaction(); + $this->record('discarded child'); + DB::rollBack(); + DB::beginTransaction(); + $this->record('committed child'); + DB::commit(); + PostHog::assertNothingCaptured(); + DB::commit(); + $this->assertSame(['outer', 'committed child'], $this->states()); + DB::beginTransaction(); + $this->record('discarded outer'); + DB::rollBack(); + $this->record('wrapper again'); + $this->assertSame(['outer', 'committed child', 'wrapper again'], $this->states()); + $this->assertSame(['count' => 0, 'bytes' => 0], $this->app->make(DeferredModelEvents::class)->pendingUsage()); + } +}