Skip to content

Commit 6a64f65

Browse files
authored
Laravel Lighthouse tracing integration (#490)
1 parent 960046d commit 6a64f65

File tree

6 files changed

+258
-9
lines changed

6 files changed

+258
-9
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- Add all log context as `log_context` to events when using the log channel (#489)
6+
- Add integration to improve performance tracing for [Laravel Lighthouse](https://github.com/nuwave/lighthouse) (#490)
67

78
## 2.5.3
89

config/sentry.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@
4242

4343
// Capture views as spans
4444
'views' => true,
45+
46+
// Indicates if the tracing integrations supplied by Sentry should be loaded
47+
'default_integrations' => true,
4548
],
4649

4750
// @see: https://docs.sentry.io/platforms/php/configuration/options/#send-default-pii

src/Sentry/Laravel/BaseServiceProvider.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,18 @@ protected function getUserConfig(): array
3636

3737
return empty($config) ? [] : $config;
3838
}
39+
40+
/**
41+
* Checks if the config is set in such a way that performance tracing could be enabled.
42+
*
43+
* Because of `traces_sampler` being dynamic we can never be 100% confident but that is also not important.
44+
*
45+
* @return bool
46+
*/
47+
protected function couldHavePerformanceTracingEnabled(): bool
48+
{
49+
$config = $this->getUserConfig();
50+
51+
return !empty($config['traces_sample_rate']) || !empty($config['traces_sampler']);
52+
}
3953
}

src/Sentry/Laravel/ServiceProvider.php

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
use Illuminate\Foundation\Http\Kernel as HttpKernel;
88
use Illuminate\Log\LogManager;
99
use Laravel\Lumen\Application as Lumen;
10+
use RuntimeException;
1011
use Sentry\ClientBuilder;
1112
use Sentry\ClientBuilderInterface;
1213
use Sentry\Integration as SdkIntegration;
1314
use Sentry\Laravel\Http\LaravelRequestFetcher;
1415
use Sentry\Laravel\Http\SetRequestIpMiddleware;
1516
use Sentry\Laravel\Http\SetRequestMiddleware;
17+
use Sentry\Laravel\Tracing\ServiceProvider as TracingServiceProvider;
1618
use Sentry\SentrySdk;
1719
use Sentry\State\Hub;
1820
use Sentry\State\HubInterface;
@@ -23,7 +25,7 @@ class ServiceProvider extends BaseServiceProvider
2325
* List of configuration options that are Laravel specific and should not be sent to the base PHP SDK.
2426
*/
2527
private const LARAVEL_SPECIFIC_OPTIONS = [
26-
// We do not want these settings to hit the PHP SDK because it's Laravel specific and the SDK will throw errors
28+
// We do not want these settings to hit the PHP SDK because they are Laravel specific and the PHP SDK will throw errors
2729
'tracing',
2830
'breadcrumbs',
2931
// We resolve the integrations through the container later, so we initially do not pass it to the SDK yet
@@ -132,7 +134,7 @@ protected function configureAndRegisterClient(): void
132134
}
133135

134136
$this->app->bind(ClientBuilderInterface::class, function () {
135-
$basePath = base_path();
137+
$basePath = base_path();
136138
$userConfig = $this->getUserConfig();
137139

138140
foreach (self::LARAVEL_SPECIFIC_OPTIONS as $laravelSpecificOptionName) {
@@ -141,7 +143,7 @@ protected function configureAndRegisterClient(): void
141143

142144
$options = \array_merge(
143145
[
144-
'prefixes' => [$basePath],
146+
'prefixes' => [$basePath],
145147
'in_app_exclude' => ["{$basePath}/vendor"],
146148
],
147149
$userConfig
@@ -228,21 +230,40 @@ private function resolveIntegrationsFromUserConfig(): array
228230
new Integration\ExceptionContextIntegration,
229231
];
230232

231-
$userIntegrations = $this->getUserConfig()['integrations'] ?? [];
233+
$userConfig = $this->getUserConfig();
234+
235+
$integrationsToResolve = $userConfig['integrations'] ?? [];
236+
237+
$enableDefaultTracingIntegrations = $userConfig['tracing']['default_integrations'] ?? true;
232238

233-
foreach ($userIntegrations as $userIntegration) {
239+
if ($enableDefaultTracingIntegrations && $this->couldHavePerformanceTracingEnabled()) {
240+
$integrationsToResolve = array_merge($integrationsToResolve, TracingServiceProvider::DEFAULT_INTEGRATIONS);
241+
}
242+
243+
foreach ($integrationsToResolve as $userIntegration) {
234244
if ($userIntegration instanceof SdkIntegration\IntegrationInterface) {
235245
$integrations[] = $userIntegration;
236246
} elseif (\is_string($userIntegration)) {
237247
$resolvedIntegration = $this->app->make($userIntegration);
238248

239-
if (!($resolvedIntegration instanceof SdkIntegration\IntegrationInterface)) {
240-
throw new \RuntimeException('Sentry integrations should a instance of `\Sentry\Integration\IntegrationInterface`.');
249+
if (!$resolvedIntegration instanceof SdkIntegration\IntegrationInterface) {
250+
throw new RuntimeException(
251+
sprintf(
252+
'Sentry integrations must be an instance of `%s` got `%s`.',
253+
SdkIntegration\IntegrationInterface::class,
254+
get_class($resolvedIntegration)
255+
)
256+
);
241257
}
242258

243259
$integrations[] = $resolvedIntegration;
244260
} else {
245-
throw new \RuntimeException('Sentry integrations should either be a container reference or a instance of `\Sentry\Integration\IntegrationInterface`.');
261+
throw new RuntimeException(
262+
sprintf(
263+
'Sentry integrations must either be a valid container reference or an instance of `%s`.',
264+
SdkIntegration\IntegrationInterface::class
265+
)
266+
);
246267
}
247268
}
248269

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
<?php
2+
3+
namespace Sentry\Laravel\Tracing\Integrations;
4+
5+
use GraphQL\Language\AST\DocumentNode;
6+
use GraphQL\Language\AST\OperationDefinitionNode;
7+
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
8+
use Nuwave\Lighthouse\Events\EndExecution;
9+
use Nuwave\Lighthouse\Events\EndRequest;
10+
use Nuwave\Lighthouse\Events\StartExecution;
11+
use Nuwave\Lighthouse\Events\StartRequest;
12+
use Sentry\Integration\IntegrationInterface;
13+
use Sentry\Laravel\Integration;
14+
use Sentry\SentrySdk;
15+
use Sentry\Tracing\SpanContext;
16+
17+
class LighthouseIntegration implements IntegrationInterface
18+
{
19+
/** @var array<int, array{?string, \GraphQL\Language\AST\OperationDefinitionNode}> */
20+
private $operations;
21+
22+
/** @var \Sentry\Tracing\Span|null */
23+
private $previousSpan;
24+
25+
/** @var \Sentry\Tracing\Span|null */
26+
private $requestSpan;
27+
28+
/** @var \Sentry\Tracing\Span|null */
29+
private $operationSpan;
30+
31+
/** @var \Illuminate\Contracts\Events\Dispatcher */
32+
private $eventDispatcher;
33+
34+
/**
35+
* Indicates if, when building the transaction name, the operation name should be ignored.
36+
*
37+
* @var bool
38+
*/
39+
private $ignoreOperationName;
40+
41+
public function __construct(EventDispatcher $eventDispatcher, bool $ignoreOperationName = false)
42+
{
43+
$this->eventDispatcher = $eventDispatcher;
44+
$this->ignoreOperationName = $ignoreOperationName;
45+
}
46+
47+
public function setupOnce(): void
48+
{
49+
if ($this->isApplicable()) {
50+
$this->eventDispatcher->listen(StartRequest::class, [$this, 'handleStartRequest']);
51+
$this->eventDispatcher->listen(StartExecution::class, [$this, 'handleStartExecution']);
52+
$this->eventDispatcher->listen(EndExecution::class, [$this, 'handleEndExecution']);
53+
$this->eventDispatcher->listen(EndRequest::class, [$this, 'handleEndRequest']);
54+
}
55+
}
56+
57+
public function handleStartRequest(StartRequest $startRequest): void
58+
{
59+
$this->previousSpan = Integration::currentTracingSpan();
60+
61+
if ($this->previousSpan === null) {
62+
return;
63+
}
64+
65+
$context = new SpanContext;
66+
$context->setOp('graphql.request');
67+
68+
$this->operations = [];
69+
$this->requestSpan = $this->previousSpan->startChild($context);
70+
$this->operationSpan = null;
71+
72+
SentrySdk::getCurrentHub()->setSpan($this->requestSpan);
73+
}
74+
75+
public function handleStartExecution(StartExecution $startExecution): void
76+
{
77+
if ($this->requestSpan === null) {
78+
return;
79+
}
80+
81+
if (!$startExecution->query instanceof DocumentNode) {
82+
return;
83+
}
84+
85+
/** @var \GraphQL\Language\AST\OperationDefinitionNode|null $operationDefinition */
86+
$operationDefinition = $startExecution->query->definitions[0] ?? null;
87+
88+
if (!$operationDefinition instanceof OperationDefinitionNode) {
89+
return;
90+
}
91+
92+
$this->operations[] = [$startExecution->operationName ?? null, $operationDefinition];
93+
94+
$this->updateTransactionName();
95+
96+
$context = new SpanContext;
97+
$context->setOp("graphql.{$operationDefinition->operation}");
98+
99+
$this->operationSpan = $this->requestSpan->startChild($context);
100+
101+
SentrySdk::getCurrentHub()->setSpan($this->operationSpan);
102+
}
103+
104+
public function handleEndExecution(EndExecution $endExecution): void
105+
{
106+
if ($this->operationSpan === null) {
107+
return;
108+
}
109+
110+
$this->operationSpan->finish();
111+
$this->operationSpan = null;
112+
113+
SentrySdk::getCurrentHub()->setSpan($this->requestSpan);
114+
}
115+
116+
public function handleEndRequest(EndRequest $endRequest): void
117+
{
118+
if ($this->requestSpan === null) {
119+
return;
120+
}
121+
122+
$this->requestSpan->finish();
123+
$this->requestSpan = null;
124+
125+
SentrySdk::getCurrentHub()->setSpan($this->previousSpan);
126+
$this->previousSpan = null;
127+
128+
$this->operations = [];
129+
}
130+
131+
private function updateTransactionName(): void
132+
{
133+
$transaction = SentrySdk::getCurrentHub()->getTransaction();
134+
135+
if ($transaction === null) {
136+
return;
137+
}
138+
139+
$groupedOperations = [];
140+
141+
foreach ($this->operations as [$operationName, $operation]) {
142+
if (!isset($groupedOperations[$operation->operation])) {
143+
$groupedOperations[$operation->operation] = [];
144+
}
145+
146+
if ($operationName === null || $this->ignoreOperationName) {
147+
$groupedOperations[$operation->operation] = array_merge(
148+
$groupedOperations[$operation->operation],
149+
$this->extractOperationNames($operation)
150+
);
151+
} else {
152+
$groupedOperations[$operation->operation][] = $operationName;
153+
}
154+
}
155+
156+
if (empty($groupedOperations)) {
157+
return;
158+
}
159+
160+
array_walk($groupedOperations, static function (array &$operations, string $operationType) {
161+
sort($operations, SORT_STRING);
162+
163+
$operations = "{$operationType}{" . implode(',', $operations) . '}';
164+
});
165+
166+
ksort($groupedOperations, SORT_STRING);
167+
168+
$transactionName = 'lighthouse?' . implode('&', $groupedOperations);
169+
170+
$transaction->setName($transactionName);
171+
172+
Integration::setTransaction($transactionName);
173+
}
174+
175+
/**
176+
* @return array<int, string>
177+
*/
178+
private function extractOperationNames(OperationDefinitionNode $operation): array
179+
{
180+
if (!$this->ignoreOperationName && $operation->name !== null) {
181+
return [$operation->name->value];
182+
}
183+
184+
$selectionSet = [];
185+
186+
/** @var \GraphQL\Language\AST\FieldNode $selection */
187+
foreach ($operation->selectionSet->selections as $selection) {
188+
// Not respecting aliases because they are only relevant for clients
189+
// and the tracing we extract here is targeted at server developers.
190+
$selectionSet[] = $selection->name->value;
191+
}
192+
193+
sort($selectionSet, SORT_STRING);
194+
195+
return $selectionSet;
196+
}
197+
198+
private function isApplicable(): bool
199+
{
200+
if (!class_exists(StartRequest::class) || !class_exists(StartExecution::class)) {
201+
return false;
202+
}
203+
204+
return property_exists(StartExecution::class, 'query');
205+
}
206+
}

src/Sentry/Laravel/Tracing/ServiceProvider.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,13 @@
1616

1717
class ServiceProvider extends BaseServiceProvider
1818
{
19+
public const DEFAULT_INTEGRATIONS = [
20+
Integrations\LighthouseIntegration::class,
21+
];
22+
1923
public function boot(): void
2024
{
21-
if ($this->hasDsnSet()) {
25+
if ($this->hasDsnSet() && $this->couldHavePerformanceTracingEnabled()) {
2226
$tracingConfig = $this->getUserConfig()['tracing'] ?? [];
2327

2428
$this->bindEvents($tracingConfig);

0 commit comments

Comments
 (0)