diff --git a/README.md b/README.md index d89bacc..924044e 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ [![PHP](https://img.shields.io/badge/PHP-8.2%2B-blue)](https://php.net) [![Laravel](https://img.shields.io/badge/Laravel-10%20|%2011%20|%2012%20|%2013-red)](https://laravel.com) [![License: MIT](https://img.shields.io/badge/License-MIT-green)](LICENSE) -[![Tests](https://img.shields.io/badge/tests-101%20passing-brightgreen)](#testing) +[![Tests](https://img.shields.io/badge/tests-104%20passing-brightgreen)](#testing) TraceReplay is not a standard error logger. It is a full-fledged **execution tracer** that captures every step of your complex workflows, reconstructs them with a waterfall timeline, and offers one-click AI debugging when things go wrong. -![TraceReplay Dashboard](art/preview.png) +![TraceReplay](art/preview.png) --- @@ -47,11 +47,10 @@ TraceReplay is not a standard error logger. It is a full-fledged **execution tra composer require iazaran/trace-replay ``` -Publish the config and migrations: +Publish the config: ```bash php artisan vendor:publish --tag=trace-replay-config -php artisan vendor:publish --tag=trace-replay-migrations ``` Run migrations: @@ -60,14 +59,18 @@ Run migrations: php artisan migrate ``` -> **Note:** Migrations use `json` columns and support `decimal` precision for timings, compatible with MySQL 5.7+, MariaDB, PostgreSQL, and SQLite. +> **Note:** Migrations run automatically without publishing. They use `json` columns and `decimal` precision for timings, compatible with MySQL 5.7+, MariaDB, PostgreSQL, and SQLite. -#### Publishing Views (Optional) +#### Publishing Views + +To customize the dashboard UI: ```bash php artisan vendor:publish --tag=trace-replay-views ``` +This copies the Blade templates to `resources/views/vendor/trace-replay/` where you can customize the layout, colors, or add your own branding. + --- ## ⚙️ Configuration @@ -260,14 +263,19 @@ Drop the `` Blade component into your layout for ins Access the built-in dashboard at `https://your-app.com/trace-replay`. +![Dashboard](art/dashboard.png) + **Features:** - **Waterfall timeline** — visual bars show each step's exact duration relative to the total trace - **Live stats** — auto-refreshing counters (total traces, failed, avg duration) - **Search & filter** — filter by name, IP, user ID; toggle failed-only view +- **Date range filter** — quickly filter traces by today, yesterday, last 7 days, or last 30 days - **Step inspector** — syntax-highlighted JSON for request payload, response payload, and state snapshot - **Replay engine** — re-execute any HTTP step and view a structural JSON diff - **AI Fix Prompt** — one-click prompt ready for Cursor, ChatGPT, or Claude +![Trace Details](art/details.png) + ### Securing the Dashboard Add authentication or authorization middleware in `config/trace-replay.php`: @@ -302,7 +310,32 @@ For any failed trace the dashboard shows an **AI Fix Prompt** button that genera - Request/response payloads (sensitive fields masked) - Step-by-step state snapshots -Paste this into any LLM. Optionally configure your OpenAI key and click **"Ask AI"** to get an answer directly in the dashboard. +### No API Key Required + +The AI prompt feature works **without any API key**. Copy the generated prompt and paste it into ChatGPT, Claude, or any other AI assistant. + +### Optional: Direct AI Integration + +For a seamless experience, configure an AI driver to get answers directly in the dashboard: + +```env +# OpenAI (default) +TRACE_REPLAY_AI_DRIVER=openai +TRACE_REPLAY_AI_KEY=sk-your-openai-key +TRACE_REPLAY_AI_MODEL=gpt-4o + +# Or Anthropic Claude +TRACE_REPLAY_AI_DRIVER=anthropic +TRACE_REPLAY_AI_KEY=sk-ant-your-key +TRACE_REPLAY_AI_MODEL=claude-3-5-sonnet-latest + +# Or Ollama (local, no API key needed) +TRACE_REPLAY_AI_DRIVER=ollama +TRACE_REPLAY_AI_MODEL=llama3 +TRACE_REPLAY_AI_BASE_URL=http://localhost:11434/api/generate +``` + +With a key configured, clicking **"Ask AI"** sends the prompt to your chosen AI provider and displays the response in the dashboard. --- @@ -371,7 +404,7 @@ composer install ./vendor/bin/pest ``` -101 tests, 200 assertions. The test suite covers: +104 tests, 208 assertions. The test suite covers: - Trace lifecycle (start, step, checkpoint, context, end, duration precision) - Error capturing, step ordering, DB query tracking - Model scopes (`failed`, `successful`, `search`) diff --git a/art/dashboard.png b/art/dashboard.png new file mode 100644 index 0000000..b84e4d4 Binary files /dev/null and b/art/dashboard.png differ diff --git a/art/details.png b/art/details.png new file mode 100644 index 0000000..deeb7ab Binary files /dev/null and b/art/details.png differ diff --git a/database/migrations/2024_01_01_000000_create_trace_replay_tables.php b/database/migrations/2024_01_01_000000_create_trace_replay_tables.php index 5a58dc8..f91b226 100644 --- a/database/migrations/2024_01_01_000000_create_trace_replay_tables.php +++ b/database/migrations/2024_01_01_000000_create_trace_replay_tables.php @@ -6,7 +6,7 @@ return new class extends Migration { - public function up() + public function up(): void { Schema::create('tr_workspaces', function (Blueprint $table) { $table->uuid('id')->primary(); @@ -27,10 +27,14 @@ public function up() $table->uuid('id')->primary(); $table->uuid('project_id')->nullable()->index(); $table->string('name')->nullable(); + $table->string('type', 20)->default('http'); // http, job, command, livewire $table->json('tags')->nullable(); - $table->float('duration_ms')->nullable(); + $table->string('trace_parent')->nullable()->index(); // W3C trace context + $table->decimal('duration_ms', 12, 2)->nullable(); + $table->unsignedBigInteger('peak_memory_usage')->nullable(); $table->string('status')->default('processing'); // processing, success, error $table->unsignedSmallInteger('http_status')->nullable(); + $table->text('error_reason')->nullable(); $table->string('user_id')->nullable()->index(); $table->string('user_type')->nullable(); $table->string('ip_address', 45)->nullable(); @@ -40,6 +44,9 @@ public function up() $table->timestamps(); $table->foreign('project_id')->references('id')->on('tr_projects')->onDelete('set null'); + $table->index('status'); + $table->index('started_at'); + $table->index(['type', 'started_at']); }); Schema::create('tr_trace_steps', function (Blueprint $table) { @@ -53,10 +60,17 @@ public function up() $table->json('response_payload')->nullable(); $table->json('state_snapshot')->nullable(); - $table->float('duration_ms')->nullable(); + $table->decimal('duration_ms', 12, 2)->nullable(); $table->unsignedBigInteger('memory_usage')->nullable(); // bytes $table->unsignedInteger('db_query_count')->nullable(); - $table->float('db_query_time_ms')->nullable(); + $table->decimal('db_query_time_ms', 12, 2)->nullable(); + $table->json('db_queries')->nullable(); // Detailed SQL tracking + $table->json('cache_calls')->nullable(); + $table->unsignedInteger('cache_hit_count')->default(0); + $table->unsignedInteger('cache_miss_count')->default(0); + $table->json('http_calls')->nullable(); + $table->json('mail_calls')->nullable(); + $table->json('log_calls')->nullable(); $table->string('status')->default('success'); // success, error, checkpoint $table->text('error_reason')->nullable(); @@ -66,7 +80,7 @@ public function up() }); } - public function down() + public function down(): void { Schema::dropIfExists('tr_trace_steps'); Schema::dropIfExists('tr_traces'); diff --git a/database/migrations/2026_04_06_000001_enhance_trace_replay_tables.php b/database/migrations/2026_04_06_000001_enhance_trace_replay_tables.php deleted file mode 100644 index 0557220..0000000 --- a/database/migrations/2026_04_06_000001_enhance_trace_replay_tables.php +++ /dev/null @@ -1,93 +0,0 @@ -isDoctrineAvailable(); - - // tr_traces enhancements - Schema::table('tr_traces', function (Blueprint $table) use ($canChangeColumns) { - // Recommendation 17: Trace parent for W3C context - if (! Schema::hasColumn('tr_traces', 'trace_parent')) { - $table->string('trace_parent')->after('tags')->nullable()->index(); - } - - // Phase 2: Memory tracking - if (! Schema::hasColumn('tr_traces', 'peak_memory_usage')) { - $table->unsignedBigInteger('peak_memory_usage')->after('duration_ms')->nullable(); - } - - // Recommendation 30: Indexes for performance - // We use try-catch or individual checks because some DBs might already have these - try { - $table->index('status'); - $table->index('started_at'); - } catch (Throwable $e) { - // Ignore if index already exists - } - - // Recommendation 29: Change precision of float to decimal - if ($canChangeColumns) { - $table->decimal('duration_ms', 12, 2)->nullable()->change(); - } - }); - - // tr_trace_steps enhancements - Schema::table('tr_trace_steps', function (Blueprint $table) use ($canChangeColumns) { - // Recommendation 13: Detailed SQL tracking - if (! Schema::hasColumn('tr_trace_steps', 'db_queries')) { - $table->json('db_queries')->after('db_query_time_ms')->nullable(); - } - - // Recommendation 6: Cache tracking - if (! Schema::hasColumn('tr_trace_steps', 'cache_calls')) { - $table->json('cache_calls')->after('db_queries')->nullable(); - $table->unsignedInteger('cache_hit_count')->after('cache_calls')->default(0); - $table->unsignedInteger('cache_miss_count')->after('cache_hit_count')->default(0); - } - - // Recommendation 7: HTTP tracking - if (! Schema::hasColumn('tr_trace_steps', 'http_calls')) { - $table->json('http_calls')->after('cache_miss_count')->nullable(); - } - - // Recommendation 14: Mail/Notification tracking - if (! Schema::hasColumn('tr_trace_steps', 'mail_calls')) { - $table->json('mail_calls')->after('http_calls')->nullable(); - } - - // Phase 3: Application logs - if (! Schema::hasColumn('tr_trace_steps', 'log_calls')) { - $table->json('log_calls')->after('mail_calls')->nullable(); - } - - // Recommendation 29: Change precision of float to decimal - if ($canChangeColumns) { - $table->decimal('duration_ms', 12, 2)->nullable()->change(); - $table->decimal('db_query_time_ms', 12, 2)->nullable()->change(); - } - }); - } - - public function down(): void - { - if (DB::getDriverName() !== 'sqlite') { - Schema::table('tr_traces', function (Blueprint $table) { - $table->dropColumn(['trace_parent', 'peak_memory_usage']); - $table->dropIndex(['status']); - $table->dropIndex(['started_at']); - }); - - Schema::table('tr_trace_steps', function (Blueprint $table) { - $table->dropColumn(['db_queries', 'cache_calls', 'cache_hit_count', 'cache_miss_count', 'http_calls', 'mail_calls', 'log_calls']); - }); - } - } -}; diff --git a/docs/index.html b/docs/index.html index 8c23e05..bb086ff 100644 --- a/docs/index.html +++ b/docs/index.html @@ -159,13 +159,13 @@

TraceReplay Documentation

Installation

composer require iazaran/trace-replay
-

Publish config and migrations:

-
php artisan vendor:publish --tag=trace-replay-config
-php artisan vendor:publish --tag=trace-replay-migrations
+

Publish the config:

+
php artisan vendor:publish --tag=trace-replay-config
+

Run migrations:

php artisan migrate
-

Migrations use json columns for full MySQL, MariaDB, PostgreSQL, and SQLite compatibility.

+

Migrations run automatically without publishing. They use json columns for full MySQL, MariaDB, PostgreSQL, and SQLite compatibility.

-

Publishing Views (Recommended)

+

Publishing Views

TraceReplay ships with a polished, dark-themed dashboard featuring a waterfall timeline, syntax-highlighted JSON inspector, and live stats — all styled and ready to use out of the box. Publishing the views lets you customise the layout, colours, or add your own branding:

php artisan vendor:publish --tag=trace-replay-views

This copies the Blade templates to resources/views/vendor/trace-replay/ where you can edit them freely. The package will automatically use your published versions instead of its built-in views.

@@ -352,7 +352,27 @@

AI Debugging

  • Request/response payloads (sensitive fields masked)
  • Step-by-step state snapshots
  • -

    Three AI drivers are supported out of the box: OpenAI (default), Anthropic Claude, and Ollama (local models). Configure via TRACE_REPLAY_AI_DRIVER and TRACE_REPLAY_AI_KEY, then click "Ask AI" to get an answer directly in the dashboard.

    + +

    No API Key Required

    +

    The AI prompt feature works without any API key. Simply copy the generated prompt and paste it into ChatGPT, Claude, or any other AI assistant of your choice.

    + +

    Optional: Direct AI Integration

    +

    For a seamless experience, configure an AI driver to get answers directly in the dashboard. Three drivers are supported:

    +
    # OpenAI (default)
    +TRACE_REPLAY_AI_DRIVER=openai
    +TRACE_REPLAY_AI_KEY=sk-your-openai-key
    +TRACE_REPLAY_AI_MODEL=gpt-4o
    +
    +# Or Anthropic Claude
    +TRACE_REPLAY_AI_DRIVER=anthropic
    +TRACE_REPLAY_AI_KEY=sk-ant-your-key
    +TRACE_REPLAY_AI_MODEL=claude-3-5-sonnet-latest
    +
    +# Or Ollama (local, no API key needed)
    +TRACE_REPLAY_AI_DRIVER=ollama
    +TRACE_REPLAY_AI_MODEL=llama3
    +TRACE_REPLAY_AI_BASE_URL=http://localhost:11434/api/generate
    +

    With a key configured, clicking "Ask AI" sends the prompt to your chosen provider and displays the response directly in the dashboard.

    MCP / AI-Agent JSON-RPC API

    Autonomous agents can query TraceReplay over JSON-RPC 2.0 at POST /api/trace-replay/mcp.

    @@ -386,7 +406,7 @@

    Data Retention

    Testing

    ./vendor/bin/pest
    -

    The test suite (101 tests, 200 assertions) covers the full engine lifecycle, model scopes & accessors, payload masking (PII, case-insensitive), AI prompt & notification services, dashboard UI, MCP JSON-RPC API, middleware (trace & auth), replay engine, log call tracking, TraceReplayFake assertions, Artisan commands (prune & export with validation), and Blade components — all using an in-memory SQLite database.

    +

    The test suite (104 tests, 208 assertions) covers the full engine lifecycle, model scopes & accessors, payload masking (PII, case-insensitive), AI prompt & notification services, dashboard UI with date range filters, MCP JSON-RPC API, middleware (trace & auth), replay engine, log call tracking, TraceReplayFake assertions, Artisan commands (prune & export with validation), and Blade components — all using an in-memory SQLite database.


    TraceReplay © 2026 — MIT License

    diff --git a/resources/views/index.blade.php b/resources/views/index.blade.php index e562c27..002082f 100644 --- a/resources/views/index.blade.php +++ b/resources/views/index.blade.php @@ -1,49 +1,105 @@ @extends('trace-replay::layout') -@section('title', 'Traces — TraceReplay') +@section('title', 'Dashboard — TraceReplay') @section('content')
    - -
    + +
    -

    Traces

    -

    {{ $traces->total() }} total

    +

    + + + + TraceReplay +

    +

    Real-time application tracing & debugging

    -
    - -
    - - - - - - - @if(request()->hasAny(['search','status'])) - - ✕ Clear - - @endif -
    + + +
    +

    Unique Features

    +
    +
    + + HTTP Replay +
    +
    + + JSON Diff +
    +
    + + AI Fix Prompts +
    +
    + + PII Masking +
    +
    + + Livewire Tracing +
    +
    + + Multi-Tenant +
    +
    + + W3C Traceparent +
    +
    + + Octane Ready +
    +
    + +
    +
    - -
    - @foreach([['Total Traces','stat-total','blue'],['Failed','stat-failed','red'],['Avg Duration','stat-avg','yellow'],['Today','stat-today','green']] as [$label,$id,$c]) -
    -
    -
    {{ $label }}
    + +
    +
    +
    +
    + +
    +
    +
    {{ number_format($stats['total']) }}
    +
    Total Traces
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    {{ number_format($stats['success']) }}
    +
    Successful
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    {{ number_format($stats['errors']) }}
    +
    Errors ({{ $stats['error_rate'] }}%)
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    {{ number_format($stats['avg_duration'], 0) }}ms
    +
    Avg Duration
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    {{ number_format($stats['last_hour']) }}
    +
    Last Hour
    +
    +
    +
    +
    + + +
    + +
    +

    Last 7 Days Activity

    +
    +
    + + +
    +
    +
    + Success + Errors +
    +
    + + +
    +

    Operations (7 days)

    + @php + $ops = $stats['operations'] ?? []; + $opsMeta = [ + 'db_queries' => ['label' => 'DB Queries', 'icon' => 'database', 'color' => 'blue'], + 'cache_calls' => ['label' => 'Cache Calls', 'icon' => 'hard-drive', 'color' => 'green'], + 'http_calls' => ['label' => 'HTTP Calls', 'icon' => 'globe', 'color' => 'purple'], + 'mail_calls' => ['label' => 'Mail Sent', 'icon' => 'mail', 'color' => 'orange'], + ]; + $maxOps = max(1, max($ops['db_queries'] ?? 0, $ops['cache_calls'] ?? 0, $ops['http_calls'] ?? 0, $ops['mail_calls'] ?? 0)); + @endphp +
    + @foreach($opsMeta as $key => $meta) + @php + $count = $ops[$key] ?? 0; + $pct = $maxOps > 0 ? round(($count / $maxOps) * 100) : 0; + @endphp +
    +
    + + + {{ $meta['label'] }} + + {{ number_format($count) }} +
    +
    +
    +
    +
    + @endforeach + @if(array_sum($ops) === 0) +
    No operations recorded yet
    + @endif +
    - @endforeach
    + +
    +

    Recent Traces

    + +
    + + + + + + + + + @if(request()->hasAny(['search','status','type','date_range'])) + + ✕ Clear + + @endif +
    +
    + +
    - - - - - - + + + + + + @forelse ($traces as $trace) - - + + + + + - - - - - @empty diff --git a/resources/views/layout.blade.php b/resources/views/layout.blade.php index 8e79a7e..1eb3f5f 100644 --- a/resources/views/layout.blade.php +++ b/resources/views/layout.blade.php @@ -69,23 +69,21 @@ - -
    -
    -
    - -
    -
    -

    TraceReplay

    -

    Laravel Debugging Engine

    + +
    + +
    +
    + TraceReplay +
    + -
    @@ -94,8 +92,39 @@ diff --git a/resources/views/show.blade.php b/resources/views/show.blade.php index 72510ce..9e89d25 100644 --- a/resources/views/show.blade.php +++ b/resources/views/show.blade.php @@ -2,15 +2,17 @@ @section('title', ($trace->name ?? 'Trace') . ' — TraceReplay') @section('content') -
    +
    -
    -

    {{ $trace->name }}

    - {{ substr($trace->id, 0, 8) }} +
    +
    +

    {{ $trace->name }}

    +
    + {{ substr($trace->id, 0, 8) }}
    @@ -28,48 +30,103 @@
    + + @if($trace->tags) +
    + @foreach((array)$trace->tags as $tag) + {{ $tag }} + @endforeach +
    + @endif + +
    +
    + HTTP Status +
    {{ $trace->http_status ?? '—' }}
    +
    +
    + Started At +
    {{ $trace->started_at?->format('M j, Y · H:i') ?? '—' }}
    +
    +
    + IP Address +
    {{ $trace->ip_address ?? '—' }}
    +
    +
    + User Agent +
    {{ $trace->user_agent ? \Illuminate\Support\Str::limit($trace->user_agent, 60) : '—' }}
    +
    +
    + + {{-- Error Details Panel --}} + @if($trace->status === 'error' && $trace->error_reason) +
    +
    + + {{ $trace->error_reason['class'] ?? 'Exception' }} + @if(isset($trace->error_reason['code']) && $trace->error_reason['code']) + Code: {{ $trace->error_reason['code'] }} + @endif +
    +
    +
    {{ $trace->error_reason['message'] ?? 'Unknown error' }}
    + @if(isset($trace->error_reason['file'])) +
    + File: {{ $trace->error_reason['file'] }}
    + Line: {{ $trace->error_reason['line'] ?? '?' }} +
    + @endif + @if(isset($trace->error_reason['trace']) && is_array($trace->error_reason['trace'])) + +
    +
    @foreach($trace->error_reason['trace'] as $line){{ $line }}
    +@endforeach
    +
    + @endif +
    +
    + @endif
    -
    +

    Execution Flow

    - -
    - + +
    @foreach ($trace->steps as $index => $step) -
    - +
    + + + @if(!$loop->last) +
    + @endif + -
    +
    + {{ $step->step_order }}
    -
    -
    - {{ $step->label }} - {{ $step->duration_ms }}ms -
    - @if($step->status === 'error') -

    Error occurred here

    - @endif +
    + {{ $step->label }} + {{ $step->duration_ms }}ms
    @endforeach -
    - +
    @@ -77,22 +134,58 @@
    - @if($trace->status === 'error') - @endif -
    + +
    + +
    + @php $totalMs = max($trace->duration_ms ?? 1, 1); @endphp +
    + @foreach($trace->steps as $step) + @php + $pct = min(max(($step->duration_ms / $totalMs) * 100, 0.5), 100); + $offset = 0; + $color = $step->status === 'error' ? '#f85149' + : ($step->duration_ms < 200 ? '#3fb950' + : ($step->duration_ms < 1000 ? '#d29922' : '#f0883e')); + @endphp +
    + {{ $step->step_order }} + {{ $step->label }} +
    +
    +
    + {{ number_format($step->duration_ms, 0) }} ms +
    + @endforeach +
    +
    + 0 ms{{ number_format($totalMs/2, 0) }} ms{{ number_format($totalMs, 0) }} ms +
    +
    +
    +
    - +
    @@ -100,8 +193,47 @@ class="px-4 py-1.5 text-sm font-medium rounded-lg bg-white/10 text-white border
    + +
    + Jump to: + + + + + + +
    + -
    +
    @@ -115,11 +247,136 @@ class="px-4 py-1.5 text-sm font-medium rounded-lg bg-white/10 text-white border
    -
    + +

    Request Payload

    -
    + + +
    +

    + + Database Queries + +

    +
    + +
    +
    + + +
    +

    + + Cache Operations + + +

    +
    + +
    +
    + + +
    +

    + + HTTP Calls + +

    +
    + +
    +
    + + +
    +

    + + Emails Sent + +

    +
    + +
    +
    + + +
    +

    + + Log Entries + +

    +
    + +
    +
    + + +

    State Snapshot

    @@ -141,55 +398,26 @@ class="px-4 py-1.5 text-sm font-medium rounded-lg bg-white/10 text-white border
    -
    -
    -
    +
    +
    +

    - AI Debugging Prompt + + AI Debugging Prompt

    - -
    -
    -

    Copy this prompt into Cursor, ChatGPT, or your preferred IDE AI assistant.

    - -
    
                     
    -
    -
    - - -
    - -
    - @php $totalMs = max($trace->duration_ms ?? 1, 1); @endphp -
    - @foreach($trace->steps as $step) - @php - $pct = min(max(($step->duration_ms / $totalMs) * 100, 0.5), 100); - $offset = 0; // Could compute start offset from step start time if tracked - $color = $step->status === 'error' ? '#f85149' - : ($step->duration_ms < 200 ? '#3fb950' - : ($step->duration_ms < 1000 ? '#d29922' : '#f0883e')); - @endphp -
    - {{ $step->step_order }} - {{ $step->label }} -
    -
    -
    - {{ number_format($step->duration_ms, 0) }} ms +
    +
    +

    Copy this prompt into Cursor, ChatGPT, or your preferred IDE AI assistant.

    +
    - @endforeach -
    -
    - 0 ms{{ number_format($totalMs/2, 0) }} ms{{ number_format($totalMs, 0) }} ms +
    
                     
    @@ -283,14 +511,22 @@ class="font-mono font-bold"> this.aiPromptContent = 'Generating expert debugging prompt…'; try { + const token = document.querySelector('meta[name="csrf-token"]').content; const response = await fetch(window.aiPromptUrl, { method: 'POST', headers: { - 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, + 'X-CSRF-TOKEN': token, + 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } }); + if (!response.ok) { + throw new Error('HTTP '+response.status); + } const data = await response.json(); + if (data.status !== 'success') { + throw new Error(data.message || 'Unable to generate prompt'); + } this.aiPromptContent = data.data.ai_response || data.data.prompt; } catch (e) { this.aiPromptContent = 'Error generating prompt: ' + e.message; @@ -302,15 +538,20 @@ class="font-mono font-bold"> this.replayData = { original: 'Running replay...', replay: 'Waiting...' }; try { + const token = document.querySelector('meta[name="csrf-token"]').content; const response = await fetch(window.replayUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, + 'X-CSRF-TOKEN': token, + 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }, body: JSON.stringify({}) }); + if (!response.ok) { + throw new Error('HTTP '+response.status); + } const result = await response.json(); if (result.status === 'success') { this.replayData = result.data; diff --git a/src/Facades/TraceReplay.php b/src/Facades/TraceReplay.php index 5dd6b55..1019d7b 100644 --- a/src/Facades/TraceReplay.php +++ b/src/Facades/TraceReplay.php @@ -7,12 +7,13 @@ use TraceReplay\TraceReplayManager; /** - * @method static \TraceReplay\Models\Trace|null start(string $name, array $tags = [], bool $forceSample = false) + * @method static \TraceReplay\Models\Trace|null start(string $name, array $tags = [], string $type = 'http', bool $forceSample = false) * @method static mixed step(string $label, callable $callback, array $extra = []) * @method static mixed measure(string $label, callable $callback, array $extra = []) * @method static void checkpoint(string $label, array $state = []) * @method static \TraceReplay\TraceReplayManager context(array $data) * @method static void captureResponseOnLastStep(array $responsePayload, int $httpStatus = 200) + * @method static void captureException(\Throwable $exception) * @method static void end(string $status = 'success') * @method static \TraceReplay\Models\Trace|null getCurrentTrace() * @method static void setTraceParent(?string $traceParent) diff --git a/src/Http/Controllers/DashboardController.php b/src/Http/Controllers/DashboardController.php index cb1a6a5..85b9aab 100644 --- a/src/Http/Controllers/DashboardController.php +++ b/src/Http/Controllers/DashboardController.php @@ -6,6 +6,7 @@ use Illuminate\Http\Request; use Illuminate\Routing\Controller; use TraceReplay\Models\Trace; +use TraceReplay\Models\TraceStep; use TraceReplay\Services\AiPromptService; use TraceReplay\Services\ReplayService; @@ -20,13 +21,112 @@ public function index(Request $request) $query->where('status', $status); } + $type = $request->query('type'); + if ($type && \in_array($type, ['http', 'job', 'command', 'schedule'], true)) { + $query->where('type', $type); + } + + // Date range filter + $dateRange = $request->query('date_range'); + if ($dateRange) { + $query->where(function ($q) use ($dateRange) { + match ($dateRange) { + 'today' => $q->whereDate('started_at', now()->toDateString()), + 'yesterday' => $q->whereDate('started_at', now()->subDay()->toDateString()), + '7days' => $q->where('started_at', '>=', now()->subDays(7)), + '30days' => $q->where('started_at', '>=', now()->subDays(30)), + 'hour' => $q->where('started_at', '>=', now()->subHour()), + default => null, + }; + }); + } + if ($search = $request->query('search')) { $query->search($search); } $traces = $query->paginate(25)->withQueryString(); - return view('trace-replay::index', compact('traces')); + // Dashboard stats + $stats = $this->getDashboardStats(); + + return view('trace-replay::index', compact('traces', 'stats')); + } + + protected function getDashboardStats(): array + { + $today = now()->startOfDay(); + $lastHour = now()->subHour(); + + // General stats + $totals = Trace::selectRaw(" + COUNT(*) as total, + SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success, + SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errors, + SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing, + AVG(duration_ms) as avg_duration + ")->first(); + + // By type + $byType = Trace::selectRaw('type, COUNT(*) as count') + ->whereDate('started_at', '>=', now()->subDays(7)) + ->groupBy('type') + ->pluck('count', 'type') + ->toArray(); + + // Last 7 days trend + $trend = Trace::selectRaw(" + DATE(started_at) as date, + COUNT(*) as total, + SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errors + ") + ->whereDate('started_at', '>=', now()->subDays(7)) + ->groupBy('date') + ->orderBy('date') + ->get() + ->map(fn ($row) => [ + 'date' => $row->date, + 'total' => (int) $row->total, + 'errors' => (int) $row->errors, + ]) + ->values() + ->toArray(); + + // Last hour count + $lastHourCount = Trace::where('started_at', '>=', $lastHour)->count(); + $todayCount = Trace::where('started_at', '>=', $today)->count(); + + // Operations breakdown (last 7 days) + $operations = TraceStep::join('tr_traces', 'tr_trace_steps.trace_id', '=', 'tr_traces.id') + ->whereDate('tr_traces.started_at', '>=', now()->subDays(7)) + ->selectRaw(" + SUM(COALESCE(db_query_count, 0)) as db_queries, + SUM(COALESCE(cache_hit_count, 0) + COALESCE(cache_miss_count, 0)) as cache_calls, + SUM(CASE WHEN http_calls IS NOT NULL AND http_calls != '[]' AND http_calls != 'null' THEN 1 ELSE 0 END) as http_calls, + SUM(CASE WHEN mail_calls IS NOT NULL AND mail_calls != '[]' AND mail_calls != 'null' THEN 1 ELSE 0 END) as mail_calls + ") + ->first(); + + $operationsData = [ + 'db_queries' => (int) ($operations->db_queries ?? 0), + 'cache_calls' => (int) ($operations->cache_calls ?? 0), + 'http_calls' => (int) ($operations->http_calls ?? 0), + 'mail_calls' => (int) ($operations->mail_calls ?? 0), + ]; + + return [ + 'total' => (int) ($totals->total ?? 0), + 'success' => (int) ($totals->success ?? 0), + 'errors' => (int) ($totals->errors ?? 0), + 'processing' => (int) ($totals->processing ?? 0), + 'avg_duration' => round($totals->avg_duration ?? 0, 2), + 'error_rate' => $totals->total > 0 ? round(($totals->errors / $totals->total) * 100, 1) : 0, + 'by_type' => $byType, + 'operations' => $operationsData, + 'trend' => $trend, + 'last_hour' => $lastHourCount, + 'today' => $todayCount, + ]; } public function show(string $id) diff --git a/src/Http/Middleware/TraceMiddleware.php b/src/Http/Middleware/TraceMiddleware.php index af334ed..e0b8719 100644 --- a/src/Http/Middleware/TraceMiddleware.php +++ b/src/Http/Middleware/TraceMiddleware.php @@ -5,6 +5,7 @@ use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Throwable; use TraceReplay\Facades\TraceReplay; use TraceReplay\Services\PayloadMasker; @@ -17,7 +18,7 @@ public function handle(Request $request, Closure $next): SymfonyResponse } // Recommendation 27: Skip trace-replay dashboard routes reliably by name - if ($request->route() && str_starts_with($request->route()->getName() ?? '', 'trace-replay.')) { + if ($this->shouldSkipInstrumentation($request)) { return $next($request); } @@ -32,7 +33,7 @@ public function handle(Request $request, Closure $next): SymfonyResponse // Request::path() returns '/' for the root URI, or 'foo/bar' (no leading slash) for others. $path = $request->path(); $uri = $path === '/' ? '/' : '/'.$path; - $trace = TraceReplay::start('HTTP '.strtoupper($request->method()).' '.$uri); + $trace = TraceReplay::start('HTTP '.strtoupper($request->method()).' '.$uri, [], 'http'); if (! $trace) { return $next($request); @@ -42,22 +43,30 @@ public function handle(Request $request, Closure $next): SymfonyResponse $requestPayload = [ 'method' => $request->method(), 'uri' => $uri, + 'full_url' => $request->fullUrl(), + 'host' => $request->getSchemeAndHttpHost(), 'headers' => $masker->mask($request->headers->all()), 'body' => $reqBody, 'query' => $masker->mask($request->query->all()), ]; - /** @var SymfonyResponse $response */ - $response = TraceReplay::step('HTTP Request', fn () => $next($request), [ - 'request_payload' => $requestPayload, - ]); - - return $response; + try { + /** @var SymfonyResponse $response */ + $response = TraceReplay::step('HTTP Request', fn () => $next($request), [ + 'request_payload' => $requestPayload, + ]); + + return $response; + } catch (Throwable $e) { + // Capture exception at trace level for proper error reporting + TraceReplay::captureException($e); + throw $e; + } } public function terminate(Request $request, SymfonyResponse $response): void { - if (! config('trace-replay.enabled')) { + if (! config('trace-replay.enabled') || $this->shouldSkipInstrumentation($request)) { return; } @@ -88,4 +97,29 @@ public function terminate(Request $request, SymfonyResponse $response): void TraceReplay::captureResponseOnLastStep($responsePayload, $httpStatus); TraceReplay::end($status); } + + protected function shouldSkipInstrumentation(Request $request): bool + { + if (! config('trace-replay.enabled')) { + return true; + } + + if ($request->headers->has('X-TraceReplay-Skip')) { + return true; + } + + $routeName = $request->route()?->getName(); + if ($routeName && str_starts_with($routeName, 'trace-replay.')) { + return true; + } + + $path = ltrim($request->path(), '/'); + foreach (['trace-replay', 'api/trace-replay'] as $prefix) { + if (str_starts_with($path, $prefix)) { + return true; + } + } + + return false; + } } diff --git a/src/Listeners/CommandTraceListener.php b/src/Listeners/CommandTraceListener.php index ac6eb05..f29d342 100644 --- a/src/Listeners/CommandTraceListener.php +++ b/src/Listeners/CommandTraceListener.php @@ -17,7 +17,7 @@ public function onCommandStarting(CommandStarting $event): void TraceReplay::start("Artisan: {$event->command}", [ 'command' => $event->command, 'arguments' => (string) $event->input, - ]); + ], 'command'); TraceReplay::checkpoint('Command Started'); } diff --git a/src/Listeners/JobTraceListener.php b/src/Listeners/JobTraceListener.php index 3ac3f50..a43013f 100644 --- a/src/Listeners/JobTraceListener.php +++ b/src/Listeners/JobTraceListener.php @@ -17,7 +17,7 @@ public function onJobProcessing(JobProcessing $event): void 'queue' => $event->job->getQueue(), 'connection' => $event->connectionName, 'job_id' => $event->job->getJobId(), - ]); + ], 'job'); TraceReplay::checkpoint('Job Started', [ 'payload' => $event->job->payload(), diff --git a/src/Models/Trace.php b/src/Models/Trace.php index ba94170..a21f460 100644 --- a/src/Models/Trace.php +++ b/src/Models/Trace.php @@ -22,12 +22,14 @@ protected static function newFactory(): TraceFactory protected $fillable = [ 'project_id', 'name', + 'type', 'tags', 'trace_parent', 'duration_ms', 'peak_memory_usage', 'status', 'http_status', + 'error_reason', 'user_id', 'user_type', 'ip_address', @@ -36,12 +38,21 @@ protected static function newFactory(): TraceFactory 'completed_at', ]; + public const TYPE_HTTP = 'http'; + + public const TYPE_JOB = 'job'; + + public const TYPE_COMMAND = 'command'; + + public const TYPE_SCHEDULE = 'schedule'; + protected $casts = [ 'tags' => 'array', 'started_at' => 'datetime', 'completed_at' => 'datetime', 'duration_ms' => 'decimal:2', 'http_status' => 'integer', + 'error_reason' => 'array', ]; public function project() diff --git a/src/Services/AiPromptService.php b/src/Services/AiPromptService.php index 5b92897..0354103 100644 --- a/src/Services/AiPromptService.php +++ b/src/Services/AiPromptService.php @@ -20,8 +20,10 @@ public function __construct(AiDriverInterface $driver) public function generateFixPrompt(Trace $trace): string { $errorStep = $trace->error_step; + $hasHttpError = $trace->http_status && $trace->http_status >= 400; - if (! $errorStep) { + // If no error step but the trace has an HTTP error status, we still generate a debug prompt + if (! $errorStep && ! $hasHttpError) { return 'This trace completed successfully with no errors recorded. Nothing to debug.'; } @@ -35,7 +37,12 @@ public function generateFixPrompt(Trace $trace): string $prompt .= "- **Trace Name:** {$trace->name}\n"; $prompt .= '- **Total Duration:** '.number_format($trace->duration_ms ?? 0, 2)." ms\n"; $prompt .= "- **Step Count:** {$steps->count()}\n"; - $prompt .= "- **Failed At:** Step #{$errorStep->step_order} — `{$errorStep->label}`\n"; + + if ($errorStep) { + $prompt .= "- **Failed At:** Step #{$errorStep->step_order} — `{$errorStep->label}`\n"; + } elseif ($hasHttpError) { + $prompt .= "- **HTTP Error:** {$trace->http_status}\n"; + } if ($trace->user_id) { $prompt .= "- **User ID:** {$trace->user_id}\n"; @@ -45,6 +52,24 @@ public function generateFixPrompt(Trace $trace): string } $prompt .= "\n"; + // Add HTTP error context if applicable + if ($hasHttpError) { + $prompt .= "## HTTP Error\n\n"; + $prompt .= "The request returned HTTP status **{$trace->http_status}**"; + if ($trace->http_status === 404) { + $prompt .= ' (Not Found)'; + } elseif ($trace->http_status === 500) { + $prompt .= ' (Internal Server Error)'; + } elseif ($trace->http_status === 403) { + $prompt .= ' (Forbidden)'; + } elseif ($trace->http_status === 401) { + $prompt .= ' (Unauthorized)'; + } elseif ($trace->http_status === 422) { + $prompt .= ' (Unprocessable Entity)'; + } + $prompt .= ".\n\n"; + } + $prompt .= "## Execution Timeline\n\n"; foreach ($steps as $step) { $icon = match ($step->status) { @@ -67,7 +92,7 @@ public function generateFixPrompt(Trace $trace): string $prompt .= ' - **State:** `'.substr(json_encode($step->state_snapshot), 0, 500)."`\n"; } - if ($step->id === $errorStep->id) { + if ($errorStep && $step->id === $errorStep->id) { $prompt .= "\n### 🚨 Failure Point\n\n"; $errorStr = is_array($step->error_reason) ? json_encode($step->error_reason, JSON_PRETTY_PRINT) : ($step->error_reason ?? 'No error details.'); $prompt .= "```json\n{$errorStr}\n```\n\n"; @@ -75,7 +100,11 @@ public function generateFixPrompt(Trace $trace): string } $prompt .= "## Your Task\n\n"; - $prompt .= "1. **Root Cause:** Analyse the timeline and identify why Step #{$errorStep->step_order} (`{$errorStep->label}`) failed.\n"; + if ($errorStep) { + $prompt .= "1. **Root Cause:** Analyse the timeline and identify why Step #{$errorStep->step_order} (`{$errorStep->label}`) failed.\n"; + } else { + $prompt .= "1. **Root Cause:** Analyse the timeline and identify why this request returned HTTP {$trace->http_status}.\n"; + } $prompt .= "2. **Hypothesis:** State your best hypothesis about the underlying issue.\n"; $prompt .= "3. **Fix:** Provide a concrete, minimal code fix (PHP/Laravel) or configuration change.\n"; $prompt .= "4. **Prevention:** Suggest how to prevent this class of error in the future (tests, validation, guards).\n\n"; diff --git a/src/Services/ReplayService.php b/src/Services/ReplayService.php index 85c6bd7..db20b74 100644 --- a/src/Services/ReplayService.php +++ b/src/Services/ReplayService.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Http; use TraceReplay\Models\Trace; +use TraceReplay\Models\TraceStep; class ReplayService { @@ -11,44 +12,44 @@ public function __construct(protected PayloadMasker $masker) {} public function replay(Trace $trace, ?string $overrideUrl = null): array { - // Prefer the dedicated 'HTTP Request' step; fall back to the first step with a payload - $initialStep = $trace->steps() - ->where('label', 'HTTP Request') - ->whereNotNull('request_payload') - ->first() - ?? $trace->steps()->whereNotNull('request_payload')->first(); - - if (! $initialStep || empty($initialStep->request_payload)) { - throw new \Exception('Cannot replay trace: No request payload found on any step.'); - } + $initialStep = $this->resolveInitialHttpStep($trace); $payload = $initialStep->request_payload; $method = strtoupper($payload['method'] ?? 'GET'); - // Safety check for mutating methods (Recommendation 12) $mutatingMethods = ['POST', 'PUT', 'PATCH', 'DELETE']; - if (in_array($method, $mutatingMethods) && ! config('trace-replay.replay.allow_mutating_methods', false)) { + if (in_array($method, $mutatingMethods, true) && ! config('trace-replay.replay.allow_mutating_methods', false)) { throw new \Exception("Replaying mutating methods ({$method}) is disabled for safety. Enable 'replay.allow_mutating_methods' in config to override."); } + $uri = $payload['uri'] ?? '/'; $headers = $payload['headers'] ?? []; $body = $payload['body'] ?? []; $query = $payload['query'] ?? []; + $baseUrl = $this->determineBaseUrl($payload, $overrideUrl); - // Remove host headers so they don't interfere with the target - unset($headers['host'], $headers['Host']); + if (! $baseUrl) { + throw new \Exception('Cannot determine target host for replay. Set TRACE_REPLAY_REPLAY_URL or pass an override_url.'); + } - $baseUrl = $overrideUrl ?? config('trace-replay.replay.default_base_url'); - $targetUrl = rtrim($baseUrl, '/').'/'.ltrim($uri, '/'); + unset($headers['host'], $headers['Host'], $headers['cookie'], $headers['Cookie']); + + $targetUrl = $this->buildTargetUrl($uri, $baseUrl, $query); - // Symfony normalises all header names to lowercase, so 'Content-Type' never exists - // in the stored headers array — only 'content-type' does. $isJson = str_contains($headers['content-type'][0] ?? '', 'json'); - $response = Http::withHeaders($headers) + $options = []; + if (! in_array($method, ['GET', 'HEAD', 'OPTIONS'], true) && ! empty($body)) { + $options = $isJson ? ['json' => $body] : ['form_params' => $body]; + } + + $normalizedHeaders = $this->normalizeHeaders($headers); + $normalizedHeaders['X-TraceReplay-Skip'] = '1'; + $normalizedHeaders['X-TraceReplay-Origin-Trace'] = $trace->id; + + $response = Http::withHeaders($normalizedHeaders) ->timeout((int) config('trace-replay.replay.timeout', 30)) - ->withQueryParameters($query) - ->send($method, $targetUrl, $isJson ? ['json' => $body] : ['form_params' => $body]); + ->send($method, $targetUrl, $options); $replayBody = $response->json() ?? $response->body(); @@ -72,6 +73,75 @@ public function replay(Trace $trace, ?string $overrideUrl = null): array ]; } + protected function resolveInitialHttpStep(Trace $trace): TraceStep + { + $initialStep = $trace->steps() + ->where('label', 'HTTP Request') + ->whereNotNull('request_payload') + ->first() + ?? $trace->steps()->whereNotNull('request_payload')->first(); + + if (! $initialStep || empty($initialStep->request_payload)) { + throw new \Exception('Cannot replay trace: No request payload found on any step.'); + } + + return $initialStep; + } + + protected function determineBaseUrl(array $payload, ?string $overrideUrl): ?string + { + if ($overrideUrl) { + return $overrideUrl; + } + + // First, try to use the host from the recorded payload (preserves original port) + if (! empty($payload['host'])) { + return $payload['host']; + } + + // Fallback: extract from full_url if host wasn't recorded + if (! empty($payload['full_url'])) { + $parts = parse_url($payload['full_url']); + if ($parts && isset($parts['scheme'], $parts['host'])) { + $port = isset($parts['port']) ? ':'.$parts['port'] : ''; + + return "{$parts['scheme']}://{$parts['host']}{$port}"; + } + } + + // Last resort: use configured default base URL + if ($configured = config('trace-replay.replay.default_base_url')) { + return $configured; + } + + return null; + } + + protected function buildTargetUrl(string $uri, string $baseUrl, array $query): string + { + $target = filter_var($uri, FILTER_VALIDATE_URL) + ? $uri + : rtrim($baseUrl, '/').'/'.ltrim($uri, '/'); + + if (! empty($query)) { + $separator = str_contains($target, '?') ? '&' : '?'; + $target .= $separator.http_build_query($query); + } + + return $target; + } + + protected function normalizeHeaders(array $headers): array + { + $normalized = []; + + foreach ($headers as $name => $value) { + $normalized[$name] = is_array($value) ? implode(', ', $value) : $value; + } + + return $normalized; + } + protected function generateDiff(array $original, array $replay): array { // Simple manual structural diffing diff --git a/src/TraceReplayManager.php b/src/TraceReplayManager.php index affc900..e6b121d 100644 --- a/src/TraceReplayManager.php +++ b/src/TraceReplayManager.php @@ -64,7 +64,7 @@ public function __construct($app) // ── Lifecycle ──────────────────────────────────────────────────────────── - public function start(string $name, array $tags = [], bool $forceSample = false): ?Trace + public function start(string $name, array $tags = [], string $type = 'http', bool $forceSample = false): ?Trace { if (! config('trace-replay.enabled', true)) { return null; @@ -80,6 +80,7 @@ public function start(string $name, array $tags = [], bool $forceSample = false) $this->currentTrace->update(array_filter([ 'name' => $name, 'tags' => ! empty($tags) ? $tags : null, + 'type' => $type, ])); } catch (Throwable $e) { $this->handleInternalError($e); @@ -112,6 +113,7 @@ public function start(string $name, array $tags = [], bool $forceSample = false) $this->currentTrace = Trace::create([ 'project_id' => $this->projectId ?? $this->determineProjectId(), 'name' => $name, + 'type' => $type, 'tags' => $tags, 'status' => 'processing', 'user_id' => $user?->getAuthIdentifier(), @@ -313,7 +315,44 @@ public function captureResponseOnLastStep(array $responsePayload, int $httpStatu } } - public function end(string $status = 'success'): void + /** + * Capture an exception at the trace level (called by middleware when exception is caught). + * Also immediately sets status to 'error' in case terminate() doesn't run. + */ + public function captureException(Throwable $exception): void + { + if (! $this->currentTrace) { + return; + } + + // Flush any buffered steps first so they're not lost + $this->flushStepBuffer(); + + try { + $durationMs = $this->startedAtMicrotime + ? round((microtime(true) - $this->startedAtMicrotime) * 1000, 2) + : null; + + $this->currentTrace->update([ + 'status' => 'error', + 'error_reason' => [ + 'class' => \get_class($exception), + 'message' => $exception->getMessage(), + 'code' => $exception->getCode(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => collect(explode("\n", $exception->getTraceAsString()))->take(30)->values()->all(), + ], + 'duration_ms' => $durationMs, + 'completed_at' => now(), + 'peak_memory_usage' => memory_get_peak_usage(true), + ]); + } catch (Throwable $e) { + $this->handleInternalError($e); + } + } + + public function end(string $status = 'success', ?Throwable $exception = null): void { if (! $this->currentTrace) { return; @@ -335,12 +374,26 @@ public function end(string $status = 'success'): void ? round((microtime(true) - $this->startedAtMicrotime) * 1000, 2) : null; - $this->currentTrace->update([ + $updateData = [ 'status' => $status, 'completed_at' => now(), 'duration_ms' => $durationMs, 'peak_memory_usage' => memory_get_peak_usage(true), - ]); + ]; + + // Capture exception details at trace level + if ($exception) { + $updateData['error_reason'] = [ + 'class' => get_class($exception), + 'message' => $exception->getMessage(), + 'code' => $exception->getCode(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => collect(explode("\n", $exception->getTraceAsString()))->take(30)->values()->all(), + ]; + } + + $this->currentTrace->update($updateData); // Fire notification if configured and trace failed if ($status === 'error' && config('trace-replay.notifications.on_failure', false)) { diff --git a/src/TraceReplayServiceProvider.php b/src/TraceReplayServiceProvider.php index 5c4be26..8a0b6e7 100644 --- a/src/TraceReplayServiceProvider.php +++ b/src/TraceReplayServiceProvider.php @@ -8,6 +8,7 @@ use Illuminate\Cache\Events\KeyWritten; use Illuminate\Console\Events\CommandFinished; use Illuminate\Console\Events\CommandStarting; +use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Http\Client\Events\RequestSending; use Illuminate\Http\Client\Events\ResponseReceived; use Illuminate\Log\Events\MessageLogged; @@ -132,5 +133,39 @@ public function boot(): void $this->app->make('trace-replay')->recordEvent($event); } }); + + // Hook into Laravel's exception handler to capture exceptions for active traces + $this->registerExceptionCapture(); + } + + /** + * Register a reportable callback to capture exceptions in active traces. + */ + protected function registerExceptionCapture(): void + { + if (! $this->app->bound(ExceptionHandler::class)) { + return; + } + + try { + $handler = $this->app->make(ExceptionHandler::class); + + // Laravel 8+ uses reportable() method + if (method_exists($handler, 'reportable')) { + $handler->reportable(function (\Throwable $e) { + if ($this->app->bound('trace-replay')) { + $manager = $this->app->make('trace-replay'); + if ($manager->getCurrentTrace()) { + $manager->captureException($e); + } + } + + // Return false to allow other reporters to run + return false; + }); + } + } catch (\Throwable $e) { + // Silently fail if exception handler is not compatible + } } } diff --git a/tests/Feature/TraceReplayTest.php b/tests/Feature/TraceReplayTest.php index d4912dc..34af7e3 100644 --- a/tests/Feature/TraceReplayTest.php +++ b/tests/Feature/TraceReplayTest.php @@ -1,8 +1,10 @@ assertSee('Login Flow'); }); +it('dashboard index filters by date_range', function () { + Trace::factory()->create(['name' => 'Today Trace', 'started_at' => now()]); + Trace::factory()->create(['name' => 'Old Trace', 'started_at' => now()->subDays(10)]); + + $response = $this->get('/trace-replay?date_range=today'); + + $response->assertOk(); + $response->assertSee('Today Trace'); +}); + it('dashboard show page loads for a valid trace', function () { $trace = Trace::factory()->create(['name' => 'Detail Test']); @@ -514,6 +527,14 @@ expect(Trace::count())->toBe(0); }); +it('TraceMiddleware skips instrumentation when header requests it', function () { + Route::middleware('web')->get('/skip-test', fn () => 'ok'); + + $this->get('/skip-test', ['X-TraceReplay-Skip' => '1'])->assertOk(); + + expect(Trace::where('name', 'like', 'HTTP GET /skip-test%')->count())->toBe(0); +}); + // ── Auth Middleware ────────────────────────────────────────────────────────── it('TraceReplayAuthMiddleware allows access when no IPs configured', function () { @@ -887,6 +908,45 @@ // ── Dashboard — replay endpoint ──────────────────────────────────────────── +it('ReplayService uses recorded host metadata when no base URL configured', function () { + Http::fake(function (HttpRequest $request) use (&$captured) { + $captured = $request; + + return Http::response(['ok' => true], 200); + }); + + config(['trace-replay.replay.default_base_url' => null]); + + $trace = Trace::factory()->create(); + TraceStep::factory()->create([ + 'trace_id' => $trace->id, + 'label' => 'HTTP Request', + 'request_payload' => [ + 'method' => 'GET', + 'uri' => '/api/demo', + 'host' => 'http://example.test', + 'full_url' => 'http://example.test/api/demo?foo=1', + 'headers' => [ + 'accept' => ['application/json'], + 'content-type' => ['application/json'], + ], + 'body' => [], + 'query' => ['foo' => 1], + ], + 'response_payload' => [ + 'status' => 200, + 'body' => ['ok' => true], + ], + ]); + + $result = app(ReplayService::class)->replay($trace); + + expect($captured->url())->toBe('http://example.test/api/demo?foo=1') + ->and($captured->header('X-TraceReplay-Skip'))->toBe(['1']) + ->and($captured->header('X-TraceReplay-Origin-Trace'))->toBe([$trace->id]) + ->and($result['replay']['status'])->toBe(200); +}); + it('dashboard replay endpoint returns error for trace without request payload', function () { $trace = Trace::factory()->create();
    StatusTraceUser / IPStartedDurationActionsTypeStatusNameStartedDurationActions
    + @php + $type = $trace->type ?? 'http'; + $typeIcons = ['http' => 'globe', 'job' => 'cpu', 'command' => 'terminal', 'schedule' => 'clock']; + $typeColors = ['http' => 'brand', 'job' => 'purple', 'command' => 'orange', 'schedule' => 'cyan']; + @endphp +
    + + + + @php $s=$trace->status; @endphp @if($s==='success') - - Success + + OK @elseif($s==='error') - - Failed + + Error @else - - Processing + + ... @endif -
    {{ $trace->name ?? 'Unnamed' }}
    -
    + + +
    + @php + $displayName = $trace->name ?? 'Trace'; + $method = null; + if ($type === 'http' && str_starts_with($displayName, 'HTTP ')) { + $parts = explode(' ', $displayName, 3); + $method = $parts[1] ?? null; + $displayName = $parts[2] ?? $displayName; + } + @endphp +
    + @if($method) + {{ $method }} + @endif + {{ \Illuminate\Support\Str::limit($displayName, 40) }} + @if($trace->http_status) + {{ $trace->http_status }} + @endif +
    +
    {{ substr($trace->id, 0, 8) }} - {{ $trace->steps_count }} steps - @if($trace->tags) - @foreach((array)$trace->tags as $tag) - {{ $tag }} - @endforeach + · + {{ $trace->steps_count ?? 0 }} steps + @if($trace->ip_address) + · + {{ $trace->ip_address }} @endif
    - @if($trace->user_id) -
    User #{{ $trace->user_id }}
    - @endif -
    {{ $trace->ip_address ?? '—' }}
    -
    - {{ $trace->started_at?->diffForHumans() ?? '—' }} + + + +
    {{ $trace->started_at?->diffForHumans() ?? '—' }}
    +
    {{ $trace->started_at?->format('H:i:s') }}
    + + + @if($trace->duration_ms) - @php $ms = $trace->duration_ms; $c = $ms < 200 ? 'green' : ($ms < 1000 ? 'yellow' : 'red'); @endphp - {{ number_format($ms, 0) }} ms + @php + $ms = $trace->duration_ms; + $c = $ms < 200 ? 'green' : ($ms < 1000 ? 'yellow' : 'red'); + @endphp +
    + + {{ number_format($ms, 0) }}ms +
    @else @endif
    - - View → - - + + + +