honeybadger-io/honeybadger-php

View on GitHub
src/BacktraceFactory.php

Summary

Maintainability
A
0 mins
Test Coverage
C
77%
<?php

namespace Honeybadger;

use ErrorException;
use ReflectionClass;
use ReflectionException;
use Throwable;

class BacktraceFactory
{
    /**
     * @var \Throwable
     */
    protected $exception;

    /**
     * @var \Honeybadger\Config
     */
    protected $config;

    /**
     * @param \Throwable $exception
     * @param \Honeybadger\Config $config
     */
    public function __construct(Throwable $exception, Config $config)
    {
        $this->exception = $exception;
        $this->config = $config;
    }

    /**
     * @return array
     */
    public function trace(): array
    {
        $backtrace = $this->offsetForThrownException(
            $this->exception->getTrace()
        );

        return $this->formatBacktrace($backtrace);
    }

    /**
     * @return array
     */
    public function previous(): array
    {
        return $this->formatPrevious($this->exception);
    }

    /**
     * @param \Throwable $e
     * @param array $previousCauses
     *
     * @return array
     */
    private function formatPrevious(Throwable $e, array $previousCauses = []): array
    {
        if ($e = $e->getPrevious()) {
            $previousCauses[] = [
                'class' => get_class($e),
                'message' => $e->getMessage(),
                'backtrace' => (new self($e, $this->config))->trace(),
            ];

            return $this->formatPrevious($e, $previousCauses);
        }

        return $previousCauses;
    }

    /**
     * @param array $backtrace
     *
     * @return array
     */
    private function offsetForThrownException(array $backtrace): array
    {
        if ($this->exception instanceof ErrorException) {
            // For errors (ie not exceptions), the trace wrongly starts from
            // when we created the wrapping ErrorException class.
            // So we unwind it to the actual error location
            while (strpos($backtrace[0]['class'] ?? '', 'Honeybadger\\') !== false) {
                array_shift($backtrace);
            }
        }

        $backtrace[0] = array_merge($backtrace[0] ?? [], [
            'line' => $this->exception->getLine(),
            'file' => $this->exception->getFile(),
        ]);

        return $backtrace;
    }

    /**
     * @param array $backtrace
     *
     * @return array
     */
    private function formatBacktrace(array $backtrace): array
    {
        return array_map(function ($frame) {
            if (!array_key_exists('file', $frame)) {
                $context = $this->contextWithoutFile($frame);
            } else {
                $context = $this->contextWithFile($frame);
            }

            return array_merge($context, [
                'method' => $frame['function'] ?? null,
                'args' => $this->parseArgs($frame['args'] ?? []),
                'class' => $frame['class'] ?? null,
                'type' => $frame['type'] ?? null,
            ]);
        }, $backtrace);
    }

    /**
     * Parse method arguments and make any transformations.
     *
     * @param array $args
     *
     * @return array
     */
    private function parseArgs(array $args): array
    {
        return array_map(function ($arg) {
            return ArgumentValueNormalizer::normalize($arg);
        }, $args);
    }

    /**
     * @param array $frame
     *
     * @return array
     */
    private function contextWithoutFile(array $frame): array
    {
        if (!empty($frame['class'])) {
            $filename = sprintf('%s%s%s', $frame['class'], $frame['type'], $frame['function']);

            try {
                $reflect = new ReflectionClass($frame['class']);
                $filename = $reflect->getFileName();
            } catch (ReflectionException $e) {
                // Forget it if we run into errors, it's not worth it.
            }
        } elseif (!empty($frame['function'])) {
            $filename = sprintf('%s(anonymous)', $frame['function']);
        } else {
            $filename = sprintf('(anonymous)');
        }

        if (empty($filename)) {
            $filename = '[Anonymous function]';
        }

        return [
            'source' => null,
            'file' => $filename,
            'number' => '0',
        ];
    }

    /**
     * @param array $frame
     *
     * @return array
     */
    private function contextWithFile(array $frame): array
    {
        return [
            'source' => (new FileSource($frame['file'], $frame['line']))->getSource(),
            'file' => $frame['file'],
            'number' => (string)$frame['line'],
            'context' => $this->fileFromApplication($frame['file'], $this->config['vendor_paths'])
                ? 'app' : 'all',
        ];
    }

    private function fileFromApplication(string $filePath, array $vendorPaths): bool
    {
        $path = $this->appendProjectRootToFilePath($filePath);

        // On Windows, file paths use backslashes, so we have to normalise them
        $path = str_replace('\\', '/', $path);

        if (preg_match('/' . array_shift($vendorPaths) . '/', $path)) {
            return false;
        }

        if (!empty($vendorPaths)) {
            return $this->fileFromApplication($filePath, $vendorPaths);
        }

        return true;
    }

    private function appendProjectRootToFilePath(string $filePath): string
    {
        $pregProjectRoot = preg_quote($this->config['project_root'] . '/', '/');

        return $this->config['project_root']
            ? preg_replace('/' . $pregProjectRoot . '/', '', $filePath)
            : '';
    }
}