Slim webhook log broadcasts to fix Reverb payload-too-large (#329)

* Stop broadcasting webhook payloads so Reverb does not reject live log updates.

The Echo event only needs list metadata; Inertia reloads the full log after the status lands.

* Type webhook Echo payloads separately and cover delivered, failed, and pending broadcasts.

The show page still receives payload and body over HTTP; Echo only carries list metadata.
This commit is contained in:
Paulo Castellano 2026-09-04 10:04:07 -03:00 committed by GitHub
parent 58d8e066b5
commit 91a1d5ca1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 181 additions and 20 deletions

View file

@ -10,6 +10,10 @@
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Lightweight signal that a delivery log changed. Carries only list
* metadata the show page reloads `logs` over HTTP for payload and body.
*/
class LogUpdated implements ShouldBroadcast
{
use Dispatchable, SerializesModels;
@ -44,16 +48,22 @@ public function broadcastQueue(): string
}
/**
* @return array<string, mixed>
* @return array{
* id: string,
* event_type: string,
* response_status: int|null,
* delivered_at: string|null,
* failed_at: string|null,
* attempts: int,
* created_at: string
* }
*/
public function broadcastWith(): array
{
return [
'id' => $this->log->id,
'event_type' => $this->log->event_type,
'payload' => $this->log->payload,
'response_status' => $this->log->response_status,
'response_body' => $this->log->response_body,
'delivered_at' => $this->log->delivered_at?->toIso8601String(),
'failed_at' => $this->log->failed_at?->toIso8601String(),
'attempts' => $this->log->attempts,

View file

@ -44,4 +44,15 @@ public function failed(): static
'failed_at' => now(),
]);
}
public function pending(): static
{
return $this->state(fn (array $attributes): array => [
'response_status' => null,
'response_body' => null,
'delivered_at' => null,
'failed_at' => null,
'attempts' => 1,
]);
}
}

View file

@ -1,17 +1,19 @@
import { router } from '@inertiajs/vue3';
import { ref, toValue, watch, type MaybeRefOrGetter } from 'vue';
import { useWebhookEcho } from '@/composables/echo/useWebhookEcho';
import dayjs from '@/dayjs';
import type { WebhookLog } from '@/types/webhook';
import {
webhookLogFromBroadcast,
type WebhookLog,
type WebhookLogBroadcast,
} from '@/types/webhook';
type LiveFields = Pick<
WebhookLog,
'response_status' | 'response_body' | 'delivered_at' | 'failed_at' | 'attempts'
>;
const liveFields = (log: WebhookLog): LiveFields => ({
const liveFields = (log: WebhookLogBroadcast): Pick<
WebhookLogBroadcast,
'response_status' | 'delivered_at' | 'failed_at' | 'attempts'
> => ({
response_status: log.response_status,
response_body: log.response_body,
delivered_at: log.delivered_at,
failed_at: log.failed_at,
attempts: log.attempts,
@ -62,9 +64,11 @@ export const useWebhookLogs = (
const liveLogs = ref<WebhookLog[]>([...toValue(incomingLogs)]);
const selectedLog = ref<WebhookLog | null>(liveLogs.value[0] ?? null);
const applyLogUpdate = (incoming: WebhookLog): void => {
const applyLogUpdate = (incoming: WebhookLogBroadcast): void => {
const existing = liveLogs.value.find((log) => log.id === incoming.id);
const next = existing ? { ...existing, ...liveFields(incoming) } : incoming;
const next = existing
? { ...existing, ...liveFields(incoming) }
: webhookLogFromBroadcast(incoming);
liveLogs.value = existing
? liveLogs.value.map((log) => (log.id === next.id ? next : log))
@ -77,6 +81,8 @@ export const useWebhookLogs = (
if (!selectedLog.value || selectedLog.value.id === next.id) {
selectedLog.value = next;
}
router.reload({ only: ['logs'], preserveScroll: true });
};
useWebhookEcho(toValue(webhookId), '.webhook.log.updated', applyLogUpdate);

View file

@ -12,14 +12,23 @@ export interface WebhookWithSecret extends Webhook {
signing_secret: string;
}
export interface WebhookLog {
export interface WebhookLogBroadcast {
id: string;
event_type: string;
payload: Record<string, unknown> | null;
response_status: number | null;
response_body: string | null;
delivered_at: string | null;
failed_at: string | null;
attempts: number;
created_at: string;
}
export interface WebhookLog extends WebhookLogBroadcast {
payload: Record<string, unknown> | null;
response_body: string | null;
}
export const webhookLogFromBroadcast = (broadcast: WebhookLogBroadcast): WebhookLog => ({
...broadcast,
payload: null,
response_body: null,
});

View file

@ -221,6 +221,33 @@
->assertSessionHasErrors('events.0');
});
test('webhook show includes full log payload and response body', function () {
$webhook = Webhook::factory()->create([
'workspace_id' => $this->workspace->id,
]);
$log = WebhookLog::factory()->create([
'webhook_id' => $webhook->id,
'payload' => [
'id' => 'post-1',
'type' => EventType::PostPublished->value,
'data' => ['content' => 'Hello'],
],
'response_body' => 'OK',
]);
$this->actingAs($this->user)
->get(route('app.webhooks.show', $webhook))
->assertOk()
->assertInertia(fn ($page) => $page
->component('webhooks/Show')
->has('logs.data', 1)
->where('logs.data.0.id', $log->id)
->where('logs.data.0.payload.id', 'post-1')
->where('logs.data.0.payload.data.content', 'Hello')
->where('logs.data.0.response_body', 'OK')
);
});
test('authenticated users can view a webhook with signing_secret exposed', function () {
$webhook = Webhook::factory()->create([
'workspace_id' => $this->workspace->id,

View file

@ -618,11 +618,11 @@
});
});
test('dispatch webhook job broadcasts log updates', function () {
test('dispatch webhook job broadcasts a slim log update', function (int $status) {
Event::fake([LogUpdated::class]);
Http::fake([
'example.com/webhook' => Http::response('OK', 200),
'example.com/webhook' => Http::response($status === 200 ? 'OK' : 'Nope', $status),
]);
$job = new DispatchWebhook(
@ -631,7 +631,51 @@
['id' => 'test-123'],
);
app()->call([$job, 'handle']);
try {
app()->call([$job, 'handle']);
} catch (RuntimeException) {
// HTTP failures rethrow after the log is persisted.
}
Event::assertDispatched(LogUpdated::class);
Event::assertDispatched(LogUpdated::class, function (LogUpdated $event) use ($job): bool {
$broadcast = $event->broadcastWith();
return $event->log->id === $job->logId
&& array_keys($broadcast) === [
'id',
'event_type',
'response_status',
'delivered_at',
'failed_at',
'attempts',
'created_at',
];
});
})->with([
'delivered' => 200,
'rejected' => 500,
]);
test('dispatch webhook job broadcasts a slim log update when the endpoint is blocked', function () {
Event::fake([LogUpdated::class]);
$this->webhook->update(['endpoint' => 'http://127.0.0.1/webhook']);
$job = new DispatchWebhook(
$this->webhook,
WebhookEvent::PostPublished->value,
['id' => 'test-123'],
);
try {
app()->call([$job, 'handle']);
} catch (RuntimeException) {
// SSRF guard rethrows after the log is persisted.
}
Event::assertDispatched(LogUpdated::class, function (LogUpdated $event) use ($job): bool {
return $event->log->id === $job->logId
&& ! array_key_exists('payload', $event->broadcastWith())
&& ! array_key_exists('response_body', $event->broadcastWith());
});
});

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use App\Events\Webhook\LogUpdated;
use App\Models\WebhookLog;
use Illuminate\Broadcasting\PrivateChannel;
$oversizedDelivery = [
'payload' => [
'id' => 'huge-post-payload',
'type' => 'post.published',
'data' => ['content' => str_repeat('x', 20_000)],
],
'response_body' => str_repeat('y', 2_000),
];
test('event broadcasts on the webhook logs channel', function () {
$log = WebhookLog::factory()->create();
$channels = (new LogUpdated($log))->broadcastOn();
expect($channels)->toHaveCount(1)
->and($channels[0])->toBeInstanceOf(PrivateChannel::class)
->and($channels[0]->name)->toBe("private-webhook.{$log->webhook_id}.logs");
});
test('event broadcasts as a stable name on the broadcasts queue', function () {
$log = WebhookLog::factory()->create();
$event = new LogUpdated($log);
expect($event->broadcastAs())->toBe('webhook.log.updated')
->and($event->broadcastQueue())->toBe('broadcasts');
});
test('event broadcasts log status without the delivery payload', function (WebhookLog $log) {
$broadcast = (new LogUpdated($log))->broadcastWith();
expect($broadcast)->toBe([
'id' => $log->id,
'event_type' => $log->event_type,
'response_status' => $log->response_status,
'delivered_at' => $log->delivered_at?->toIso8601String(),
'failed_at' => $log->failed_at?->toIso8601String(),
'attempts' => $log->attempts,
'created_at' => $log->created_at->toIso8601String(),
])
->and($broadcast)->not->toHaveKeys(['payload', 'response_body', 'webhook_id'])
->and(strlen((string) json_encode($broadcast)))->toBeLessThan(10_000);
})->with([
'delivered' => fn () => WebhookLog::factory()->create($oversizedDelivery),
'failed' => fn () => WebhookLog::factory()->failed()->create($oversizedDelivery),
'pending' => fn () => WebhookLog::factory()->pending()->create($oversizedDelivery),
]);