File "Pulse.php"

Full Path: /var/www/drive/laravel/pulse/src/Pulse.php
File size: 16.03 KB
MIME-type: text/x-php
Charset: utf-8

<?php

namespace Laravel\Pulse;

use Carbon\CarbonImmutable;
use DateTimeInterface;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Lottery;
use Illuminate\Support\Str;
use Illuminate\Support\Traits\ForwardsCalls;
use Laravel\Pulse\Contracts\Ingest;
use Laravel\Pulse\Contracts\ResolvesUsers;
use Laravel\Pulse\Contracts\Storage;
use Laravel\Pulse\Events\ExceptionReported;
use RuntimeException;
use Throwable;

/**
 * @internal
 *
 * @mixin \Laravel\Pulse\Contracts\Storage
 */
class Pulse
{
    use Concerns\ConfiguresAfterResolving, ForwardsCalls;

    /**
     * The list of metric recorders.
     *
     * @var \Illuminate\Support\Collection<int, object>
     */
    protected Collection $recorders;

    /**
     * The list of queued items.
     *
     * @var \Illuminate\Support\Collection<int, \Laravel\Pulse\Entry|\Laravel\Pulse\Value>
     */
    protected Collection $entries;

    /**
     * The list of queued lazy entry and value resolvers.
     *
     * @var \Illuminate\Support\Collection<int, callable>
     */
    protected Collection $lazy;

    /**
     * Indicates if Pulse should be recording.
     */
    protected bool $shouldRecord = true;

    /**
     * The entry filters.
     *
     * @var \Illuminate\Support\Collection<int, (callable(\Laravel\Pulse\Entry|\Laravel\Pulse\Value): bool)>
     */
    protected Collection $filters;

    /**
     * The remembered user's ID.
     */
    protected int|string|null $rememberedUserId = null;

    /**
     * Indicates if Pulse routes will be registered.
     */
    protected bool $registersRoutes = true;

    /**
     * Handle exceptions using the given callback.
     *
     * @var ?callable(\Throwable): mixed
     */
    protected $handleExceptionsUsing = null;

    /**
     * The CSS paths to include on the dashboard.
     *
     * @var list<string|Htmlable>
     */
    protected $css = [__DIR__.'/../dist/pulse.css'];

    /**
     * Indicates that Pulse is currently evaluating the buffer.
     */
    protected bool $evaluatingBuffer = false;

    /**
     * Create a new Pulse instance.
     */
    public function __construct(protected Application $app)
    {
        $this->filters = collect([]);
        $this->recorders = collect([]);
        $this->entries = collect([]);
        $this->lazy = collect([]);
    }

    /**
     * Register a recorder.
     *
     * @param  array<class-string, array<mixed>|bool>  $recorders
     */
    public function register(array $recorders): self
    {
        $recorders = collect($recorders)->map(function ($recorder, $key) {
            if ($recorder === false || (is_array($recorder) && ! ($recorder['enabled'] ?? true))) {
                return;
            }

            return $this->app->make($key);
        })->filter()->values();

        $this->afterResolving($this->app, 'events', fn (Dispatcher $event) => $recorders
            ->filter(fn ($recorder) => $recorder->listen ?? null)
            ->each(fn ($recorder) => $event->listen(
                $recorder->listen,
                fn ($event) => $this->rescue(fn () => $recorder->record($event))
            ))
        );

        $recorders
            ->filter(fn ($recorder) => method_exists($recorder, 'register'))
            ->each(function ($recorder) {
                $this->app->call($recorder->register(...), [
                    'record' => fn (...$args) => $this->rescue(fn () => $recorder->record(...$args)),
                ]);
            });

        $this->recorders = collect([...$this->recorders, ...$recorders]);

        return $this;
    }

    /**
     * Record an entry.
     */
    public function record(
        string $type,
        string $key,
        ?int $value = null,
        DateTimeInterface|int|null $timestamp = null,
    ): Entry {
        $timestamp ??= CarbonImmutable::now();

        $entry = new Entry(
            timestamp: $timestamp instanceof DateTimeInterface ? $timestamp->getTimestamp() : $timestamp,
            type: $type,
            key: $key,
            value: $value,
        );

        if ($this->shouldRecord) {
            $this->entries[] = $entry;

            $this->ingestWhenOverBufferSize();
        }

        return $entry;
    }

    /**
     * Record a value.
     */
    public function set(
        string $type,
        string $key,
        string $value,
        DateTimeInterface|int|null $timestamp = null,
    ): Value {
        $timestamp ??= CarbonImmutable::now();

        $value = new Value(
            timestamp: $timestamp instanceof DateTimeInterface ? $timestamp->getTimestamp() : $timestamp,
            type: $type,
            key: $key,
            value: $value,
        );

        if ($this->shouldRecord) {
            $this->entries[] = $value;

            $this->ingestWhenOverBufferSize();
        }

        return $value;
    }

    /**
     * Lazily capture items.
     */
    public function lazy(callable $closure): self
    {
        if ($this->shouldRecord) {
            $this->lazy[] = $closure;

            $this->ingestWhenOverBufferSize();
        }

        return $this;
    }

    /**
     * Report the throwable exception to Pulse.
     */
    public function report(Throwable $e): self
    {
        $this->rescue(fn () => $this->app->make('events')->dispatch(new ExceptionReported($e)));

        return $this;
    }

    /**
     * Start recording.
     */
    public function startRecording(): self
    {
        $this->shouldRecord = true;

        return $this;
    }

    /**
     * Stop recording.
     */
    public function stopRecording(): self
    {
        $this->shouldRecord = false;

        return $this;
    }

    /**
     * Execute the given callback without recording.
     *
     * @template TReturn
     *
     * @param  (callable(): TReturn)  $callback
     * @return TReturn
     */
    public function ignore($callback): mixed
    {
        $cachedRecording = $this->shouldRecord;

        try {
            $this->shouldRecord = false;

            return $callback();
        } finally {
            $this->shouldRecord = $cachedRecording;
        }
    }

    /**
     * Flush the queue.
     */
    public function flush(): self
    {
        $this->entries = collect([]);

        $this->lazy = collect([]);

        $this->rememberedUserId = null;

        return $this;
    }

    /**
     * Filter items before storage using the provided filter.
     *
     * @param  (callable(\Laravel\Pulse\Entry|\Laravel\Pulse\Value): bool)  $filter
     */
    public function filter(callable $filter): self
    {
        $this->filters[] = $filter;

        return $this;
    }

    /**
     * Ingest the entries.
     */
    public function ingest(): int
    {
        $this->resolveLazyEntries();

        return $this->ignore(function () {
            $entries = $this->rescue(fn () => $this->entries->filter($this->shouldRecord(...))) ?? collect([]);

            if ($entries->isEmpty()) {
                $this->flush();

                return 0;
            }

            $ingest = $this->app->make(Ingest::class);

            $count = $this->rescue(function () use ($entries, $ingest) {
                $ingest->ingest($entries);

                return $entries->count();
            }) ?? 0;

            $odds = $this->app->make('config')->get('pulse.ingest.trim.lottery') ?? $this->app->make('config')->get('pulse.ingest.trim_lottery');

            Lottery::odds(...$odds)
                ->winner(fn () => $this->rescue($ingest->trim(...)))
                ->choose();

            $this->flush();

            return $count;
        });
    }

    /**
     * Digest the entries.
     */
    public function digest(): int
    {
        return $this->ignore(
            fn () => $this->app->make(Ingest::class)->digest($this->app->make(Storage::class))
        );
    }

    /**
     * Determine if Pulse wants to ingest entries.
     */
    public function wantsIngesting(): bool
    {
        return $this->lazy->isNotEmpty() || $this->entries->isNotEmpty();
    }

    /**
     * Start ingesting entires if over buffer size.
     */
    protected function ingestWhenOverBufferSize(): void
    {
        // To prevent recursion, we track when we are already evaluating the
        // buffer and resolving entries. When we are we may simply return
        // and the continue execution. We set the value to false later.
        if ($this->evaluatingBuffer) {
            return;
        }

        $buffer = $this->app->make('config')->get('pulse.ingest.buffer') ?? 5_000;

        if (($this->entries->count() + $this->lazy->count()) > $buffer) {
            $this->evaluatingBuffer = true;

            $this->resolveLazyEntries();
        }

        if ($this->entries->count() > $buffer) {
            $this->evaluatingBuffer = true;

            $this->ingest();
        }

        $this->evaluatingBuffer = false;
    }

    /**
     * Resolve lazy entries.
     */
    protected function resolveLazyEntries(): void
    {
        $this->rescue(fn () => $this->lazy->each(fn ($lazy) => $lazy()));

        $this->lazy = collect([]);
    }

    /**
     * Determine if the given entry should be recorded.
     */
    protected function shouldRecord(Entry|Value $entry): bool
    {
        return $this->filters->every(fn (callable $filter) => $filter($entry));
    }

    /**
     * Get the registered recorders.
     *
     * @return \Illuminate\Support\Collection<int, object>
     */
    public function recorders(): Collection
    {
        return collect($this->recorders);
    }

    /**
     * Resolve the user details for the given user IDs.
     *
     * @param  \Illuminate\Support\Collection<int, string>  $keys
     */
    public function resolveUsers(Collection $keys): ResolvesUsers
    {
        $resolver = $this->app->make(ResolvesUsers::class);

        return $resolver->load($keys);
    }

    /**
     * Resolve the users' details using the given closure.
     *
     * @deprecated
     *
     * @param  callable(\Illuminate\Support\Collection<int, mixed>): ?iterable<int|string, array{name: string, email?: ?string, avatar?: ?string, extra?: ?string}>  $callback
     */
    public function users(callable $callback): self
    {
        $this->app->instance(ResolvesUsers::class, new LegacyUsers($callback));

        return $this;
    }

    /**
     * Resolve the user's details using the given closure.
     *
     * @param  callable(\Illuminate\Contracts\Auth\Authenticatable): array{name: string, email?: ?string, avatar?: ?string, extra?: ?string}  $callback
     */
    public function user(callable $callback): self
    {
        $resolver = $this->app->make(ResolvesUsers::class);

        if (! method_exists($resolver, 'setFieldResolver')) {
            throw new RuntimeException('The configured user resolver does not support setting user fields');
        }

        $resolver->setFieldResolver($callback); // @phpstan-ignore method.nonObject

        return $this;
    }

    /**
     * Get the authenticated user ID resolver.
     *
     * @return callable(): (int|string|null)
     */
    public function authenticatedUserIdResolver(): callable
    {
        $auth = $this->app->make('auth');

        if ($auth->hasUser()) {
            $resolver = $this->app->make(ResolvesUsers::class);
            $key = $resolver->key($auth->user());

            return fn () => $key;
        }

        return function () {
            $auth = $this->app->make('auth');

            if ($auth->hasUser()) {
                $resolver = $this->app->make(ResolvesUsers::class);

                return $resolver->key($auth->user());
            } else {
                return $this->rememberedUserId;
            }
        };
    }

    /**
     * Resolve the authenticated user id.
     */
    public function resolveAuthenticatedUserId(): string|int|null
    {
        return $this->authenticatedUserIdResolver()();
    }

    /**
     * Remember the authenticated user's ID.
     */
    public function rememberUser(Authenticatable $user): self
    {
        $resolver = $this->app->make(ResolvesUsers::class);

        $this->rememberedUserId = $resolver->key($user);

        return $this;
    }

    /**
     * Register or return CSS for the Pulse dashboard.
     *
     * @param  string|Htmlable|list<string|Htmlable>|null  $css
     */
    public function css(string|Htmlable|array|null $css = null): string|self
    {
        if (func_num_args() === 1) {
            $this->css = array_values(array_unique(array_merge($this->css, Arr::wrap($css)), SORT_REGULAR));

            return $this;
        }

        return collect($this->css)->reduce(function ($carry, $css) {
            if ($css instanceof Htmlable) {
                return $carry.Str::finish($css->toHtml(), PHP_EOL);
            } else {
                if (($contents = @file_get_contents($css)) === false) {
                    throw new RuntimeException("Unable to load Pulse dashboard CSS path [$css].");
                }

                return $carry."<style>{$contents}</style>".PHP_EOL;
            }
        }, '');
    }

    /**
     * Return the compiled JavaScript from the vendor directory.
     */
    public function js(): string
    {
        if (
            ($livewire = @file_get_contents(__DIR__.'/../../../livewire/livewire/dist/livewire.js')) === false &&
            ($livewire = @file_get_contents(__DIR__.'/../vendor/livewire/livewire/dist/livewire.js')) === false) {
            throw new RuntimeException('Unable to load the Livewire JavaScript.');
        }

        if (($pulse = @file_get_contents(__DIR__.'/../dist/pulse.js')) === false) {
            throw new RuntimeException('Unable to load the Pulse dashboard JavaScript.');
        }

        return "<script>{$livewire}</script>".PHP_EOL."<script>{$pulse}</script>".PHP_EOL;
    }

    /**
     * The default "vendor" cache keys that should be ignored by Pulse.
     *
     * @return list<string>
     */
    public static function defaultVendorCacheKeys(): array
    {
        return [
            '/(^laravel_vapor_job_attemp(t?)s:)/', // Laravel Vapor keys...
            '/^.+@.+\|(?:(?:\d+\.\d+\.\d+\.\d+)|[0-9a-fA-F:]+)(?::timer)?$/', // Breeze / Jetstream keys...
            '/^[a-zA-Z0-9]{40}$/', // Session IDs...
            '/^illuminate:/', // Laravel keys...
            '/^laravel:pulse:/', // Pulse keys...
            '/^laravel:reverb:/', // Reverb keys...
            '/^nova/', // Nova keys...
            '/^telescope:/', // Telescope keys...
        ];
    }

    /**
     * Determine if Pulse may register routes.
     */
    public function registersRoutes(): bool
    {
        return $this->registersRoutes;
    }

    /**
     * Configure Pulse to not register its routes.
     */
    public function ignoreRoutes(): self
    {
        $this->registersRoutes = false;

        return $this;
    }

    /**
     * Handle exceptions using the given callback.
     *
     * @param  (callable(\Throwable): mixed)  $callback
     */
    public function handleExceptionsUsing(callable $callback): self
    {
        $this->handleExceptionsUsing = $callback;

        return $this;
    }

    /**
     * Execute the given callback handling any exceptions.
     *
     * @template TReturn
     *
     * @param  (callable(): TReturn)  $callback
     * @return TReturn|null
     */
    public function rescue(callable $callback): mixed
    {
        try {
            return $callback();
        } catch (Throwable $e) {
            ($this->handleExceptionsUsing ?? fn () => null)($e);
        }

        return null;
    }

    /**
     * Set the container instance.
     *
     * @param  \Illuminate\Contracts\Foundation\Application  $container
     * @return $this
     */
    public function setContainer($container)
    {
        $this->app = $container;

        return $this;
    }

    /**
     * Forward calls to the storage driver.
     *
     * @param  string  $method
     * @param  array<mixed>  $parameters
     */
    public function __call($method, $parameters): mixed
    {
        return $this->ignore(fn () => $this->forwardCallTo($this->app->make(Storage::class), $method, $parameters));
    }
}