phase 2
This commit is contained in:
@@ -95,17 +95,17 @@ A self-hosted error tracking application (inspired by [Bugsink](https://www.bugs
|
|||||||
|
|
||||||
Implement a `/api/{project_id}/store/` endpoint that accepts the Sentry event payload format. This allows using existing Sentry SDKs (for PHP, JS, Python, etc.) as clients without any custom SDK work.
|
Implement a `/api/{project_id}/store/` endpoint that accepts the Sentry event payload format. This allows using existing Sentry SDKs (for PHP, JS, Python, etc.) as clients without any custom SDK work.
|
||||||
|
|
||||||
- [ ] `POST /api/{project_id}/store/` route
|
- [x] `POST /api/{project_id}/store/` route
|
||||||
- [ ] Validate DSN public key via `sentry_key` query param or `X-Sentry-Auth` header
|
- [x] Validate DSN public key via `sentry_key` query param or `X-Sentry-Auth` header
|
||||||
- [ ] `AuthenticateDsn` middleware
|
- [x] `AuthenticateDsn` middleware
|
||||||
- [ ] `EventIngestController`
|
- [x] `EventIngestController`
|
||||||
|
|
||||||
### 2.2 Event Processing Pipeline
|
### 2.2 Event Processing Pipeline
|
||||||
|
|
||||||
- [ ] `ProcessEventJob` (queued)
|
- [x] `ProcessEventJob` (queued)
|
||||||
- [ ] `PayloadNormalizer` service
|
- [x] `PayloadNormalizer` service
|
||||||
- [ ] `EventProcessor` service (find or create Issue, store Event, update counters)
|
- [x] `EventProcessor` service (find or create Issue, store Event, update counters)
|
||||||
- [ ] Regression detection (re-open resolved issues)
|
- [x] Regression detection (re-open resolved issues)
|
||||||
|
|
||||||
```
|
```
|
||||||
Ingest Request
|
Ingest Request
|
||||||
@@ -122,7 +122,7 @@ Ingest Request
|
|||||||
|
|
||||||
### 2.3 Fingerprinting / Grouping Strategy
|
### 2.3 Fingerprinting / Grouping Strategy
|
||||||
|
|
||||||
- [ ] `Fingerprinter` service
|
- [x] `Fingerprinter` service
|
||||||
|
|
||||||
Group events into issues using a fingerprint hash built from:
|
Group events into issues using a fingerprint hash built from:
|
||||||
1. Exception type (class name)
|
1. Exception type (class name)
|
||||||
@@ -238,8 +238,8 @@ Clients configure this DSN in their Sentry SDK — no changes needed on the SDK
|
|||||||
- [x] 1. Install Breeze, set up auth
|
- [x] 1. Install Breeze, set up auth
|
||||||
- [x] 2. Create migrations for `projects`, `issues`, `events`, `project_user`
|
- [x] 2. Create migrations for `projects`, `issues`, `events`, `project_user`
|
||||||
- [x] 3. Build models with relationships
|
- [x] 3. Build models with relationships
|
||||||
- [ ] 4. Build the ingest endpoint (`POST /api/{project_id}/store/`)
|
- [x] 4. Build the ingest endpoint (`POST /api/{project_id}/store/`)
|
||||||
- [ ] 5. Implement fingerprinting and the `ProcessEventJob`
|
- [x] 5. Implement fingerprinting and the `ProcessEventJob`
|
||||||
- [ ] 6. Build a minimal issues list page and issue detail page
|
- [ ] 6. Build a minimal issues list page and issue detail page
|
||||||
- [ ] 7. Test with a real Sentry SDK (e.g. `sentry/sentry-laravel` on another app pointing at this tracker)
|
- [ ] 7. Test with a real Sentry SDK (e.g. `sentry/sentry-laravel` on another app pointing at this tracker)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Jobs\ProcessEventJob;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class EventIngestController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request, Project $project): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->json()->all();
|
||||||
|
|
||||||
|
if (empty($payload)) {
|
||||||
|
return response()->json(['error' => 'Empty payload'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProcessEventJob::dispatch($project, $payload);
|
||||||
|
|
||||||
|
return response()->json(
|
||||||
|
['id' => $payload['event_id'] ?? null],
|
||||||
|
202,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class AuthenticateDsn
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$project = $request->route('project');
|
||||||
|
|
||||||
|
if (! $project instanceof Project) {
|
||||||
|
abort(404, 'Project not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sentryKey = $this->extractSentryKey($request);
|
||||||
|
|
||||||
|
if (! $sentryKey || $sentryKey !== $project->dsn_public_key) {
|
||||||
|
abort(401, 'Invalid DSN key.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the sentry_key from the query string or X-Sentry-Auth header.
|
||||||
|
*
|
||||||
|
* Header format: Sentry sentry_version=7, sentry_client=..., sentry_key=<key>
|
||||||
|
*/
|
||||||
|
private function extractSentryKey(Request $request): ?string
|
||||||
|
{
|
||||||
|
if ($key = $request->query('sentry_key')) {
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
$header = $request->header('X-Sentry-Auth', '');
|
||||||
|
|
||||||
|
if (preg_match('/sentry_key=([a-f0-9\-]+)/i', $header, $matches)) {
|
||||||
|
return $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Services\EventProcessor;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ProcessEventJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
public int $backoff = 5;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public Project $project,
|
||||||
|
public array $rawPayload,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(EventProcessor $processor): void
|
||||||
|
{
|
||||||
|
$processor->process($this->project, $this->rawPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function failed(\Throwable $exception): void
|
||||||
|
{
|
||||||
|
Log::error('Failed to process event', [
|
||||||
|
'project_id' => $this->project->id,
|
||||||
|
'event_id' => $this->rawPayload['event_id'] ?? 'unknown',
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\Issue;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class EventProcessor
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Fingerprinter $fingerprinter,
|
||||||
|
private PayloadNormalizer $normalizer,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a raw Sentry event payload for the given project.
|
||||||
|
* Returns the created Event model.
|
||||||
|
*/
|
||||||
|
public function process(Project $project, array $rawPayload): Event
|
||||||
|
{
|
||||||
|
$data = $this->normalizer->normalize($rawPayload);
|
||||||
|
$fingerprint = $this->fingerprinter->compute($rawPayload);
|
||||||
|
$now = Carbon::now();
|
||||||
|
|
||||||
|
$issue = $this->findOrCreateIssue($project, $data, $fingerprint, $now);
|
||||||
|
|
||||||
|
$event = Event::create([
|
||||||
|
'issue_id' => $issue->id,
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'event_id' => $data['event_id'],
|
||||||
|
'level' => $data['level'],
|
||||||
|
'message' => $data['message'],
|
||||||
|
'platform' => $data['platform'],
|
||||||
|
'timestamp' => $data['timestamp'],
|
||||||
|
'server_name' => $data['server_name'],
|
||||||
|
'release' => $data['release'],
|
||||||
|
'environment' => $data['environment'],
|
||||||
|
'exception' => $data['exception'],
|
||||||
|
'breadcrumbs' => $data['breadcrumbs'],
|
||||||
|
'contexts' => $data['contexts'],
|
||||||
|
'tags' => $data['tags'],
|
||||||
|
'extra' => $data['extra'],
|
||||||
|
'user_context' => $data['user_context'],
|
||||||
|
'request_context' => $data['request_context'],
|
||||||
|
'sdk' => $data['sdk'],
|
||||||
|
'raw_payload' => $rawPayload,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $event;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findOrCreateIssue(
|
||||||
|
Project $project,
|
||||||
|
array $data,
|
||||||
|
string $fingerprint,
|
||||||
|
Carbon $now,
|
||||||
|
): Issue {
|
||||||
|
$issue = Issue::where('project_id', $project->id)
|
||||||
|
->where('fingerprint', $fingerprint)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($issue) {
|
||||||
|
$this->updateExistingIssue($issue, $data, $now);
|
||||||
|
|
||||||
|
return $issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->createNewIssue($project, $data, $fingerprint, $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createNewIssue(
|
||||||
|
Project $project,
|
||||||
|
array $data,
|
||||||
|
string $fingerprint,
|
||||||
|
Carbon $now,
|
||||||
|
): Issue {
|
||||||
|
$exception = $this->fingerprinter->extractPrimaryException(
|
||||||
|
['exception' => $data['exception']],
|
||||||
|
);
|
||||||
|
|
||||||
|
$title = $this->buildTitle($exception, $data['message']);
|
||||||
|
$culprit = $this->buildCulprit($exception);
|
||||||
|
|
||||||
|
return Issue::create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'fingerprint' => $fingerprint,
|
||||||
|
'title' => $title,
|
||||||
|
'culprit' => $culprit,
|
||||||
|
'level' => $data['level'],
|
||||||
|
'status' => 'unresolved',
|
||||||
|
'first_seen_at' => $now,
|
||||||
|
'last_seen_at' => $now,
|
||||||
|
'times_seen' => 1,
|
||||||
|
'is_regression' => false,
|
||||||
|
'metadata' => array_filter([
|
||||||
|
'platform' => $data['platform'],
|
||||||
|
'sdk' => $data['sdk'],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateExistingIssue(Issue $issue, array $data, Carbon $now): void
|
||||||
|
{
|
||||||
|
$wasResolved = $issue->status === 'resolved';
|
||||||
|
|
||||||
|
$issue->increment('times_seen');
|
||||||
|
$issue->update(['last_seen_at' => $now]);
|
||||||
|
|
||||||
|
if ($wasResolved) {
|
||||||
|
$issue->markRegression();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTitle(?array $exception, ?string $message): string
|
||||||
|
{
|
||||||
|
if ($exception) {
|
||||||
|
$type = $exception['type'] ?? 'Error';
|
||||||
|
$value = $exception['value'] ?? '';
|
||||||
|
|
||||||
|
return str($value)
|
||||||
|
->whenEmpty(fn () => str($type))
|
||||||
|
->prepend("{$type}: ")
|
||||||
|
->limit(255)
|
||||||
|
->toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return str($message ?? 'Unknown Error')->limit(255)->toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildCulprit(?array $exception): ?string
|
||||||
|
{
|
||||||
|
if (! $exception) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$frames = $exception['stacktrace']['frames'] ?? [];
|
||||||
|
$last = end($frames);
|
||||||
|
|
||||||
|
if (! $last) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $last['filename'] ?? $last['abs_path'] ?? '?';
|
||||||
|
$function = $last['function'] ?? null;
|
||||||
|
|
||||||
|
return $function ? "{$file} in {$function}" : $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
class Fingerprinter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Compute a fingerprint hash for grouping events into issues.
|
||||||
|
*
|
||||||
|
* If the payload contains a custom `fingerprint` array (Sentry SDK standard),
|
||||||
|
* that takes precedence. Otherwise we derive one from exception type, a
|
||||||
|
* sanitized message, and the top stacktrace frames.
|
||||||
|
*/
|
||||||
|
public function compute(array $payload): string
|
||||||
|
{
|
||||||
|
if (! empty($payload['fingerprint']) && is_array($payload['fingerprint'])) {
|
||||||
|
return $this->hash($payload['fingerprint']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
$exception = $this->extractPrimaryException($payload);
|
||||||
|
|
||||||
|
if ($exception) {
|
||||||
|
$parts[] = $exception['type'] ?? '';
|
||||||
|
$parts[] = $this->sanitizeMessage($exception['value'] ?? '');
|
||||||
|
$parts = array_merge($parts, $this->topFrames($exception, 5));
|
||||||
|
} else {
|
||||||
|
$parts[] = $payload['message'] ?? $payload['logentry']['message'] ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->hash($parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function extractPrimaryException(array $payload): ?array
|
||||||
|
{
|
||||||
|
$exceptions = $payload['exception']['values'] ?? [];
|
||||||
|
|
||||||
|
return end($exceptions) ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip variable parts (numbers, UUIDs, hex strings) so that
|
||||||
|
* "User 42 not found" and "User 99 not found" group together.
|
||||||
|
*/
|
||||||
|
private function sanitizeMessage(string $message): string
|
||||||
|
{
|
||||||
|
$message = preg_replace(
|
||||||
|
'/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i',
|
||||||
|
'{uuid}',
|
||||||
|
$message,
|
||||||
|
);
|
||||||
|
|
||||||
|
$message = preg_replace('/\b[0-9a-f]{24,}\b/i', '{hash}', $message);
|
||||||
|
|
||||||
|
$message = preg_replace('/\b\d+\b/', '{n}', $message);
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return file+function strings from the top N stacktrace frames.
|
||||||
|
*/
|
||||||
|
private function topFrames(array $exception, int $limit): array
|
||||||
|
{
|
||||||
|
$frames = $exception['stacktrace']['frames'] ?? [];
|
||||||
|
|
||||||
|
$frames = array_reverse($frames);
|
||||||
|
$frames = array_slice($frames, 0, $limit);
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
foreach ($frames as $frame) {
|
||||||
|
$file = $frame['filename'] ?? $frame['abs_path'] ?? '';
|
||||||
|
$function = $frame['function'] ?? '';
|
||||||
|
$parts[] = "{$file}:{$function}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hash(array $parts): string
|
||||||
|
{
|
||||||
|
return md5(implode("\x00", $parts));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class PayloadNormalizer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Normalize a raw Sentry event payload into the canonical shape
|
||||||
|
* expected by the EventProcessor.
|
||||||
|
*/
|
||||||
|
public function normalize(array $raw): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'event_id' => $raw['event_id'] ?? Str::uuid()->toString(),
|
||||||
|
'level' => $this->normalizeLevel($raw['level'] ?? 'error'),
|
||||||
|
'message' => $this->extractMessage($raw),
|
||||||
|
'platform' => $raw['platform'] ?? null,
|
||||||
|
'timestamp' => $this->normalizeTimestamp($raw['timestamp'] ?? null),
|
||||||
|
'server_name' => $raw['server_name'] ?? null,
|
||||||
|
'release' => $raw['release'] ?? null,
|
||||||
|
'environment' => $raw['environment'] ?? null,
|
||||||
|
'exception' => $raw['exception'] ?? ['values' => []],
|
||||||
|
'breadcrumbs' => $raw['breadcrumbs'] ?? null,
|
||||||
|
'contexts' => $raw['contexts'] ?? null,
|
||||||
|
'tags' => $this->normalizeTags($raw['tags'] ?? null),
|
||||||
|
'extra' => $raw['extra'] ?? null,
|
||||||
|
'user_context' => $raw['user'] ?? null,
|
||||||
|
'request_context' => $raw['request'] ?? null,
|
||||||
|
'sdk' => $raw['sdk'] ?? null,
|
||||||
|
'fingerprint' => $raw['fingerprint'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeLevel(string $level): string
|
||||||
|
{
|
||||||
|
$map = [
|
||||||
|
'fatal' => 'fatal',
|
||||||
|
'critical' => 'fatal',
|
||||||
|
'error' => 'error',
|
||||||
|
'warning' => 'warning',
|
||||||
|
'warn' => 'warning',
|
||||||
|
'info' => 'info',
|
||||||
|
'log' => 'info',
|
||||||
|
'debug' => 'debug',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $map[strtolower($level)] ?? 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractMessage(array $raw): ?string
|
||||||
|
{
|
||||||
|
if (! empty($raw['message'])) {
|
||||||
|
return Str::limit($raw['message'], 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($raw['logentry']['message'])) {
|
||||||
|
return Str::limit($raw['logentry']['message'], 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
$exceptions = $raw['exception']['values'] ?? [];
|
||||||
|
$last = end($exceptions);
|
||||||
|
if ($last) {
|
||||||
|
$type = $last['type'] ?? 'Error';
|
||||||
|
$value = $last['value'] ?? '';
|
||||||
|
|
||||||
|
return Str::limit("{$type}: {$value}", 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeTimestamp(mixed $timestamp): Carbon
|
||||||
|
{
|
||||||
|
if ($timestamp === null) {
|
||||||
|
return Carbon::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_numeric($timestamp)) {
|
||||||
|
return Carbon::createFromTimestamp((float) $timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Carbon::parse($timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tags can arrive as key-value pairs or as an array of {key, value} objects.
|
||||||
|
* Normalize to a plain associative array.
|
||||||
|
*/
|
||||||
|
private function normalizeTags(mixed $tags): ?array
|
||||||
|
{
|
||||||
|
if ($tags === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_is_list($tags)) {
|
||||||
|
$normalized = [];
|
||||||
|
foreach ($tags as $tag) {
|
||||||
|
if (isset($tag['key'], $tag['value'])) {
|
||||||
|
$normalized[$tag['key']] = $tag['value'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tags;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ use Illuminate\Foundation\Configuration\Middleware;
|
|||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
|
apiPrefix: 'api',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\EventIngestController;
|
||||||
|
use App\Http\Middleware\AuthenticateDsn;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::post('/{project}/store/', [EventIngestController::class, 'store'])
|
||||||
|
->middleware(AuthenticateDsn::class)
|
||||||
|
->name('api.events.store');
|
||||||
Reference in New Issue
Block a user