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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config/sentry.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
// Capture Laravel logs in breadcrumbs
'logs' => true,

// Capture Livewire components in breadcrumbs
'livewire' => true,

// Capture SQL queries in breadcrumbs
'sql_queries' => true,

Expand Down Expand Up @@ -47,6 +50,9 @@
// Capture views as spans
'views' => true,

// Capture Livewire components as spans
'livewire' => true,

// Capture HTTP client requests as spans
'http_client_requests' => true,

Expand Down
119 changes: 119 additions & 0 deletions src/Sentry/Laravel/Features/Feature.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

namespace Sentry\Laravel\Features;

use Illuminate\Contracts\Container\Container;
use Sentry\Integration\IntegrationInterface;
use Sentry\Laravel\BaseServiceProvider;

/**
* @method void setup() Setup the feature in the environment.
*
* @internal
*/
abstract class Feature implements IntegrationInterface
{
/**
* @var Container The Laravel application container.
*/
private $container;

/**
* In-memory cache for the tracing feature flag.
*
* @var bool|null
*/
private $isTracingFeatureEnabled;

/**
* In-memory cache for the breadcumb feature flag.
*
* @var bool|null
*/
private $isBreadcrumbFeatureEnabled;

/**
* @param Container $container The Laravel application container.
*/
public function __construct(Container $container)
{
$this->container = $container;
}

/**
* Indicates if the feature is applicable to the current environment.
*
* @return bool
*/
abstract public function isApplicable(): bool;

/**
* Initializes the current integration by registering it once.
*/
public function setupOnce(): void
{
if (method_exists($this, 'setup') && $this->isApplicable()) {
try {
$this->container->call([$this, 'setup']);
} catch (\Throwable $exception) {
// If the feature setup fails, we don't want to prevent the rest of the SDK from working.
}
}
}

/**
* Retrieve the Laravel application container.
*
* @return Container
*/
protected function container(): Container
{
return $this->container;
}

/**
* Retrieve the user configuration.
*
* @return array
*/
protected function getUserConfig(): array
{
$config = $this->container['config'][BaseServiceProvider::$abstract];

return empty($config) ? [] : $config;
}

/**
* Indicates if the given feature is enabled for tracing.
*/
protected function isTracingFeatureEnabled(string $feature, bool $default = true): bool
{
if ($this->isTracingFeatureEnabled === null) {
$this->isTracingFeatureEnabled = $this->isFeatureEnabled('tracing', $feature, $default);
}

return $this->isTracingFeatureEnabled;
}

/**
* Indicates if the given feature is enabled for breadcrumbs.
*/
protected function isBreadcrumbFeatureEnabled(string $feature, bool $default = true): bool
{
if ($this->isBreadcrumbFeatureEnabled === null) {
$this->isBreadcrumbFeatureEnabled = $this->isFeatureEnabled('breadcrumbs', $feature, $default);
}

return $this->isBreadcrumbFeatureEnabled;
}

/**
* Helper to test if a certain feature is enabled in the user config.
*/
private function isFeatureEnabled(string $category, string $feature, bool $default): bool
{
$config = $this->getUserConfig()[$category] ?? [];

return ($config[$feature] ?? $default) === true;
}
}
145 changes: 145 additions & 0 deletions src/Sentry/Laravel/Features/LivewirePackageIntegration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php

namespace Sentry\Laravel\Features;

use Livewire\Component;
use Livewire\LivewireManager;
use Livewire\Request;
use Sentry\Breadcrumb;
use Sentry\Laravel\Integration;
use Sentry\SentrySdk;
use Sentry\Tracing\Span;
use Sentry\Tracing\SpanContext;
use Sentry\Tracing\TransactionSource;

class LivewirePackageIntegration extends Feature
{
private const FEATURE_KEY = 'livewire';

private const COMPONENT_SPAN_OP = 'ui.livewire.component';

/** @var array<Span> */
private $spanStack = [];

public function isApplicable(): bool
{
if (!class_exists(LivewireManager::class)) {
return false;
}

return $this->isTracingFeatureEnabled(self::FEATURE_KEY) || $this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY);
}

public function setup(LivewireManager $livewireManager): void
{
$livewireManager->listen('component.booted', [$this, 'handleComponentBooted']);

if ($this->isTracingFeatureEnabled(self::FEATURE_KEY)) {
$livewireManager->listen('component.boot', [$this, 'handleComponentBoot']);
$livewireManager->listen('component.dehydrate', [$this, 'handleComponentDehydrate']);
}

if ($this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY)) {
$livewireManager->listen('component.mount', [$this, 'handleComponentMount']);
}
}

public function handleComponentBoot(Component $component): void
{
$currentSpan = SentrySdk::getCurrentHub()->getSpan();

if ($currentSpan === null) {
return;
}

$this->spanStack[] = $currentSpan;

$context = new SpanContext;
$context->setOp(self::COMPONENT_SPAN_OP);
$context->setDescription($component->getName());

$componentSpan = $currentSpan->startChild($context);

SentrySdk::getCurrentHub()->setSpan($componentSpan);
}

public function handleComponentMount(Component $component, array $data): void
{
Integration::addBreadcrumb(new Breadcrumb(
Breadcrumb::LEVEL_INFO,
Breadcrumb::TYPE_DEFAULT,
'livewire',
"Component mount: {$component->getName()}",
$data
));
}

public function handleComponentBooted(Component $component, Request $request): void
{
if (!$this->isLivewireRequest()) {
return;
}

if ($this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY)) {
Integration::addBreadcrumb(new Breadcrumb(
Breadcrumb::LEVEL_INFO,
Breadcrumb::TYPE_DEFAULT,
'livewire',
"Component booted: {$component->getName()}",
['updates' => $request->updates]
));
}

if ($this->isTracingFeatureEnabled(self::FEATURE_KEY)) {
$this->updateTransactionName($component::getName());
}
}

public function handleComponentDehydrate(Component $component): void
{
$currentSpan = SentrySdk::getCurrentHub()->getSpan();

if ($currentSpan === null || empty($this->spanStack)) {
return;
}

$currentSpan->finish();

$previousSpan = array_pop($this->spanStack);

SentrySdk::getCurrentHub()->setSpan($previousSpan);
}

private function updateTransactionName(string $componentName): void
{
$transaction = SentrySdk::getCurrentHub()->getTransaction();

if ($transaction === null) {
return;
}

$transactionName = "livewire?component={$componentName}";

$transaction->setName($transactionName);
$transaction->getMetadata()->setSource(TransactionSource::custom());

Integration::setTransaction($transactionName);
}

private function isLivewireRequest(): bool
{
try {
/** @var \Illuminate\Http\Request $request */
$request = $this->container()->make('request');

if ($request === null) {
return false;
}

return $request->header('x-livewire') === 'true';
} catch (\Throwable $e) {
// If the request cannot be resolved, it's probably not a Livewire request.
return false;
}
}
}
18 changes: 16 additions & 2 deletions src/Sentry/Laravel/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ class ServiceProvider extends BaseServiceProvider
'controllers_base_namespace',
];

/**
* List of default feature integrations that are enabled by default.
*/
protected const DEFAULT_FEATURES = [
Features\LivewirePackageIntegration::class,
];

/**
* Boot the service provider.
*/
Expand Down Expand Up @@ -234,12 +241,19 @@ private function resolveIntegrationsFromUserConfig(): array

$userConfig = $this->getUserConfig();

$integrationsToResolve = $userConfig['integrations'] ?? [];
$integrationsToResolve = array_merge(
$userConfig['integrations'] ?? [],
// These features are enabled by default and can be configured using the `tracing` and `breadcrumbs` config
self::DEFAULT_FEATURES
);

$enableDefaultTracingIntegrations = $userConfig['tracing']['default_integrations'] ?? true;

if ($enableDefaultTracingIntegrations) {
$integrationsToResolve = array_merge($integrationsToResolve, TracingServiceProvider::DEFAULT_INTEGRATIONS);
$integrationsToResolve = array_merge(
$integrationsToResolve,
TracingServiceProvider::DEFAULT_INTEGRATIONS
);
}

foreach ($integrationsToResolve as $userIntegration) {
Expand Down