Skip to content

Commit 8e956f4

Browse files
Naorayclaude
andcommitted
feat(tracing): add request-based Livewire/Inertia detection
- Rewrite LivewireDataCollector to use request-based detection - Detects via X-Livewire header or /livewire/update path - Parses component data directly from request payload - No Livewire package dependency required - Captures: component name, id, path, methods, updated properties - Add InertiaDataCollector for Inertia.js requests - Detects via X-Inertia header - Captures: component, version, partial reload info, partial keys - No Inertia package dependency required - Update EventHandler to support multiple collectors per event - Restore context dehydration to prevent bloated job payloads - Add Inertia section to issue template and section mappings - Update ContextFormatter to exclude new context keys Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8733559 commit 8e956f4

File tree

11 files changed

+628
-103
lines changed

11 files changed

+628
-103
lines changed

config/github-monolog.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,12 @@
140140
// Collect outgoing HTTP request/response data
141141
'outgoing_requests' => true,
142142

143-
// Collect Livewire component context
143+
// Collect Livewire component context (auto-detects Livewire requests)
144144
'livewire' => true,
145145

146+
// Collect Inertia.js request context (auto-detects Inertia requests)
147+
'inertia' => true,
148+
146149
/*
147150
|--------------------------------------------------------------------------
148151
| Query Collector Settings

resources/views/issue.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@
7171
</details>
7272
<!-- livewire:end -->
7373

74+
<!-- inertia:start -->
75+
<details>
76+
<summary>🚀 Inertia Request</summary>
77+
78+
{inertia}
79+
80+
</details>
81+
<!-- inertia:end -->
82+
7483
<!-- user:start -->
7584
<details>
7685
<summary>👤 User Details</summary>

src/GithubMonologServiceProvider.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@
22

33
namespace Naoray\LaravelGithubMonolog;
44

5+
use Illuminate\Support\Facades\Context;
56
use Illuminate\Support\Facades\Event;
67
use Illuminate\Support\ServiceProvider;
78
use Naoray\LaravelGithubMonolog\Tracing\EventHandler;
89

910
class GithubMonologServiceProvider extends ServiceProvider
1011
{
12+
public function register(): void
13+
{
14+
$this->registerContextDehydration();
15+
}
16+
1117
public function boot(): void
1218
{
1319
$config = config('logging.channels.github.tracing');
@@ -22,4 +28,36 @@ public function boot(): void
2228
], 'github-monolog-views');
2329
}
2430
}
31+
32+
/**
33+
* Register context dehydration callback to prevent large tracing data
34+
* from being serialized into job payloads.
35+
*/
36+
protected function registerContextDehydration(): void
37+
{
38+
Context::dehydrating(function ($context) {
39+
$keysToForget = [
40+
'queries',
41+
'outgoing_requests',
42+
'session',
43+
'request',
44+
'livewire',
45+
'livewire_originating_page',
46+
'inertia',
47+
];
48+
49+
foreach ($keysToForget as $key) {
50+
if ($context->has($key)) {
51+
$context->forget($key);
52+
}
53+
}
54+
55+
// Clean up prefixed keys
56+
foreach (array_keys($context->all()) as $key) {
57+
if (str_starts_with($key, 'outgoing_request.')) {
58+
$context->forget($key);
59+
}
60+
}
61+
});
62+
}
2563
}

src/Issues/Formatters/ContextFormatter.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ public function format(array $context): string
2222
'outgoing_requests',
2323
'session',
2424
'livewire',
25+
'livewire_originating_page',
26+
'inertia',
2527
]);
2628

2729
if (empty($context)) {

src/Issues/SectionMapping.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class SectionMapping
2020
'{outgoing_requests}' => 'outgoing_requests',
2121
'{session}' => 'session',
2222
'{livewire}' => 'livewire',
23+
'{inertia}' => 'inertia',
2324
'{context}' => 'context',
2425
'{extra}' => 'extra',
2526
'{prev_exception_simplified_stack_trace}' => 'prev-exception-stacktrace',
@@ -61,6 +62,6 @@ public static function getSectionPattern(string $section, bool $removeContent =
6162

6263
public static function getStandaloneFlagPattern(): string
6364
{
64-
return '/<!-- (stacktrace|prev-stacktrace|context|extra|prev-exception|prev-exception-stacktrace|environment|request|route|user|queries|job|command|outgoing_requests|session|livewire):(start|end) -->\n?/s';
65+
return '/<!-- (stacktrace|prev-stacktrace|context|extra|prev-exception|prev-exception-stacktrace|environment|request|route|user|queries|job|command|outgoing_requests|session|livewire|inertia):(start|end) -->\n?/s';
6566
}
6667
}

src/Issues/TemplateRenderer.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ private function buildReplacements(LogRecord $record, ?string $signature): array
108108
'{outgoing_requests}' => $this->outgoingRequestFormatter->format($record->context['outgoing_requests'] ?? null),
109109
'{session}' => $this->structuredDataFormatter->format($record->context['session'] ?? null),
110110
'{livewire}' => $this->structuredDataFormatter->format($record->context['livewire'] ?? null),
111+
'{inertia}' => $this->structuredDataFormatter->format($record->context['inertia'] ?? null),
111112
'{context}' => $this->contextFormatter->format($record->context),
112113
'{extra}' => $this->extraFormatter->format(Arr::except($record->extra, ['github_issue_signature'])),
113114
];

src/Tracing/EventHandler.php

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,19 @@ class EventHandler
1919
/**
2020
* Get all event-driven collectors mapped to their events.
2121
*
22-
* @return array<string, class-string>
22+
* Returns an array where keys are event classes and values are either
23+
* a single collector class or an array of collector classes.
24+
*
25+
* @return array<string, class-string|array<class-string>>
2326
*/
2427
protected static function getCollectors(): array
2528
{
2629
return [
27-
RequestHandled::class => RequestDataCollector::class,
30+
RequestHandled::class => [
31+
RequestDataCollector::class,
32+
LivewireDataCollector::class,
33+
InertiaDataCollector::class,
34+
],
2835
RouteMatched::class => RouteDataCollector::class,
2936
Authenticated::class => UserDataCollector::class,
3037
QueryExecuted::class => QueryCollector::class,
@@ -48,17 +55,22 @@ public function subscribe(Dispatcher $events): void
4855
return;
4956
}
5057

51-
foreach (self::getCollectors() as $eventClass => $collectorClass) {
52-
/** @var EventDrivenCollectorInterface $collector */
53-
$collector = new $collectorClass;
58+
foreach (self::getCollectors() as $eventClass => $collectors) {
59+
// Normalize to array for consistent handling
60+
$collectors = is_array($collectors) ? $collectors : [$collectors];
61+
62+
foreach ($collectors as $collectorClass) {
63+
/** @var EventDrivenCollectorInterface $collector */
64+
$collector = new $collectorClass;
5465

55-
if ($collector->isEnabled()) {
56-
$events->listen($eventClass, function ($event) use ($collectorClass) {
57-
/** @var EventDrivenCollectorInterface $collectorInstance */
58-
$collectorInstance = new $collectorClass;
66+
if ($collector->isEnabled()) {
67+
$events->listen($eventClass, function ($event) use ($collectorClass) {
68+
/** @var EventDrivenCollectorInterface $collectorInstance */
69+
$collectorInstance = new $collectorClass;
5970

60-
rescue(fn () => $collectorInstance($event));
61-
});
71+
rescue(fn () => $collectorInstance($event));
72+
});
73+
}
6274
}
6375
}
6476

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<?php
2+
3+
namespace Naoray\LaravelGithubMonolog\Tracing;
4+
5+
use Illuminate\Foundation\Http\Events\RequestHandled;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Support\Facades\Context;
8+
use Naoray\LaravelGithubMonolog\Tracing\Concerns\RedactsData;
9+
use Naoray\LaravelGithubMonolog\Tracing\Concerns\ResolvesTracingConfig;
10+
use Naoray\LaravelGithubMonolog\Tracing\Contracts\EventDrivenCollectorInterface;
11+
12+
/**
13+
* Inertia.js data collector.
14+
*
15+
* This collector detects Inertia requests by examining request headers,
16+
* without requiring the Inertia package as a dependency. It captures
17+
* component and version information from the request structure.
18+
*/
19+
class InertiaDataCollector implements EventDrivenCollectorInterface
20+
{
21+
use RedactsData;
22+
use ResolvesTracingConfig;
23+
24+
public function isEnabled(): bool
25+
{
26+
return $this->isTracingFeatureEnabled('inertia');
27+
}
28+
29+
public function __invoke(RequestHandled $event): void
30+
{
31+
if (! $this->isInertiaRequest($event->request)) {
32+
return;
33+
}
34+
35+
$this->captureFromRequest($event->request, $event->response);
36+
}
37+
38+
/**
39+
* Detect if the current request is an Inertia request.
40+
*/
41+
protected function isInertiaRequest(Request $request): bool
42+
{
43+
return $request->hasHeader('X-Inertia');
44+
}
45+
46+
/**
47+
* Capture Inertia request data.
48+
*
49+
* @param \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\Response $response
50+
*/
51+
protected function captureFromRequest(Request $request, $response): void
52+
{
53+
$data = [
54+
'version' => $request->header('X-Inertia-Version'),
55+
'partial_reload' => $request->hasHeader('X-Inertia-Partial-Data'),
56+
];
57+
58+
// Capture partial reload details
59+
if ($data['partial_reload']) {
60+
$data['partial_component'] = $request->header('X-Inertia-Partial-Component');
61+
$data['partial_keys'] = $this->parsePartialKeys(
62+
$request->header('X-Inertia-Partial-Data')
63+
);
64+
$data['partial_except'] = $this->parsePartialKeys(
65+
$request->header('X-Inertia-Partial-Except')
66+
);
67+
}
68+
69+
// Try to extract component name from response
70+
$component = $this->extractComponentFromResponse($response);
71+
if ($component !== null) {
72+
$data['component'] = $component;
73+
}
74+
75+
// Add request URL for context
76+
$data['url'] = $request->fullUrl();
77+
78+
// Filter nulls but keep false values (like partial_reload = false)
79+
Context::add('inertia', array_filter($data, fn ($value) => $value !== null));
80+
}
81+
82+
/**
83+
* Parse comma-separated partial keys into an array.
84+
*
85+
* @return array<string>|null
86+
*/
87+
protected function parsePartialKeys(?string $keys): ?array
88+
{
89+
if ($keys === null || $keys === '') {
90+
return null;
91+
}
92+
93+
return array_map('trim', explode(',', $keys));
94+
}
95+
96+
/**
97+
* Extract the Inertia component name from the response.
98+
*
99+
* @param \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\Response $response
100+
*/
101+
protected function extractComponentFromResponse($response): ?string
102+
{
103+
// Check X-Inertia header in response
104+
if (! $response->headers->has('X-Inertia')) {
105+
return null;
106+
}
107+
108+
// Try to parse component from JSON response
109+
$content = $response->getContent();
110+
if (! $content || ! is_string($content)) {
111+
return null;
112+
}
113+
114+
$decoded = json_decode($content, true);
115+
if (! is_array($decoded)) {
116+
return null;
117+
}
118+
119+
return $decoded['component'] ?? null;
120+
}
121+
}

0 commit comments

Comments
 (0)