diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..be711b6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +## Description + + +## Changes Made + +- +- + +## Checklist +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have made corresponding changes to the documentation +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes + +## Related Issues + +Fixes # diff --git a/.github/workflows/code-analysis.yml b/.github/workflows/code-analysis.yml new file mode 100644 index 0000000..32d3eba --- /dev/null +++ b/.github/workflows/code-analysis.yml @@ -0,0 +1,27 @@ +name: Code Analysis + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + analysis: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + tools: composer:v2, pint + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Check code styling (Laravel Pint) + run: pint --test diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..19a3d44 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,51 @@ +name: Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4'] + laravel: ['10.*', '11.*', '12.*', '13.*'] + stability: [prefer-lowest, prefer-stable] + exclude: + # Laravel 10 is NOT compatible with PHP 8.4 + - php: '8.4' + laravel: '10.*' + # Laravel 13 requires PHP ^8.3 + - php: '8.2' + laravel: '13.*' + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, intl, fileinfo + coverage: none + + - name: Setup problem matchers + run: | + echo "::add-matcher::${{ runner.tool_cache }}/php.json" + echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install dependencies + run: | + composer require "illuminate/support:${{ matrix.laravel }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: vendor/bin/pest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b30019 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# ── Composer ────────────────────────────────────────────────────────────────── +/vendor/ +composer.lock +composer.phar + +# ── PHPUnit / Pest ──────────────────────────────────────────────────────────── +.phpunit.result.cache +.phpunit.cache/ +coverage/ +.pest/ + +# ── IDE & Editor ────────────────────────────────────────────────────────────── +.idea/ +.vscode/ +*.sublime-project +*.sublime-workspace +.DS_Store +Thumbs.db + +# ── Environment ─────────────────────────────────────────────────────────────── +.env +.env.* +!.env.example + +# ── Build / Cache artefacts ─────────────────────────────────────────────────── +/node_modules/ +npm-debug.log* +yarn-error.log +*.log + +# ── OS artefacts ────────────────────────────────────────────────────────────── +*.swp +*.swo +*~ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a24bbf9 --- /dev/null +++ b/README.md @@ -0,0 +1,389 @@ +# TraceReplay + +> **High-fidelity process tracking, deterministic replay, and AI-powered debugging for Laravel — production & enterprise ready.** + +[![Latest Version](https://img.shields.io/packagist/v/iazaran/tracereplay)](https://packagist.org/packages/iazaran/tracereplay) +[![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-90%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](https://raw.githubusercontent.com/iazaran/tracereplay/refs/heads/main/art/preview.png) + +--- + +## ✨ Key Features + +| Feature | TraceReplay | Telescope | Debugbar | Clockwork | +|---|---|---|---|---| +| Manual step instrumentation | ✅ | ❌ | ❌ | ❌ | +| Waterfall timeline UI | ✅ | ❌ | ✅ | ✅ | +| Deterministic HTTP replay | ✅ | ❌ | ❌ | ❌ | +| Visual JSON diff on replay | ✅ | ❌ | ❌ | ❌ | +| AI fix-prompt generator | ✅ | ❌ | ❌ | ❌ | +| MCP / AI-agent JSON-RPC API | ✅ | ❌ | ❌ | ❌ | +| DB query tracking per step | ✅ | ✅ | ✅ | ✅ | +| Memory tracking per step | ✅ | ❌ | ✅ | ✅ | +| PII / sensitive-field masking | ✅ | ❌ | ❌ | ❌ | +| Queue-job auto-tracing | ✅ | ✅ | ❌ | ❌ | +| Artisan-command auto-tracing | ✅ | ✅ | ❌ | ❌ | +| Sampling rate control | ✅ | ❌ | ❌ | ❌ | +| Dashboard auth gate | ✅ | ✅ | ❌ | N/A | +| Pruning / data retention | ✅ | ✅ | ❌ | ❌ | +| Multi-tenant (workspace/project) | ✅ | ❌ | ❌ | ❌ | +| Laravel 10 / 11 / 12 support | ✅ | ✅ | ✅ | ✅ | + +--- + +## 🛠 Installation + +```bash +composer require iazaran/tracereplay +``` + +Publish the config and migrations: + +```bash +php artisan vendor:publish --tag=tracereplay-config +php artisan vendor:publish --tag=tracereplay-migrations +``` + +Run migrations: + +```bash +php artisan migrate +``` + +> **Note:** Migrations use `json` columns (not `jsonb`) for full MySQL 5.7+, MariaDB, PostgreSQL, and SQLite compatibility. + +#### Publishing Views (Recommended) + +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: + +```bash +php artisan vendor:publish --tag=tracereplay-views +``` + +This copies the Blade templates to `resources/views/vendor/tracereplay/` where you can edit them freely. The package will automatically use your published versions instead of its built-in views. + +--- + +## ⚙️ Configuration + +Open `config/tracereplay.php`. Every option is documented inline; the key ones are: + +```php +return [ + // Globally enable or disable tracing (useful for CI) + 'enabled' => env('TRACEREPLAY_ENABLED', true), + + // Probabilistic sampling — 1.0 = always trace, 0.1 = trace 10% of requests + 'sample_rate' => env('TRACEREPLAY_SAMPLE_RATE', 1.0), + + // Multi-tenant project ID (optional) + 'project_id' => env('TRACEREPLAY_PROJECT_ID', null), + + // Automatically mask these keys in request/response payloads + 'mask_fields' => ['password', 'password_confirmation', 'token', 'api_key', + 'authorization', 'secret', 'credit_card', 'cvv', 'ssn', 'private_key'], + + // Track DB queries inside each step + 'track_db_queries' => env('TRACEREPLAY_TRACK_DB', true), + + // Dashboard route middleware (add 'auth' or custom gate middleware for production) + 'middleware' => ['web'], + 'api_middleware' => ['api'], + + // IP allowlist for the dashboard (exact match; empty = allow all) + 'allowed_ips' => array_filter(explode(',', env('TRACEREPLAY_ALLOWED_IPS', ''))), + + // Async step persistence via a queue + 'queue' => [ + 'enabled' => env('TRACEREPLAY_QUEUE_ENABLED', false), + 'connection' => env('TRACEREPLAY_QUEUE_CONNECTION', env('QUEUE_CONNECTION', 'sync')), + 'queue' => env('TRACEREPLAY_QUEUE_NAME', 'default'), + ], + + // Replay engine + 'replay' => [ + 'default_base_url' => env('TRACEREPLAY_REPLAY_URL', env('APP_URL', 'http://localhost')), + 'timeout' => env('TRACEREPLAY_REPLAY_TIMEOUT', 30), + ], + + // Auto-pruning retention period (days) + 'retention_days' => env('TRACEREPLAY_RETENTION_DAYS', 30), + + // Failure notifications (email / Slack webhook) + 'notifications' => [ + 'on_failure' => env('TRACEREPLAY_NOTIFY_ON_FAILURE', false), + 'channels' => ['mail'], + 'mail' => ['to' => env('TRACEREPLAY_NOTIFY_EMAIL')], + 'slack' => ['webhook_url' => env('TRACEREPLAY_SLACK_WEBHOOK')], + ], + + // OpenAI integration for in-dashboard AI responses + 'ai' => [ + 'openai_api_key' => env('TRACEREPLAY_OPENAI_KEY'), + 'model' => env('TRACEREPLAY_OPENAI_MODEL', 'gpt-4o'), + ], + + // Auto-tracing for jobs and artisan commands (registered automatically) + 'auto_trace' => [ + 'jobs' => env('TRACEREPLAY_AUTO_TRACE_JOBS', true), + 'commands' => env('TRACEREPLAY_AUTO_TRACE_COMMANDS', false), + 'exclude_commands' => [ + 'queue:work', 'queue:listen', 'horizon', 'schedule:run', + 'schedule:work', 'tracereplay:prune', 'tracereplay:export', + ], + ], +]; +``` + +--- + +## 🚀 Usage + +### Manual Instrumentation + +Wrap any complex logic in `TraceReplay::step()` — each callback's return value is passed through transparently. + +```php +use TraceReplay\Facades\TraceReplay; + +class BookingService +{ + public function handleBooking(array $payload): void + { + TraceReplay::start('Flight Booking', ['channel' => 'web']); + + try { + $inventory = TraceReplay::step('Validate Inventory', function () use ($payload) { + return Inventory::check($payload['flight_id']); + }); + + TraceReplay::checkpoint('Inventory validated', ['seats_left' => $inventory->seats]); + + TraceReplay::context(['user_tier' => auth()->user()->tier]); + + TraceReplay::step('Charge Credit Card', function () use ($payload) { + return PaymentGateway::charge($payload['amount']); + }); + + TraceReplay::end('success'); + + } catch (\Exception $e) { + TraceReplay::end('error'); + throw $e; + } + } +} +``` + +**API Reference:** + +| Method | Description | +|---|---| +| `TraceReplay::start(name, tags[])` | Start a new trace; returns `Trace` or `null` if disabled/sampled-out | +| `TraceReplay::step(label, callable, extra[])` | Wrap callable, record timing, memory, DB queries, errors | +| `TraceReplay::measure(label, callable)` | Alias for `step()` — semantic clarity for benchmarks | +| `TraceReplay::checkpoint(label, state[])` | Record a zero-overhead breadcrumb (no callable) | +| `TraceReplay::context(array)` | Merge data into the next step's `state_snapshot` | +| `TraceReplay::end(status)` | Finalise trace; status: `success` or `error` | +| `TraceReplay::getCurrentTrace()` | Returns the active `Trace` model (or `null`) | + +--- + +### Auto HTTP Ingestion (Middleware) + +Automatically trace every HTTP request. Add to `app/Http/Kernel.php`: + +```php +protected $middlewareGroups = [ + 'web' => [ + // ... + \TraceReplay\Http\Middleware\TraceMiddleware::class, + ], +]; +``` + +For Laravel 11+ (using `bootstrap/app.php`): + +```php +->withMiddleware(function (Middleware $middleware) { + $middleware->append(\TraceReplay\Http\Middleware\TraceMiddleware::class); +}) +``` + +--- + +### Auto Queue-Job Tracing + +Queue jobs are automatically traced when `auto_trace.jobs` is enabled (default: `true`). No manual listener registration is needed — the service provider wires everything up. + +To disable, set `TRACEREPLAY_AUTO_TRACE_JOBS=false` in your `.env`. + +--- + +### Auto Artisan-Command Tracing + +Artisan commands can be auto-traced by enabling `auto_trace.commands`: + +```env +TRACEREPLAY_AUTO_TRACE_COMMANDS=true +``` + +Internal commands like `queue:work`, `horizon`, and `tracereplay:prune` are excluded by default (see `auto_trace.exclude_commands` in the config). + +--- + +### Debug Bar Component + +Drop the `` Blade component into your layout for instant in-page trace inspection: + +```blade +{{-- resources/views/layouts/app.blade.php --}} +@if(config('app.debug')) + +@endif +``` + +--- + +## 🎨 The Dashboard + +Access the built-in dashboard at `https://your-app.com/tracereplay`. + +**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 +- **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 + +### Securing the Dashboard + +Add authentication or authorization middleware in `config/tracereplay.php`: + +```php +'middleware' => ['web', 'auth', 'can:view-tracereplay'], +``` + +Then define the gate: + +```php +// app/Providers/AuthServiceProvider.php +Gate::define('view-tracereplay', function ($user) { + return in_array($user->email, config('tracereplay.admin_emails', [])); +}); +``` + +Or use IP allowlisting (exact match, comma-separated via env): + +```env +TRACEREPLAY_ALLOWED_IPS=203.0.113.5,10.0.0.1 +``` + +--- + +## 🤖 AI Debugging + +For any failed trace the dashboard shows an **AI Fix Prompt** button that generates a structured markdown prompt including: + +- Full execution timeline with timing and DB stats +- The exact error message, file, line, and first 20 stack frames +- 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. + +--- + +## 🤖 MCP / AI-Agent JSON-RPC API + +TraceReplay exposes a JSON-RPC 2.0 endpoint at `POST /api/tracereplay/mcp` for autonomous AI agents. + +**Available methods:** + +| Method | Params | Returns | +|---|---|---| +| `list_traces` | `limit`, `status` | Array of trace summaries | +| `get_trace_context` | `trace_id` | Full trace with steps | +| `generate_fix_prompt` | `trace_id` | Markdown debugging prompt | +| `trigger_replay` | `trace_id` | Replay result + JSON diff | + +Example request: + +```json +{ + "jsonrpc": "2.0", + "method": "generate_fix_prompt", + "params": { "trace_id": "9b12f7e4-..." }, + "id": 1 +} +``` + +--- + +## 🧹 Data Retention + +Automatically prune old traces with the built-in Artisan command. Add to your scheduler: + +```php +// app/Console/Kernel.php +$schedule->command('tracereplay:prune --days=30')->daily(); +``` + +Options: + +```bash +php artisan tracereplay:prune --days=30 # Delete traces older than 30 days +php artisan tracereplay:prune --days=30 --dry-run # Preview what would be deleted +php artisan tracereplay:prune --days=7 --status=error # Only prune error traces +``` + +--- + +## 📤 Export + +Export a trace to JSON or CSV for archiving or external analysis: + +```bash +php artisan tracereplay:export {id} --format=json +php artisan tracereplay:export {id} --format=csv +php artisan tracereplay:export {id} --format=json --output=/tmp/trace.json +php artisan tracereplay:export --status=error --format=json # Export all error traces +``` + +--- + +## 🧪 Testing + +```bash +composer install +./vendor/bin/pest +``` + +90 tests, 183 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`) +- Model accessors (`error_step`, `total_db_queries`, `total_memory_usage`, `completion_percentage`) +- `PayloadMasker` — recursive PII field redaction, case-insensitivity +- `AiPromptService` — prompt generation, OpenAI integration (mocked), null-safety +- `NotificationService` — mail and Slack dispatch, null-safety +- `ReplayService` — HTTP replay and JSON diff +- Dashboard — index, filters, search, show, stats, export, replay, AI prompt +- MCP API — REST endpoints and JSON-RPC (all methods + error handling) +- Middleware — TraceMiddleware (route skipping, disabled config), AuthMiddleware (IP allow/block) +- Artisan `tracereplay:prune` (delete, dry-run, status filter, validation) +- Artisan `tracereplay:export` (JSON, CSV, file output, status filter, validation) +- Blade components — TraceBar rendering with enabled/disabled states + +--- + +## 🛡️ License + +The MIT License (MIT). See [LICENSE](LICENSE) for details. diff --git a/art/preview.png b/art/preview.png new file mode 100644 index 0000000..9196d7f Binary files /dev/null and b/art/preview.png differ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b7be60d --- /dev/null +++ b/composer.json @@ -0,0 +1,65 @@ +{ + "name": "iazaran/tracereplay", + "description": "A high-fidelity process tracking, replay, and AI debugging package for Laravel.", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Ismael", + "email": "hi@iazaran.com" + } + ], + "keywords": [ + "laravel", + "trace", + "replay", + "debug", + "ai", + "monitoring", + "observability" + ], + "homepage": "https://iazaran.github.io/tracereplay/", + "require": { + "php": "^8.2", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^10.0|^11.0|^12.0|^13.0", + "illuminate/routing": "^10.0|^11.0|^12.0|^13.0", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0" + }, + "require-dev": { + "orchestra/testbench": "^8.37|^9.16|^10.9|^11.0", + "orchestra/pest-plugin-testbench": "^2.2|^3.0|^4.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^2.3|^3.0|^4.0", + "guzzlehttp/guzzle": "^7.0", + "laravel/pint": "^1.29" + }, + "autoload": { + "psr-4": { + "TraceReplay\\": "src/", + "TraceReplay\\Database\\Factories\\": "database/factories/" + } + }, + "autoload-dev": { + "psr-4": { + "TraceReplay\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "TraceReplay\\TraceReplayServiceProvider" + ], + "aliases": { + "TraceReplay": "TraceReplay\\Facades\\TraceReplay" + } + } + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} diff --git a/config/tracereplay.php b/config/tracereplay.php new file mode 100644 index 0000000..765351c --- /dev/null +++ b/config/tracereplay.php @@ -0,0 +1,162 @@ + env('TRACEREPLAY_ENABLED', true), + + /* + |-------------------------------------------------------------------------- + | Sampling Rate + |-------------------------------------------------------------------------- + | A float between 0.0 and 1.0 controlling what fraction of HTTP requests + | are traced. 1.0 = trace every request, 0.1 = trace 10% at random. + | Manual TraceReplay::start() calls are never sampled. + */ + 'sample_rate' => env('TRACEREPLAY_SAMPLE_RATE', 1.0), + + /* + |-------------------------------------------------------------------------- + | Multi-Tenant / Project ID + |-------------------------------------------------------------------------- + | Optionally set a static project UUID, or override determineProjectId() + | in a custom TraceReplayManager binding for dynamic multi-tenancy. + */ + 'project_id' => env('TRACEREPLAY_PROJECT_ID', null), + + /* + |-------------------------------------------------------------------------- + | Storage & Queueing + |-------------------------------------------------------------------------- + | When queue.enabled is true, step persistence is offloaded to a queue + | worker to avoid adding latency to the request lifecycle. + */ + 'queue' => [ + 'enabled' => env('TRACEREPLAY_QUEUE_ENABLED', false), + 'connection' => env('TRACEREPLAY_QUEUE_CONNECTION', env('QUEUE_CONNECTION', 'sync')), + 'queue' => env('TRACEREPLAY_QUEUE_NAME', 'default'), + ], + + /* + |-------------------------------------------------------------------------- + | DB Query Tracking + |-------------------------------------------------------------------------- + | When enabled, each step records the number and total time of DB queries + | executed within the step closure. + */ + 'track_db_queries' => env('TRACEREPLAY_TRACK_DB', true), + + /* + |-------------------------------------------------------------------------- + | Data Masking + |-------------------------------------------------------------------------- + | Fields whose values will be replaced with '********' in all captured + | payloads (request bodies, response bodies, state snapshots). + */ + 'mask_fields' => [ + 'password', + 'password_confirmation', + 'token', + 'api_key', + 'authorization', + 'secret', + 'credit_card', + 'cvv', + 'ssn', + 'private_key', + ], + + /* + |-------------------------------------------------------------------------- + | Replay Engine + |-------------------------------------------------------------------------- + */ + 'replay' => [ + 'default_base_url' => env('TRACEREPLAY_REPLAY_URL', env('APP_URL', 'http://localhost')), + 'timeout' => env('TRACEREPLAY_REPLAY_TIMEOUT', 30), + ], + + /* + |-------------------------------------------------------------------------- + | Retention / Auto-Pruning + |-------------------------------------------------------------------------- + | Traces older than `retention_days` will be deleted by the artisan command: + | php artisan tracereplay:prune + | Set to null to disable pruning. + */ + 'retention_days' => env('TRACEREPLAY_RETENTION_DAYS', 30), + + /* + |-------------------------------------------------------------------------- + | Dashboard Route Middleware + |-------------------------------------------------------------------------- + | Protect the TraceReplay dashboard. For production use, add 'auth' or a + | custom gate middleware, e.g. ['web', 'auth', 'can:view-tracereplay']. + */ + 'middleware' => ['web'], + 'api_middleware' => ['api'], + + /* + |-------------------------------------------------------------------------- + | Dashboard IP Allowlist + |-------------------------------------------------------------------------- + | When non-empty, only requests from these IP addresses can access the + | dashboard. CIDR notation is not evaluated — exact match only. + | Leave empty to allow all IPs (rely on middleware for auth instead). + */ + 'allowed_ips' => array_filter(explode(',', env('TRACEREPLAY_ALLOWED_IPS', ''))), + + /* + |-------------------------------------------------------------------------- + | Failure Notifications + |-------------------------------------------------------------------------- + | When on_failure is true and a trace ends with status=error, a + | notification is dispatched via the configured channels. + */ + 'notifications' => [ + 'on_failure' => env('TRACEREPLAY_NOTIFY_ON_FAILURE', false), + 'channels' => ['mail'], // 'mail', 'slack' + 'mail' => [ + 'to' => env('TRACEREPLAY_NOTIFY_EMAIL', null), + ], + 'slack' => [ + 'webhook_url' => env('TRACEREPLAY_SLACK_WEBHOOK', null), + ], + ], + + /* + |-------------------------------------------------------------------------- + | AI Integration (Optional) + |-------------------------------------------------------------------------- + | When openai_api_key is set, the "AI Fix" button in the dashboard will + | call the OpenAI API directly and stream the response. When null, users + | receive a copyable prompt instead (no external call is made). + */ + 'ai' => [ + 'openai_api_key' => env('TRACEREPLAY_OPENAI_KEY', null), + 'model' => env('TRACEREPLAY_OPENAI_MODEL', 'gpt-4o'), + ], + + /* + |-------------------------------------------------------------------------- + | Auto-Tracing: Jobs & Artisan Commands + |-------------------------------------------------------------------------- + | When enabled, queued jobs and artisan commands are automatically wrapped + | in traces without any manual instrumentation. + */ + 'auto_trace' => [ + 'jobs' => env('TRACEREPLAY_AUTO_TRACE_JOBS', true), + 'commands' => env('TRACEREPLAY_AUTO_TRACE_COMMANDS', false), + // Artisan commands to exclude from auto-tracing (exact names) + 'exclude_commands' => [ + 'queue:work', 'queue:listen', 'horizon', 'schedule:run', + 'schedule:work', 'tracereplay:prune', 'tracereplay:export', + ], + ], +]; diff --git a/database/factories/TraceFactory.php b/database/factories/TraceFactory.php new file mode 100644 index 0000000..dede410 --- /dev/null +++ b/database/factories/TraceFactory.php @@ -0,0 +1,44 @@ + implode(' ', array_map(fn () => fake()->word(), range(1, 3))), + 'status' => $status, + 'http_status' => $status === 'success' ? 200 : ($status === 'error' ? 500 : null), + 'duration_ms' => round(rand(10, 5000) + rand(0, 99) / 100, 2), + 'ip_address' => fake()->ipv4(), + 'user_agent' => fake()->userAgent(), + 'tags' => [], + 'started_at' => now(), + 'completed_at' => $status !== 'processing' ? now()->addMilliseconds(rand(10, 500)) : null, + ]; + } + + public function success(): static + { + return $this->state(['status' => 'success', 'http_status' => 200]); + } + + public function failed(): static + { + return $this->state(['status' => 'error', 'http_status' => 500]); + } + + public function processing(): static + { + return $this->state(['status' => 'processing', 'http_status' => null, 'completed_at' => null]); + } +} diff --git a/database/migrations/2024_01_01_000000_create_tracereplay_tables.php b/database/migrations/2024_01_01_000000_create_tracereplay_tables.php new file mode 100644 index 0000000..5a58dc8 --- /dev/null +++ b/database/migrations/2024_01_01_000000_create_tracereplay_tables.php @@ -0,0 +1,76 @@ +uuid('id')->primary(); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('tr_projects', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->uuid('workspace_id')->index(); + $table->string('name'); + $table->timestamps(); + + $table->foreign('workspace_id')->references('id')->on('tr_workspaces')->onDelete('cascade'); + }); + + Schema::create('tr_traces', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->uuid('project_id')->nullable()->index(); + $table->string('name')->nullable(); + $table->json('tags')->nullable(); + $table->float('duration_ms')->nullable(); + $table->string('status')->default('processing'); // processing, success, error + $table->unsignedSmallInteger('http_status')->nullable(); + $table->string('user_id')->nullable()->index(); + $table->string('user_type')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + + $table->foreign('project_id')->references('id')->on('tr_projects')->onDelete('set null'); + }); + + Schema::create('tr_trace_steps', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->uuid('trace_id')->index(); + $table->string('label'); + $table->string('type')->default('step'); // step, checkpoint, http, job, command + $table->integer('step_order')->default(0); + + $table->json('request_payload')->nullable(); + $table->json('response_payload')->nullable(); + $table->json('state_snapshot')->nullable(); + + $table->float('duration_ms')->nullable(); + $table->unsignedBigInteger('memory_usage')->nullable(); // bytes + $table->unsignedInteger('db_query_count')->nullable(); + $table->float('db_query_time_ms')->nullable(); + $table->string('status')->default('success'); // success, error, checkpoint + $table->text('error_reason')->nullable(); + + $table->timestamps(); + + $table->foreign('trace_id')->references('id')->on('tr_traces')->onDelete('cascade'); + }); + } + + public function down() + { + Schema::dropIfExists('tr_trace_steps'); + Schema::dropIfExists('tr_traces'); + Schema::dropIfExists('tr_projects'); + Schema::dropIfExists('tr_workspaces'); + } +}; diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..75edd2e --- /dev/null +++ b/docs/index.html @@ -0,0 +1,401 @@ + + + + + + TraceReplay - High-Fidelity Process Tracking for Laravel + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+ TraceReplay +
+ +
+
+ + +
+ + + + + +
+ +

TraceReplay Documentation

+

High-fidelity process tracking, deterministic replay, and AI debugging for Laravel — production & enterprise ready.

+

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. It supports Laravel 10, 11, 12, and 13.

+ +

Installation

+
composer require iazaran/tracereplay
+

Publish config and migrations:

+
php artisan vendor:publish --tag=tracereplay-config
+php artisan vendor:publish --tag=tracereplay-migrations
+
php artisan migrate
+

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

+ +

Publishing Views (Recommended)

+

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=tracereplay-views
+

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

+ +

Configuration

+

Key options in config/tracereplay.php:

+
return [
+    'enabled'          => env('TRACEREPLAY_ENABLED', true),
+    'sample_rate'      => env('TRACEREPLAY_SAMPLE_RATE', 1.0), // 0.1 = 10% of requests
+    'project_id'       => env('TRACEREPLAY_PROJECT_ID', null),
+    'mask_fields'      => ['password', 'password_confirmation', 'token', 'api_key',
+                           'authorization', 'secret', 'credit_card', 'cvv', 'ssn', 'private_key'],
+    'track_db_queries' => env('TRACEREPLAY_TRACK_DB', true),
+    'middleware'        => ['web'],           // add 'auth', 'can:...' for production
+    'api_middleware'    => ['api'],
+    'allowed_ips'      => array_filter(explode(',', env('TRACEREPLAY_ALLOWED_IPS', ''))),
+    'queue' => [
+        'enabled'    => env('TRACEREPLAY_QUEUE_ENABLED', false),
+        'connection' => env('TRACEREPLAY_QUEUE_CONNECTION', env('QUEUE_CONNECTION', 'sync')),
+        'queue'      => env('TRACEREPLAY_QUEUE_NAME', 'default'),
+    ],
+    'replay' => [
+        'default_base_url' => env('TRACEREPLAY_REPLAY_URL', env('APP_URL')),
+        'timeout'          => env('TRACEREPLAY_REPLAY_TIMEOUT', 30),
+    ],
+    'retention_days' => env('TRACEREPLAY_RETENTION_DAYS', 30),
+    'notifications' => [
+        'on_failure'  => env('TRACEREPLAY_NOTIFY_ON_FAILURE', false),
+        'channels'    => ['mail'],
+        'mail'        => ['to' => env('TRACEREPLAY_NOTIFY_EMAIL')],
+        'slack'       => ['webhook_url' => env('TRACEREPLAY_SLACK_WEBHOOK')],
+    ],
+    'ai' => [
+        'openai_api_key' => env('TRACEREPLAY_OPENAI_KEY'),
+        'model'          => env('TRACEREPLAY_OPENAI_MODEL', 'gpt-4o'),
+    ],
+    'auto_trace' => [
+        'jobs'     => env('TRACEREPLAY_AUTO_TRACE_JOBS', true),
+        'commands' => env('TRACEREPLAY_AUTO_TRACE_COMMANDS', false),
+        'exclude_commands' => ['queue:work', 'queue:listen', 'horizon', 'schedule:run',
+                               'schedule:work', 'tracereplay:prune', 'tracereplay:export'],
+    ],
+];
+ +

Manual Tracing

+

Wrap any complex logic in TraceReplay::step(). Each callback's return value is passed through transparently.

+
use TraceReplay\Facades\TraceReplay;
+
+class BookingService
+{
+    public function handleBooking(array $payload): void
+    {
+        TraceReplay::start('Flight Booking', ['channel' => 'web']);
+
+        try {
+            $inventory = TraceReplay::step('Validate Inventory', function () use ($payload) {
+                return Inventory::check($payload['flight_id']);
+            });
+
+            TraceReplay::checkpoint('Inventory OK', ['seats' => $inventory->seats]);
+
+            TraceReplay::context(['user_tier' => auth()->user()->tier]);
+
+            TraceReplay::step('Charge Credit Card', function () use ($payload) {
+                return PaymentGateway::charge($payload['amount']);
+            });
+
+            TraceReplay::end('success');
+        } catch (\Exception $e) {
+            TraceReplay::end('error');
+            throw $e;
+        }
+    }
+}
+ +

API Reference

+ + + + + + + + + + + +
MethodDescription
TraceReplay::start(name, tags[])Start a new trace; returns Trace or null
TraceReplay::step(label, callable, extra[])Wrap callable — records timing, memory, DB queries, errors
TraceReplay::measure(label, callable)Alias for step() — semantic clarity for benchmarks
TraceReplay::checkpoint(label, state[])Zero-overhead breadcrumb (no callable)
TraceReplay::context(array)Merge data into the next step's state_snapshot
TraceReplay::end(status)Finalise trace; status: 'success' or 'error'
TraceReplay::getCurrentTrace()Returns the active Trace model or null
+ +

HTTP Auto Ingestion

+

Automatically trace every HTTP request. Add to app/Http/Kernel.php:

+
protected $middlewareGroups = [
+    'web' => [
+        // ...
+        \TraceReplay\Http\Middleware\TraceMiddleware::class,
+    ],
+];
+

For Laravel 11+ (bootstrap/app.php):

+
->withMiddleware(function (Middleware $middleware) {
+    $middleware->append(\TraceReplay\Http\Middleware\TraceMiddleware::class);
+})
+ +

Auto Tracing — Jobs & Commands

+

Queue jobs are automatically traced when auto_trace.jobs is enabled (default: true). No manual listener registration needed — the service provider wires everything up.

+

Artisan commands can be auto-traced by enabling auto_trace.commands:

+
TRACEREPLAY_AUTO_TRACE_COMMANDS=true
+

Internal commands like queue:work, horizon, and tracereplay:prune are excluded by default (see auto_trace.exclude_commands).

+ +

The Dashboard

+

Navigate to https://your-app.com/tracereplay.

+
    +
  • Waterfall timeline — visual duration bars relative to total trace time
  • +
  • Live stats — auto-refreshing counters (total, failed, avg duration)
  • +
  • Search & filter — by name, IP, user ID; toggle failed-only
  • +
  • Step inspector — syntax-highlighted JSON payloads and state snapshots
  • +
  • Replay engine — re-run any HTTP step and view a structural JSON diff
  • +
  • AI Fix Prompt — one-click prompt for Cursor, ChatGPT, or Claude
  • +
+ +

Security

+

Add authentication or authorization middleware in config/tracereplay.php:

+
// config/tracereplay.php
+'middleware' => ['web', 'auth', 'can:view-tracereplay'],
+
+// AuthServiceProvider
+Gate::define('view-tracereplay', fn ($user) =>
+    in_array($user->email, ['admin@example.com'])
+);
+

Or restrict by IP (exact match, comma-separated via env):

+
TRACEREPLAY_ALLOWED_IPS=203.0.113.5,10.0.0.1
+ +

Data Masking

+

TraceReplay automatically redacts configured keys from all request/response payloads before they touch the database — including deeply nested values:

+
// config/tracereplay.php
+'mask_fields' => ['password', 'token', 'secret', 'api_key', 'credit_card'],
+
+// Input:  ['username' => 'alice', 'password' => 'hunter2']
+// Stored: ['username' => 'alice', 'password' => '********']
+ +

Replay Engine

+

When an HTTP request fails, TraceReplay lets you deterministically replay it from the dashboard. The replay engine proxies the original request, captures the new response, and generates an exact structural JSON diff.

+ +

AI Debugging

+

For any failed trace the dashboard shows an AI Fix Prompt button. It generates a structured markdown prompt containing:

+
    +
  • Full execution timeline with per-step timing and DB query stats
  • +
  • Exact error message, file, line number, and first 20 stack frames
  • +
  • Request/response payloads (sensitive fields masked)
  • +
  • Step-by-step state snapshots
  • +
+

Configure OPENAI_API_KEY and click "Ask AI" to get an answer directly in the dashboard.

+ +

MCP / AI-Agent JSON-RPC API

+

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

+ + + + + + + + +
MethodParamsReturns
list_traceslimit, statusArray of trace summaries
get_trace_contexttrace_idFull trace with steps
generate_fix_prompttrace_idMarkdown debugging prompt
trigger_replaytrace_idReplay result + JSON diff
+
{
+  "jsonrpc": "2.0",
+  "method": "generate_fix_prompt",
+  "params": { "trace_id": "9b12f7e4-..." },
+  "id": 1
+}
+ +

Data Retention

+

Prune old traces automatically via the scheduler:

+
// app/Console/Kernel.php
+$schedule->command('tracereplay:prune --days=30')->daily();
+
php artisan tracereplay:prune --days=30 --dry-run       # preview
+php artisan tracereplay:prune --days=7 --status=error    # prune only error traces
+

Export a trace for archiving:

+
php artisan tracereplay:export {id} --format=json
+php artisan tracereplay:export {id} --format=csv
+php artisan tracereplay:export {id} --format=json --output=/tmp/trace.json
+php artisan tracereplay:export --status=error --format=json  # all error traces
+ +

Testing

+
./vendor/bin/pest
+

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

+ +
+

TraceReplay © 2026 — MIT License

+ +
+
+ + + + + diff --git a/docs/robots.txt b/docs/robots.txt new file mode 100644 index 0000000..77e19f7 --- /dev/null +++ b/docs/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Allow: / +Sitemap: https://iazaran.github.io/tracereplay/sitemap.xml diff --git a/docs/sitemap.xml b/docs/sitemap.xml new file mode 100644 index 0000000..651a20f --- /dev/null +++ b/docs/sitemap.xml @@ -0,0 +1,9 @@ + + + + https://iazaran.github.io/tracereplay/ + 2024-05-01T00:00:00+00:00 + weekly + 1.0 + + diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..ff1499b --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,28 @@ + + + + + ./tests/Feature + + + + + + + ./src + + + + + + + + + + + diff --git a/resources/views/components/trace-bar.blade.php b/resources/views/components/trace-bar.blade.php new file mode 100644 index 0000000..1af6589 --- /dev/null +++ b/resources/views/components/trace-bar.blade.php @@ -0,0 +1,41 @@ +@if(config('tracereplay.enabled') && $trace) +
+ + + ⚡ TraceReplay + + + {{ $trace->name ?? 'Trace' }} + + + @php $color = $trace->status === 'error' ? '#f87171' : ($trace->status === 'processing' ? '#fbbf24' : '#4ade80'); @endphp + + + + + {{ $trace->steps()->count() }} steps + + + + @if($trace->duration_ms) + + {{ number_format($trace->duration_ms, 1) }} ms + + @endif + + + {{ substr($trace->id, 0, 8) }} + + + + View in Dashboard → + + + + +
+@endif + diff --git a/resources/views/index.blade.php b/resources/views/index.blade.php new file mode 100644 index 0000000..b7e5c4c --- /dev/null +++ b/resources/views/index.blade.php @@ -0,0 +1,166 @@ +@extends('tracereplay::layout') +@section('title', 'Traces — TraceReplay') + +@section('content') +
+ +
+
+

Traces

+

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

+
+ +
+ +
+ + + + + + + @if(request()->hasAny(['search','status'])) + + ✕ Clear + + @endif +
+ + + +
+
+ + +
+ @foreach([['Total Traces','stat-total','blue'],['Failed','stat-failed','red'],['Avg Duration','stat-avg','yellow'],['Today','stat-today','green']] as [$label,$id,$c]) +
+
+
{{ $label }}
+
+ @endforeach +
+ +
+ + + + + + + + + + + + + @forelse ($traces as $trace) + + + + + + + + + @empty + + + + @endforelse + +
StatusTraceUser / IPStartedDurationActions
+ @php $s=$trace->status; @endphp + @if($s==='success') + + Success + + @elseif($s==='error') + + Failed + + @else + + Processing + + @endif + +
{{ $trace->name ?? 'Unnamed' }}
+
+ {{ substr($trace->id, 0, 8) }} + {{ $trace->steps_count }} steps + @if($trace->tags) + @foreach((array)$trace->tags as $tag) + {{ $tag }} + @endforeach + @endif +
+
+ @if($trace->user_id) +
User #{{ $trace->user_id }}
+ @endif +
{{ $trace->ip_address ?? '—' }}
+
+ {{ $trace->started_at?->diffForHumans() ?? '—' }} + + @if($trace->duration_ms) + @php $ms = $trace->duration_ms; $c = $ms < 200 ? 'green' : ($ms < 1000 ? 'yellow' : 'red'); @endphp + {{ number_format($ms, 0) }} ms + @else + + @endif + + + View → + + +
+
+ +

No traces recorded yet.

+

Instrument your code with TraceReplay::step() or enable the middleware.

+
+
+ + @if ($traces->hasPages()) +
+ Showing {{ $traces->firstItem() }}–{{ $traces->lastItem() }} of {{ $traces->total() }} + {{ $traces->links() }} +
+ @endif +
+
+@endsection diff --git a/resources/views/layout.blade.php b/resources/views/layout.blade.php new file mode 100644 index 0000000..63ce232 --- /dev/null +++ b/resources/views/layout.blade.php @@ -0,0 +1,96 @@ + + + + + + @yield('title', 'TraceReplay — Laravel Debugging Engine') + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+

TraceReplay

+

Laravel Debugging Engine

+
+
+ +
+ + +
+ @yield('content') +
+ + + + diff --git a/resources/views/show.blade.php b/resources/views/show.blade.php new file mode 100644 index 0000000..0582551 --- /dev/null +++ b/resources/views/show.blade.php @@ -0,0 +1,327 @@ +@extends('tracereplay::layout') +@section('title', ($trace->name ?? 'Trace') . ' — TraceReplay') + +@section('content') +
+ + +
+ +
+
+

{{ $trace->name }}

+ {{ substr($trace->id, 0, 8) }} +
+ +
+
+ + {{ number_format($trace->duration_ms ?? 0, 2) }} ms +
+
+ + {{ $trace->steps->count() }} Steps +
+
+ + +
+
+
+
+ + +
+

Execution Flow

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

Error occurred here

+ @endif +
+
+ @endforeach + +
+
+
+ + +
+ + +
+
+ Select a step from the timeline to inspect + +
+
+ @if($trace->status === 'error') + + @endif + +
+
+ + +
+ + +
+ + + +
+ + +
+ + +
+ + + + +
+ + +
+
+

Request Payload

+
+
+
+

State Snapshot

+
+
+
+ + +
+ +
No response payload recorded for this step.
+
+ + +
+
+
+
+
+ + +
+
+
+

+ 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 +
+ @endforeach +
+
+ 0 ms{{ number_format($totalMs/2, 0) }} ms{{ number_format($totalMs, 0) }} ms +
+
+
+ + +
+
+
+

+ Replay Results +

+ +
+
+ +
+ HTTP Status: + + + +
+
+
+

Original Response

+
+
+
+

Replayed Response

+
+
+
+
+

Structural Diff

+
+
+
+
+
+ +
+
+ + in error messages + // cannot break out of the script block (XSS mitigation). + this.allSteps = {!! json_encode($trace->steps->toArray(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE) !!}; + if (this.allSteps.length > 0) { + const errorStep = this.allSteps.find(s => s.status === 'error'); + this.selectStep(errorStep || this.allSteps[0]); + } + }, + + selectStep(step) { + this.selectedStep = step; + this.activeTab = step.status === 'error' ? 'error' : 'request'; + }, + + formatJSON(obj) { + if (!obj) return 'No data available'; + if (typeof obj === 'string') { + try { + obj = JSON.parse(obj); + } catch (e) { + return obj; + } + } + return JSON.stringify(obj, null, 2); + }, + + async generateFixPrompt() { + this.showAiModal = true; + this.aiPromptContent = 'Generating expert debugging prompt…'; + + try { + const response = await fetch(`{{ route('tracereplay.ai.prompt', $trace->id) }}`, { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, + 'Accept': 'application/json' + } + }); + const data = await response.json(); + // Show AI response if available, otherwise just the prompt + this.aiPromptContent = data.data.ai_response || data.data.prompt; + } catch (e) { + this.aiPromptContent = 'Error generating prompt: ' + e.message; + } + }, + + async replayRequest() { + this.showReplayModal = true; + this.replayData = { original: 'Running replay...', replay: 'Waiting...' }; + + try { + const response = await fetch(`{{ route('tracereplay.replay', $trace->id) }}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, + 'Accept': 'application/json' + }, + body: JSON.stringify({}) + }); + const result = await response.json(); + if (result.status === 'success') { + this.replayData = result.data; + } else { + throw new Error(result.message); + } + } catch (e) { + this.replayData = { original: 'Failed', replay: e.message }; + } + }, + + copyPrompt() { + navigator.clipboard.writeText(this.aiPromptContent); + alert('Prompt copied to clipboard!'); + } + })); + }); + +@endsection diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..045cd4e --- /dev/null +++ b/routes/api.php @@ -0,0 +1,18 @@ + 'api/tracereplay/mcp', + 'as' => 'tracereplay.api.mcp.', + 'middleware' => config('tracereplay.api_middleware', ['api']), +], function () { + Route::post('/', [McpController::class, 'handleRpc'])->name('rpc'); + + // REST fallbacks if preferred over RPC + Route::get('/traces', [McpController::class, 'listTraces']); + Route::get('/traces/{trace}/context', [McpController::class, 'getContext']); + Route::post('/traces/{trace}/replay', [McpController::class, 'triggerReplay']); + Route::get('/traces/{trace}/fix-prompt', [McpController::class, 'generateFixPrompt']); +}); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..0883156 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,23 @@ + 'tracereplay', + 'as' => 'tracereplay.', + 'middleware' => $middleware, +], function () { + Route::get('/', [DashboardController::class, 'index'])->name('index'); + Route::get('/traces/{id}', [DashboardController::class, 'show'])->name('show'); + Route::post('/traces/{id}/replay', [DashboardController::class, 'replay'])->name('replay'); + Route::post('/traces/{id}/ai-prompt', [DashboardController::class, 'generatePrompt'])->name('ai.prompt'); + Route::get('/stats', [DashboardController::class, 'stats'])->name('stats'); + Route::get('/traces/{id}/export', [DashboardController::class, 'export'])->name('export'); +}); diff --git a/src/Console/Commands/ExportTraceCommand.php b/src/Console/Commands/ExportTraceCommand.php new file mode 100644 index 0000000..6461895 --- /dev/null +++ b/src/Console/Commands/ExportTraceCommand.php @@ -0,0 +1,100 @@ +argument('id'); + $format = strtolower($this->option('format')); + $output = $this->option('output'); + $status = $this->option('status'); + + if (! \in_array($format, ['json', 'csv'], true)) { + $this->error("Unsupported format '{$format}'. Use 'json' or 'csv'."); + + return self::FAILURE; + } + + if ($status && ! \in_array($status, ['success', 'error', 'processing'], true)) { + $this->error("Invalid status '{$status}'. Use 'success', 'error', or 'processing'."); + + return self::FAILURE; + } + + if ($id) { + $traces = Trace::with('steps')->where('id', $id)->get(); + if ($traces->isEmpty()) { + $this->error("Trace '{$id}' not found."); + + return self::FAILURE; + } + } else { + $query = Trace::with('steps'); + if ($status) { + $query->where('status', $status); + } + $traces = $query->orderBy('started_at', 'desc')->get(); + } + + $content = $format === 'json' + ? $this->toJson($traces) + : $this->toCsv($traces); + + if ($output) { + $dir = \dirname($output); + if ($dir && ! \is_dir($dir)) { + $this->error("Directory '{$dir}' does not exist."); + + return self::FAILURE; + } + if (@file_put_contents($output, $content) === false) { + $this->error("Failed to write to '{$output}'."); + + return self::FAILURE; + } + $this->info("Exported {$traces->count()} trace(s) to {$output}"); + } else { + $this->line($content); + } + + return self::SUCCESS; + } + + private function toJson($traces): string + { + return json_encode($traces->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + private function toCsv($traces): string + { + $rows = []; + $rows[] = implode(',', ['id', 'name', 'status', 'duration_ms', 'steps', 'started_at', 'completed_at']); + + foreach ($traces as $trace) { + $rows[] = implode(',', [ + $trace->id, + '"'.str_replace('"', '""', $trace->name ?? '').'"', + $trace->status, + $trace->duration_ms ?? 0, + $trace->steps->count(), + '"'.($trace->started_at ?? '').'"', + '"'.($trace->completed_at ?? '').'"', + ]); + } + + return implode("\n", $rows); + } +} diff --git a/src/Console/Commands/PruneTracesCommand.php b/src/Console/Commands/PruneTracesCommand.php new file mode 100644 index 0000000..0729761 --- /dev/null +++ b/src/Console/Commands/PruneTracesCommand.php @@ -0,0 +1,63 @@ +option('days') ?? config('tracereplay.retention_days', 30)); + $status = $this->option('status'); + $dryRun = $this->option('dry-run'); + + if ($days <= 0) { + $this->error('Retention days must be a positive integer.'); + + return self::FAILURE; + } + + $cutoff = now()->subDays($days); + + if ($status && ! \in_array($status, ['success', 'error', 'processing'], true)) { + $this->error("Invalid status '{$status}'. Use 'success', 'error', or 'processing'."); + + return self::FAILURE; + } + + $query = Trace::where('started_at', '<', $cutoff); + + if ($status) { + $query->where('status', $status); + } + + $count = $query->count(); + + if ($count === 0) { + $this->info('No traces found matching the criteria.'); + + return self::SUCCESS; + } + + if ($dryRun) { + $this->warn("[Dry Run] Would delete {$count} trace(s) older than {$days} day(s)."); + + return self::SUCCESS; + } + + $query->delete(); + + $this->info("Deleted {$count} trace(s) older than {$days} day(s)."); + + return self::SUCCESS; + } +} diff --git a/src/Facades/TraceReplay.php b/src/Facades/TraceReplay.php new file mode 100644 index 0000000..d5a431d --- /dev/null +++ b/src/Facades/TraceReplay.php @@ -0,0 +1,27 @@ +orderBy('started_at', 'desc'); + + if ($request->boolean('filter_by_error')) { + $query->where('status', 'error'); + } + + return response()->json([ + 'status' => 'success', + 'data' => $query->paginate(20), + ]); + } + + public function getContext($id) + { + $trace = Trace::with('steps')->findOrFail($id); + + return response()->json([ + 'status' => 'success', + 'data' => [ + 'trace' => $trace, + 'completion_percentage' => $trace->completion_percentage, + 'total_duration' => $trace->duration_ms, + 'error_step' => $trace->error_step, + ], + ]); + } + + public function triggerReplay(Request $request, $id, ReplayService $replayService) + { + $trace = Trace::with('steps')->findOrFail($id); + + try { + $overrideUrl = $request->input('override_url'); + $result = $replayService->replay($trace, $overrideUrl); + + return response()->json([ + 'status' => 'success', + 'data' => $result, + ]); + } catch (\Throwable $e) { + return response()->json([ + 'status' => 'error', + 'message' => $e->getMessage(), + ], 400); + } + } + + public function generateFixPrompt($id, AiPromptService $promptService) + { + $trace = Trace::with('steps')->findOrFail($id); + + return response()->json([ + 'status' => 'success', + 'data' => [ + 'prompt' => $promptService->generateFixPrompt($trace), + ], + ]); + } + + /** + * Optional JSON-RPC 2.0 handler + */ + public function handleRpc(Request $request, ReplayService $replayService, AiPromptService $promptService) + { + $method = $request->input('method'); + $params = $request->input('params', []); + + try { + switch ($method) { + case 'list_traces': + $query = Trace::withCount('steps')->orderBy('started_at', 'desc'); + if (isset($params['filter_by_error']) && $params['filter_by_error']) { + $query->where('status', 'error'); + } + $result = $query->paginate(20)->toArray(); + break; + + case 'get_trace_context': + $trace = Trace::with('steps')->findOrFail($params['trace_id']); + $result = [ + 'trace' => $trace, + 'completion_percentage' => $trace->completion_percentage, + 'error_step' => $trace->error_step, + ]; + break; + + case 'trigger_replay': + $trace = Trace::with('steps')->findOrFail($params['trace_id']); + $result = $replayService->replay($trace, $params['override_url'] ?? null); + break; + + case 'generate_fix_prompt': + $trace = Trace::with('steps')->findOrFail($params['trace_id']); + $result = ['prompt' => $promptService->generateFixPrompt($trace)]; + break; + + default: + throw new \Exception('Method not found', -32601); + } + + return response()->json([ + 'jsonrpc' => '2.0', + 'result' => $result, + 'id' => $request->input('id'), + ]); + } catch (\Throwable $e) { + return response()->json([ + 'jsonrpc' => '2.0', + 'error' => [ + 'code' => \is_int($e->getCode()) && $e->getCode() !== 0 ? $e->getCode() : -32000, + 'message' => $e->getMessage(), + ], + 'id' => $request->input('id'), + ]); + } + } +} diff --git a/src/Http/Controllers/DashboardController.php b/src/Http/Controllers/DashboardController.php new file mode 100644 index 0000000..26ce35e --- /dev/null +++ b/src/Http/Controllers/DashboardController.php @@ -0,0 +1,102 @@ +orderBy('started_at', 'desc'); + + $status = $request->query('status'); + if ($status && \in_array($status, ['success', 'error', 'processing'], true)) { + $query->where('status', $status); + } + + if ($search = $request->query('search')) { + $query->search($search); + } + + $traces = $query->paginate(25)->withQueryString(); + + return view('tracereplay::index', compact('traces')); + } + + public function show(string $id) + { + $trace = Trace::with('steps')->findOrFail($id); + + return view('tracereplay::show', compact('trace')); + } + + public function replay(Request $request, string $id, ReplayService $replayService): JsonResponse + { + $trace = Trace::with('steps')->findOrFail($id); + + try { + $result = $replayService->replay($trace, $request->input('override_url')); + + return response()->json(['status' => 'success', 'data' => $result]); + } catch (\Throwable $e) { + return response()->json(['status' => 'error', 'message' => $e->getMessage()], 400); + } + } + + public function generatePrompt(string $id, AiPromptService $promptService): JsonResponse + { + $trace = Trace::with('steps')->findOrFail($id); + $prompt = $promptService->generateFixPrompt($trace); + + // If OpenAI key is configured, attempt a direct API call + $aiResponse = $promptService->callOpenAI($prompt); + + return response()->json([ + 'status' => 'success', + 'data' => [ + 'prompt' => $prompt, + 'ai_response' => $aiResponse, // null when no key is configured + ], + ]); + } + + public function stats(): JsonResponse + { + $total = Trace::count(); + $failed = Trace::failed()->count(); + $success = Trace::successful()->count(); + $today = Trace::whereDate('started_at', today())->count(); + + $avgDuration = Trace::whereNotNull('duration_ms')->avg('duration_ms'); + $slowest = Trace::whereNotNull('duration_ms')->max('duration_ms'); + + return response()->json([ + 'total' => $total, + 'success' => $success, + 'failed' => $failed, + 'today' => $today, + 'failure_rate' => $total > 0 ? round(($failed / $total) * 100, 1) : 0, + 'avg_duration' => round($avgDuration ?? 0, 2), + 'slowest' => round($slowest ?? 0, 2), + ]); + } + + public function export(string $id) + { + $trace = Trace::with('steps')->findOrFail($id); + + $filename = 'trace-'.substr($id, 0, 8).'.json'; + $content = json_encode($trace->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + return response($content, 200, [ + 'Content-Type' => 'application/json', + 'Content-Disposition' => "attachment; filename=\"{$filename}\"", + ]); + } +} diff --git a/src/Http/Middleware/TraceMiddleware.php b/src/Http/Middleware/TraceMiddleware.php new file mode 100644 index 0000000..59ebfd6 --- /dev/null +++ b/src/Http/Middleware/TraceMiddleware.php @@ -0,0 +1,86 @@ + $sampleRate) { + return $next($request); + } + + // Skip tracereplay dashboard routes to avoid recursive tracing + if (str_starts_with($request->path(), 'tracereplay') || str_starts_with($request->path(), 'api/tracereplay')) { + return $next($request); + } + + $masker = app(PayloadMasker::class); + $reqBody = $masker->mask($request->all()); + + // 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); + + if (! $trace) { + return $next($request); + } + + // Capture the full request payload on the HTTP step + $requestPayload = [ + 'method' => $request->method(), + 'uri' => $uri, + '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; + } + + public function terminate(Request $request, SymfonyResponse $response): void + { + if (! config('tracereplay.enabled')) { + return; + } + + $httpStatus = $response->getStatusCode(); + $status = ($httpStatus >= 400) ? 'error' : 'success'; + + // Capture response on the last step + $masker = app(PayloadMasker::class); + + $responsePayload = [ + 'status' => $httpStatus, + 'headers' => $masker->mask($response->headers->all()), + ]; + + // Try to decode JSON body; fall back to truncated text + $content = $response->getContent(); + $decoded = json_decode($content, true); + $responsePayload['body'] = json_last_error() === JSON_ERROR_NONE + ? $masker->mask($decoded ?? []) + : substr($content, 0, 2000); + + TraceReplay::captureResponseOnLastStep($responsePayload, $httpStatus); + TraceReplay::end($status); + } +} diff --git a/src/Http/Middleware/TraceReplayAuthMiddleware.php b/src/Http/Middleware/TraceReplayAuthMiddleware.php new file mode 100644 index 0000000..7979dee --- /dev/null +++ b/src/Http/Middleware/TraceReplayAuthMiddleware.php @@ -0,0 +1,27 @@ +ip(), $allowedIps, true)) { + abort(403, 'Access to TraceReplay dashboard is restricted.'); + } + + return $next($request); + } +} diff --git a/src/Jobs/PersistTraceStepJob.php b/src/Jobs/PersistTraceStepJob.php new file mode 100644 index 0000000..d51f1ec --- /dev/null +++ b/src/Jobs/PersistTraceStepJob.php @@ -0,0 +1,29 @@ +stepData = $stepData; + } + + public function handle(): void + { + // Actually save the model + $step = new TraceStep($this->stepData); + $step->save(); + } +} diff --git a/src/Listeners/CommandTraceListener.php b/src/Listeners/CommandTraceListener.php new file mode 100644 index 0000000..a0df112 --- /dev/null +++ b/src/Listeners/CommandTraceListener.php @@ -0,0 +1,62 @@ +command)) { + return; + } + + $excluded = config('tracereplay.auto_trace.exclude_commands', []); + if (\in_array($event->command, $excluded, true)) { + return; + } + + TraceReplay::start("Artisan: {$event->command}", [ + 'command' => $event->command, + 'arguments' => (string) $event->input, + ]); + + TraceReplay::checkpoint('Command Started'); + } + + public function onCommandFinished(CommandFinished $event): void + { + if (! config('tracereplay.auto_trace.commands', false)) { + return; + } + + if (empty($event->command)) { + return; + } + + $excluded = config('tracereplay.auto_trace.exclude_commands', []); + if (\in_array($event->command, $excluded, true)) { + return; + } + + if (! TraceReplay::getCurrentTrace()) { + return; + } + + $status = $event->exitCode === 0 ? 'success' : 'error'; + + TraceReplay::checkpoint('Command Finished', [ + 'exit_code' => $event->exitCode, + ]); + + TraceReplay::end($status); + } +} diff --git a/src/Listeners/JobTraceListener.php b/src/Listeners/JobTraceListener.php new file mode 100644 index 0000000..34b94a9 --- /dev/null +++ b/src/Listeners/JobTraceListener.php @@ -0,0 +1,59 @@ +resolveJobName($event->job->payload()); + + TraceReplay::start("Job: {$jobName}", [ + 'queue' => $event->job->getQueue(), + 'connection' => $event->connectionName, + 'job_id' => $event->job->getJobId(), + ]); + + TraceReplay::checkpoint('Job Started', [ + 'payload' => $event->job->payload(), + ]); + } + + public function onJobProcessed(JobProcessed $_event): void + { + if (! config('tracereplay.auto_trace.jobs', true)) { + return; + } + + TraceReplay::checkpoint('Job Completed'); + TraceReplay::end('success'); + } + + public function onJobFailed(JobFailed $event): void + { + if (! config('tracereplay.auto_trace.jobs', true)) { + return; + } + + TraceReplay::checkpoint('Job Failed', [ + 'error' => $event->exception->getMessage(), + ]); + TraceReplay::end('error'); + } + + private function resolveJobName(array $payload): string + { + $class = $payload['displayName'] ?? $payload['job'] ?? 'UnknownJob'; + + return class_basename($class); + } +} diff --git a/src/Models/Project.php b/src/Models/Project.php new file mode 100644 index 0000000..ecaeff5 --- /dev/null +++ b/src/Models/Project.php @@ -0,0 +1,25 @@ +belongsTo(Workspace::class, 'workspace_id'); + } + + public function traces() + { + return $this->hasMany(Trace::class, 'project_id'); + } +} diff --git a/src/Models/Trace.php b/src/Models/Trace.php new file mode 100644 index 0000000..f40160b --- /dev/null +++ b/src/Models/Trace.php @@ -0,0 +1,111 @@ + 'array', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'duration_ms' => 'float', + 'http_status' => 'integer', + ]; + + public function project() + { + return $this->belongsTo(Project::class, 'project_id'); + } + + public function steps() + { + return $this->hasMany(TraceStep::class, 'trace_id')->orderBy('step_order'); + } + + // ── Scopes ────────────────────────────────────────────────────────────── + + public function scopeFailed(Builder $query): Builder + { + return $query->where('status', 'error'); + } + + public function scopeSuccessful(Builder $query): Builder + { + return $query->where('status', 'success'); + } + + public function scopeSearch(Builder $query, string $term): Builder + { + return $query->where(function (Builder $q) use ($term) { + $q->where('name', 'like', "%{$term}%") + ->orWhere('user_id', 'like', "%{$term}%") + ->orWhere('ip_address', 'like', "%{$term}%"); + }); + } + + // ── Accessors ──────────────────────────────────────────────────────────── + + public function getCompletionPercentageAttribute(): int + { + if ($this->status === 'success') { + return 100; + } + + $totalSteps = $this->steps()->where('type', '!=', 'checkpoint')->count(); + if ($totalSteps === 0) { + return 0; + } + + $errorStep = $this->steps()->where('status', 'error')->first(); + if ($errorStep) { + return (int) round((($errorStep->step_order - 1) / $totalSteps) * 100); + } + + return 50; + } + + public function getErrorStepAttribute(): ?TraceStep + { + return $this->steps()->where('status', 'error')->first(); + } + + public function getTotalDbQueriesAttribute(): int + { + return (int) $this->steps()->sum('db_query_count'); + } + + public function getTotalMemoryUsageAttribute(): int + { + return (int) $this->steps()->sum('memory_usage'); + } +} diff --git a/src/Models/TraceStep.php b/src/Models/TraceStep.php new file mode 100644 index 0000000..c4d6b76 --- /dev/null +++ b/src/Models/TraceStep.php @@ -0,0 +1,60 @@ + 'array', + 'response_payload' => 'array', + 'state_snapshot' => 'array', + 'duration_ms' => 'float', + 'db_query_time_ms' => 'float', + 'memory_usage' => 'integer', + 'db_query_count' => 'integer', + ]; + + public function trace() + { + return $this->belongsTo(Trace::class, 'trace_id'); + } + + public function getDurationColorAttribute(): string + { + $ms = $this->duration_ms ?? 0; + if ($ms < 50) { + return 'green'; + } + if ($ms < 200) { + return 'yellow'; + } + if ($ms < 1000) { + return 'orange'; + } + + return 'red'; + } +} diff --git a/src/Models/Workspace.php b/src/Models/Workspace.php new file mode 100644 index 0000000..3669c2e --- /dev/null +++ b/src/Models/Workspace.php @@ -0,0 +1,20 @@ +hasMany(Project::class, 'workspace_id'); + } +} diff --git a/src/Services/AiPromptService.php b/src/Services/AiPromptService.php new file mode 100644 index 0000000..40ab598 --- /dev/null +++ b/src/Services/AiPromptService.php @@ -0,0 +1,107 @@ +error_step; + + if (! $errorStep) { + return 'This trace completed successfully with no errors recorded. Nothing to debug.'; + } + + $steps = $trace->steps()->orderBy('step_order')->get(); + $app = config('app.name', 'Laravel'); + + $prompt = "You are an expert Laravel/PHP engineer. Your task is to diagnose and fix a failed request.\n\n"; + $prompt .= "## Application Context\n"; + $prompt .= "- **App:** {$app}\n"; + $prompt .= "- **Trace ID:** `{$trace->id}`\n"; + $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 ($trace->user_id) { + $prompt .= "- **User ID:** {$trace->user_id}\n"; + } + if ($trace->ip_address) { + $prompt .= "- **IP Address:** {$trace->ip_address}\n"; + } + $prompt .= "\n"; + + $prompt .= "## Execution Timeline\n\n"; + foreach ($steps as $step) { + $icon = match ($step->status) { + 'success' => '✅', + 'error' => '❌', + 'checkpoint' => '📍', + default => '⏳', + }; + $prompt .= "{$icon} **Step {$step->step_order}: {$step->label}**"; + $prompt .= " | {$step->duration_ms} ms"; + if ($step->db_query_count) { + $prompt .= " | {$step->db_query_count} queries ({$step->db_query_time_ms} ms)"; + } + $prompt .= "\n"; + + if (! empty($step->request_payload)) { + $prompt .= ' - **Input:** `'.substr(json_encode($step->request_payload), 0, 500)."`\n"; + } + if (! empty($step->state_snapshot)) { + $prompt .= ' - **State:** `'.substr(json_encode($step->state_snapshot), 0, 500)."`\n"; + } + + if ($step->id === $errorStep->id) { + $prompt .= "\n### 🚨 Failure Point\n\n"; + $prompt .= "```json\n".($step->error_reason ?? 'No error details.')."\n```\n\n"; + } + } + + $prompt .= "## Your Task\n\n"; + $prompt .= "1. **Root Cause:** Analyse the timeline and identify why Step #{$errorStep->step_order} (`{$errorStep->label}`) failed.\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"; + $prompt .= "_Context generated by TraceReplay — https://github.com/iazaran/tracereplay_\n"; + + return $prompt; + } + + /** + * Optionally call the OpenAI API directly and return the AI response. + * Returns null if no API key is configured (caller should fall back to prompt copy). + */ + public function callOpenAI(string $prompt): ?string + { + $apiKey = config('tracereplay.ai.openai_api_key'); + $model = config('tracereplay.ai.model', 'gpt-4o'); + + if (! $apiKey) { + return null; + } + + $response = Http::withToken($apiKey) + ->timeout(60) + ->post('https://api.openai.com/v1/chat/completions', [ + 'model' => $model, + 'messages' => [ + ['role' => 'user', 'content' => $prompt], + ], + ]); + + if ($response->successful()) { + return $response->json('choices.0.message.content'); + } + + return null; + } +} diff --git a/src/Services/NotificationService.php b/src/Services/NotificationService.php new file mode 100644 index 0000000..e28ec9a --- /dev/null +++ b/src/Services/NotificationService.php @@ -0,0 +1,93 @@ + $this->sendMail($trace), + 'slack' => $this->sendSlack($trace), + default => null, + }; + } + } + + protected function sendMail(Trace $trace): void + { + $to = config('tracereplay.notifications.mail.to'); + if (! $to) { + return; + } + + $errorStep = $trace->error_step; + $dashboardUrl = rtrim(config('app.url', ''), '/').'/tracereplay/traces/'.$trace->id; + + $subject = "[TraceReplay] Trace Failed: {$trace->name}"; + $body = $this->buildEmailBody($trace, $errorStep, $dashboardUrl); + + Mail::raw($body, function ($message) use ($to, $subject) { + $message->to($to)->subject($subject); + }); + } + + protected function sendSlack(Trace $trace): void + { + $webhookUrl = config('tracereplay.notifications.slack.webhook_url'); + if (! $webhookUrl) { + return; + } + + $errorStep = $trace->error_step; + $dashboardUrl = rtrim(config('app.url', ''), '/').'/tracereplay/traces/'.$trace->id; + + $payload = [ + 'text' => ':red_circle: *TraceReplay — Trace Failed*', + 'attachments' => [ + [ + 'color' => 'danger', + 'fields' => [ + ['title' => 'Trace', 'value' => $trace->name, 'short' => true], + ['title' => 'Status', 'value' => 'Failed', 'short' => true], + ['title' => 'Duration', 'value' => ($trace->duration_ms ?? 0).' ms', 'short' => true], + ['title' => 'Failed At', 'value' => $errorStep?->label ?? 'N/A', 'short' => true], + ['title' => 'Error', 'value' => substr($errorStep?->error_reason ?? 'Unknown', 0, 300)], + ['title' => 'Dashboard', 'value' => $dashboardUrl], + ], + 'footer' => 'TraceReplay | '.config('app.name'), + 'ts' => time(), + ], + ], + ]; + + Http::post($webhookUrl, $payload); + } + + private function buildEmailBody(Trace $trace, $errorStep, string $dashboardUrl): string + { + $lines = [ + 'TraceReplay has detected a failed trace in your Laravel application.', + '', + "Trace: {$trace->name}", + "ID: {$trace->id}", + 'Duration: '.($trace->duration_ms ?? 0).' ms', + 'Failed At: '.($errorStep?->label ?? 'Unknown'), + '', + 'Error:', + $errorStep?->error_reason ?? 'No error details captured.', + '', + 'View in Dashboard:', + $dashboardUrl, + ]; + + return implode("\n", $lines); + } +} diff --git a/src/Services/PayloadMasker.php b/src/Services/PayloadMasker.php new file mode 100644 index 0000000..d2ccedb --- /dev/null +++ b/src/Services/PayloadMasker.php @@ -0,0 +1,43 @@ + */ + protected array $fields; + + public function __construct() + { + $this->fields = array_map( + 'strtolower', + config('tracereplay.mask_fields', [ + 'password', 'password_confirmation', 'token', + 'api_key', 'authorization', 'secret', 'credit_card', + ]) + ); + } + + /** + * Recursively mask sensitive fields in an array. + */ + public function mask(mixed $data): mixed + { + if (! is_array($data)) { + return $data; + } + + $result = []; + foreach ($data as $key => $value) { + if (\in_array(strtolower((string) $key), $this->fields, true)) { + $result[$key] = '********'; + } elseif (is_array($value)) { + $result[$key] = $this->mask($value); + } else { + $result[$key] = $value; + } + } + + return $result; + } +} diff --git a/src/Services/ReplayService.php b/src/Services/ReplayService.php new file mode 100644 index 0000000..9f6d9ac --- /dev/null +++ b/src/Services/ReplayService.php @@ -0,0 +1,96 @@ +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.'); + } + + $payload = $initialStep->request_payload; + $method = strtoupper($payload['method'] ?? 'GET'); + $uri = $payload['uri'] ?? '/'; + $headers = $payload['headers'] ?? []; + $body = $payload['body'] ?? []; + $query = $payload['query'] ?? []; + + // Remove host headers so they don't interfere with the target + unset($headers['host'], $headers['Host']); + + $baseUrl = $overrideUrl ?? config('tracereplay.replay.default_base_url'); + $targetUrl = rtrim($baseUrl, '/').'/'.ltrim($uri, '/'); + + // 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) + ->timeout((int) config('tracereplay.replay.timeout', 30)) + ->withQueryParameters($query) + ->send($method, $targetUrl, $isJson ? ['json' => $body] : ['form_params' => $body]); + + $replayBody = $response->json() ?? $response->body(); + + $replayResponsePayload = $this->masker->mask([ + 'status' => $response->status(), + 'headers' => $response->headers(), + 'body' => $replayBody, + ]); + + $originalResponsePayload = $this->masker->mask($initialStep->response_payload ?? []); + + $originalBody = $originalResponsePayload['body'] ?? $originalResponsePayload; + $replayBody2 = $replayResponsePayload['body'] ?? $replayResponsePayload; + + return [ + 'original' => $originalResponsePayload, + 'replay' => $replayResponsePayload, + 'diff' => (\is_array($originalBody) && \is_array($replayBody2)) + ? $this->generateDiff($originalBody, $replayBody2) + : ['status' => $originalBody === $replayBody2 ? 'unchanged' : 'changed', 'original' => $originalBody, 'replay' => $replayBody2], + ]; + } + + protected function generateDiff(array $original, array $replay): array + { + // Simple manual structural diffing + $diff = []; + + foreach ($original as $key => $value) { + if (! array_key_exists($key, $replay)) { + $diff[$key] = ['status' => 'removed', 'original' => $value]; + } elseif ($replay[$key] !== $value) { + if (is_array($value) && is_array($replay[$key])) { + $diff[$key] = $this->generateDiff($value, $replay[$key]); + } else { + $diff[$key] = ['status' => 'changed', 'original' => $value, 'replay' => $replay[$key]]; + } + } else { + $diff[$key] = ['status' => 'unchanged', 'value' => $value]; + } + } + + foreach ($replay as $key => $value) { + if (! array_key_exists($key, $original)) { + $diff[$key] = ['status' => 'added', 'replay' => $value]; + } + } + + return $diff; + } +} diff --git a/src/TraceReplayManager.php b/src/TraceReplayManager.php new file mode 100644 index 0000000..be94c90 --- /dev/null +++ b/src/TraceReplayManager.php @@ -0,0 +1,291 @@ +> Buffered steps for batch insert */ + protected array $stepBuffer = []; + + /** @var array Extra context merged into the next step */ + protected array $pendingContext = []; + + /** @var int Running step order counter */ + protected int $stepCounter = 0; + + /** @var float|null High-resolution start time (microtime) */ + protected ?float $startedAtMicrotime = null; + + public function __construct($app) + { + $this->app = $app; + } + + // ── Lifecycle ──────────────────────────────────────────────────────────── + + public function start(string $name, array $tags = []): ?Trace + { + if (! config('tracereplay.enabled', true)) { + return null; + } + + try { + $user = null; + try { + $user = auth()->user(); + } catch (Throwable) { + } + + $this->stepCounter = 0; + $this->stepBuffer = []; + $this->startedAtMicrotime = microtime(true); + + $this->currentTrace = Trace::create([ + 'project_id' => $this->determineProjectId(), + 'name' => $name, + 'tags' => $tags, + 'status' => 'processing', + 'user_id' => $user?->getAuthIdentifier(), + 'user_type' => $user ? \get_class($user) : null, + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + 'started_at' => now(), + ]); + + return $this->currentTrace; + } catch (Throwable $e) { + $this->handleInternalError($e); + + return null; + } + } + + public function step(string $label, callable $callback, array $extra = []): mixed + { + if (! $this->currentTrace) { + return $callback(); + } + + $memBefore = memory_get_usage(true); + $start = microtime(true); + $status = 'success'; + $errorReason = null; + $trackDb = config('tracereplay.track_db_queries', true); + + // Use Laravel's built-in query log rather than DB::listen() to avoid + // listener accumulation: each additional step() call would register + // another persistent listener, causing every query to be counted + // multiple times (once per registered listener). + if ($trackDb) { + DB::flushQueryLog(); + DB::enableQueryLog(); + } + + try { + return $callback(); + } catch (Throwable $e) { + $status = 'error'; + $errorReason = json_encode([ + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => collect(explode("\n", $e->getTraceAsString()))->take(20)->implode("\n"), + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + throw $e; + } finally { + $durationMs = round((microtime(true) - $start) * 1000, 2); + $memDelta = memory_get_usage(true) - $memBefore; + + $queryCount = 0; + $queryTimeMs = 0.0; + if ($trackDb) { + $queries = DB::getQueryLog(); + $queryCount = \count($queries); + $queryTimeMs = round(array_sum(array_column($queries, 'time')), 2); + DB::disableQueryLog(); + DB::flushQueryLog(); + } + + $this->stepCounter++; + + $stepData = [ + 'trace_id' => $this->currentTrace->id, + 'label' => $label, + 'type' => $extra['type'] ?? 'step', + 'status' => $status, + 'duration_ms' => $durationMs, + 'memory_usage' => max(0, $memDelta), + 'db_query_count' => $queryCount, + 'db_query_time_ms' => $queryTimeMs, + 'error_reason' => $errorReason, + 'step_order' => $this->stepCounter, + 'request_payload' => $extra['request_payload'] ?? null, + 'response_payload' => $extra['response_payload'] ?? null, + 'state_snapshot' => [ + ...($extra['state_snapshot'] ?? []), + ...$this->pendingContext, + ], + ]; + + $this->pendingContext = []; + + $step = new TraceStep($stepData); + $this->persistStep($step); + } + } + + /** + * Alias of step() for semantic clarity around measuring a single callable. + */ + public function measure(string $label, callable $callback, array $extra = []): mixed + { + return $this->step($label, $callback, $extra); + } + + /** + * Record a zero-overhead breadcrumb (no callable) in the timeline. + */ + public function checkpoint(string $label, array $state = []): void + { + if (! $this->currentTrace) { + return; + } + + $this->stepCounter++; + + $step = new TraceStep([ + 'trace_id' => $this->currentTrace->id, + 'label' => $label, + 'type' => 'checkpoint', + 'status' => 'checkpoint', + 'step_order' => $this->stepCounter, + 'duration_ms' => 0, + 'state_snapshot' => [...$state, ...$this->pendingContext], + ]); + + $this->pendingContext = []; + $this->persistStep($step); + } + + /** + * Attach arbitrary key/value context that will be merged into the next step's state_snapshot. + */ + public function context(array $data): static + { + $this->pendingContext = [...$this->pendingContext, ...$data]; + + return $this; + } + + /** + * Attach the response payload onto the most recently saved step (used by middleware terminate()). + */ + public function captureResponseOnLastStep(array $responsePayload, int $httpStatus = 200): void + { + if (! $this->currentTrace) { + return; + } + + try { + $last = TraceStep::where('trace_id', $this->currentTrace->id) + ->orderBy('step_order', 'desc') + ->first(); + + if ($last) { + $last->update([ + 'response_payload' => $responsePayload, + ]); + } + + $this->currentTrace->update(['http_status' => $httpStatus]); + } catch (Throwable $e) { + $this->handleInternalError($e); + } + } + + public function end(string $status = 'success'): void + { + if (! $this->currentTrace) { + return; + } + + try { + $durationMs = $this->startedAtMicrotime + ? round((microtime(true) - $this->startedAtMicrotime) * 1000, 2) + : null; + + $this->currentTrace->update([ + 'status' => $status, + 'completed_at' => now(), + 'duration_ms' => $durationMs, + ]); + + // Fire notification if configured and trace failed + if ($status === 'error' && config('tracereplay.notifications.on_failure', false)) { + try { + app(NotificationService::class)->notifyFailure($this->currentTrace->fresh(['steps'])); + } catch (Throwable) { + } + } + } catch (Throwable $e) { + $this->handleInternalError($e); + } finally { + $this->currentTrace = null; + $this->stepBuffer = []; + $this->stepCounter = 0; + $this->pendingContext = []; + $this->startedAtMicrotime = null; + } + } + + public function getCurrentTrace(): ?Trace + { + return $this->currentTrace; + } + + // ── Internal Helpers ───────────────────────────────────────────────────── + + protected function persistStep(TraceStep $step): void + { + try { + if (config('tracereplay.queue.enabled') && class_exists(PersistTraceStepJob::class)) { + dispatch(new PersistTraceStepJob($step->toArray())) + ->onConnection(config('tracereplay.queue.connection')) + ->onQueue(config('tracereplay.queue.queue')); + } else { + $step->save(); + } + } catch (Throwable $e) { + $this->handleInternalError($e); + } + } + + protected function determineProjectId(): ?string + { + return config('tracereplay.project_id'); + } + + /** + * Graceful degradation: log but never let tracing crash the application. + */ + protected function handleInternalError(Throwable $e): void + { + if (function_exists('logger')) { + logger()->error('[TraceReplay] Internal error: '.$e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); + } + } +} diff --git a/src/TraceReplayServiceProvider.php b/src/TraceReplayServiceProvider.php new file mode 100644 index 0000000..ae01c85 --- /dev/null +++ b/src/TraceReplayServiceProvider.php @@ -0,0 +1,81 @@ +mergeConfigFrom(__DIR__.'/../config/tracereplay.php', 'tracereplay'); + + $this->app->singleton('tracereplay', fn ($app) => new TraceReplayManager($app)); + + $this->app->singleton(PayloadMasker::class); + $this->app->singleton(AiPromptService::class); + $this->app->singleton(NotificationService::class); + $this->app->singleton(ReplayService::class, fn ($app) => new ReplayService( + $app->make(PayloadMasker::class) + )); + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->publishes([ + __DIR__.'/../config/tracereplay.php' => config_path('tracereplay.php'), + ], 'tracereplay-config'); + + $this->publishes([ + __DIR__.'/../database/migrations/' => database_path('migrations'), + ], 'tracereplay-migrations'); + + $this->publishes([ + __DIR__.'/../resources/views' => resource_path('views/vendor/tracereplay'), + ], 'tracereplay-views'); + + $this->commands([ + PruneTracesCommand::class, + ExportTraceCommand::class, + ]); + } + + $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); + $this->loadViewsFrom(__DIR__.'/../resources/views', 'tracereplay'); + $this->loadViewComponentsAs('tracereplay', [ + TraceBar::class, + ]); + + $this->loadRoutesFrom(__DIR__.'/../routes/web.php'); + $this->loadRoutesFrom(__DIR__.'/../routes/api.php'); + + // Auto-trace queue jobs + if (config('tracereplay.auto_trace.jobs', true)) { + Event::listen(JobProcessing::class, fn (JobProcessing $e) => $this->app->make(JobTraceListener::class)->onJobProcessing($e)); + Event::listen(JobProcessed::class, fn (JobProcessed $e) => $this->app->make(JobTraceListener::class)->onJobProcessed($e)); + Event::listen(JobFailed::class, fn (JobFailed $e) => $this->app->make(JobTraceListener::class)->onJobFailed($e)); + } + + // Auto-trace artisan commands + if (config('tracereplay.auto_trace.commands', false)) { + Event::listen(CommandStarting::class, fn (CommandStarting $e) => $this->app->make(CommandTraceListener::class)->onCommandStarting($e)); + Event::listen(CommandFinished::class, fn (CommandFinished $e) => $this->app->make(CommandTraceListener::class)->onCommandFinished($e)); + } + } +} diff --git a/src/View/Components/TraceBar.php b/src/View/Components/TraceBar.php new file mode 100644 index 0000000..0e53c73 --- /dev/null +++ b/src/View/Components/TraceBar.php @@ -0,0 +1,26 @@ +show || ! config('tracereplay.enabled', true)) { + return ''; + } + + $trace = TraceReplay::getCurrentTrace(); + + return view('tracereplay::components.trace-bar', [ + 'trace' => $trace, + ]); + } +} diff --git a/tests/Feature/TraceReplayTest.php b/tests/Feature/TraceReplayTest.php new file mode 100644 index 0000000..b71cfc5 --- /dev/null +++ b/tests/Feature/TraceReplayTest.php @@ -0,0 +1,1078 @@ +toBeInstanceOf(Trace::class) + ->and($trace->status)->toBe('processing') + ->and($trace->name)->toBe('Login Process'); + + TraceReplay::end('success'); + + $fresh = $trace->fresh(); + expect($fresh->status)->toBe('success') + ->and($fresh->completed_at)->not->toBeNull() + ->and($fresh->duration_ms)->toBeFloat(); // precision may be tiny negative due to clock resolution +}); + +it('returns null from start() when tracing is disabled', function () { + config(['tracereplay.enabled' => false]); + + $trace = TraceReplay::start('Disabled Trace'); + + expect($trace)->toBeNull(); +}); + +it('marks trace as error when ended with error status', function () { + $trace = TraceReplay::start('Failing Process'); + TraceReplay::end('error'); + + expect($trace->fresh()->status)->toBe('error'); +}); + +it('getCurrentTrace() returns the active trace', function () { + $trace = TraceReplay::start('Active'); + expect(TraceReplay::getCurrentTrace())->toBe($trace); + + TraceReplay::end(); + expect(TraceReplay::getCurrentTrace())->toBeNull(); +}); + +// ── Steps ───────────────────────────────────────────────────────────────────── + +it('can record a step and returns the callback result', function () { + TraceReplay::start('Booking'); + + $result = TraceReplay::step('Validate Request', fn () => 'validated'); + + expect($result)->toBe('validated'); + + $trace = TraceReplay::getCurrentTrace(); + expect($trace->steps()->count())->toBe(1) + ->and($trace->steps()->first()->label)->toBe('Validate Request') + ->and($trace->steps()->first()->status)->toBe('success'); +}); + +it('records step order correctly', function () { + TraceReplay::start('Multi-Step'); + + TraceReplay::step('Step A', fn () => 1); + TraceReplay::step('Step B', fn () => 2); + TraceReplay::step('Step C', fn () => 3); + + $steps = TraceReplay::getCurrentTrace()->steps()->orderBy('step_order')->pluck('label')->all(); + + expect($steps)->toBe(['Step A', 'Step B', 'Step C']); +}); + +it('records error status when step throws an exception', function () { + TraceReplay::start('Error Step Test'); + + try { + TraceReplay::step('Failing Step', function () { + throw new RuntimeException('Intentional error'); + }); + } catch (RuntimeException) { + // expected + } + + $step = TraceReplay::getCurrentTrace()->steps()->first(); + + expect($step->status)->toBe('error') + ->and($step->error_reason)->toContain('Intentional error'); +}); + +it('measure() is an alias for step()', function () { + TraceReplay::start('Measure Test'); + + $result = TraceReplay::measure('Measured Op', fn () => 42); + + expect($result)->toBe(42) + ->and(TraceReplay::getCurrentTrace()->steps()->count())->toBe(1); +}); + +// ── Checkpoints ─────────────────────────────────────────────────────────────── + +it('can record a checkpoint', function () { + TraceReplay::start('Checkpoint Test'); + + TraceReplay::checkpoint('After Validation', ['user_id' => 5]); + + $step = TraceReplay::getCurrentTrace()->steps()->first(); + + expect($step->label)->toBe('After Validation') + ->and($step->type)->toBe('checkpoint') + ->and($step->state_snapshot)->toMatchArray(['user_id' => 5]); +}); + +// ── Context ─────────────────────────────────────────────────────────────────── + +it('context() merges into the next step state_snapshot', function () { + TraceReplay::start('Context Test'); + TraceReplay::context(['tenant' => 'acme', 'tier' => 'pro']); + + TraceReplay::step('Do Work', fn () => null); + + $snapshot = TraceReplay::getCurrentTrace()->steps()->first()->state_snapshot; + + expect($snapshot)->toMatchArray(['tenant' => 'acme', 'tier' => 'pro']); +}); + +// ── Model Scopes ────────────────────────────────────────────────────────────── + +it('Trace::failed() scope filters by error status', function () { + Trace::factory()->create(['status' => 'success']); + Trace::factory()->create(['status' => 'error']); + + expect(Trace::failed()->count())->toBe(1); +}); + +it('Trace::successful() scope filters by success status', function () { + Trace::factory()->create(['status' => 'success']); + Trace::factory()->create(['status' => 'error']); + + expect(Trace::successful()->count())->toBe(1); +}); + +it('Trace::search() scope searches by name', function () { + Trace::factory()->create(['name' => 'Login Flow']); + Trace::factory()->create(['name' => 'Checkout Process']); + + expect(Trace::search('login')->count())->toBe(1); +}); + +// ── Accessors ───────────────────────────────────────────────────────────────── + +it('error_step accessor returns the first error step', function () { + TraceReplay::start('Error Accessor Test'); + TraceReplay::step('OK Step', fn () => null); + + try { + TraceReplay::step('Bad Step', fn () => throw new Exception('boom')); + } catch (Exception) { + } + + $trace = TraceReplay::getCurrentTrace(); + $errorStep = $trace->error_step; + + expect($errorStep)->not->toBeNull() + ->and($errorStep->label)->toBe('Bad Step'); +}); + +it('total_db_queries accessor sums step db_query_count', function () { + TraceReplay::start('DB Count Test'); + TraceReplay::step('Step', fn () => null); + + $trace = TraceReplay::getCurrentTrace(); + $trace->steps()->first()->update(['db_query_count' => 3]); + + expect($trace->total_db_queries)->toBe(3); +}); + +// ── PayloadMasker ───────────────────────────────────────────────────────────── + +it('PayloadMasker masks configured sensitive fields', function () { + config(['tracereplay.mask_fields' => ['password', 'token']]); + + $masker = new PayloadMasker; + $result = $masker->mask([ + 'username' => 'alice', + 'password' => 'supersecret', + 'token' => 'abc123', + 'data' => ['token' => 'nested_token'], + ]); + + expect($result['username'])->toBe('alice') + ->and($result['password'])->toBe('********') + ->and($result['token'])->toBe('********') + ->and($result['data']['token'])->toBe('********'); +}); + +// ── AiPromptService ─────────────────────────────────────────────────────────── + +it('AiPromptService generates a prompt for a failed trace', function () { + TraceReplay::start('AI Prompt Test'); + + try { + TraceReplay::step('Broken Step', fn () => throw new Exception('DB connection failed')); + } catch (Exception) { + } + + TraceReplay::end('error'); + + $trace = Trace::latest()->first(); + $prompt = app(AiPromptService::class)->generateFixPrompt($trace->load('steps')); + + expect($prompt)->toContain('Broken Step') + ->and($prompt)->toContain('DB connection failed') + ->and($prompt)->toContain('Root Cause'); +}); + +it('AiPromptService returns a no-error message for successful traces', function () { + TraceReplay::start('Success Trace'); + TraceReplay::end('success'); + + $trace = Trace::latest()->first(); + $prompt = app(AiPromptService::class)->generateFixPrompt($trace->load('steps')); + + expect($prompt)->toContain('successfully with no errors recorded'); +}); + +// ── Artisan Commands ────────────────────────────────────────────────────────── + +it('tracereplay:prune deletes old traces', function () { + Trace::factory()->create(['started_at' => now()->subDays(60)]); + Trace::factory()->create(['started_at' => now()->subDays(60)]); + Trace::factory()->create(['started_at' => now()->subDays(1)]); + + $this->artisan('tracereplay:prune', ['--days' => 30]) + ->expectsOutput('Deleted 2 trace(s) older than 30 day(s).') + ->assertExitCode(0); + + expect(Trace::count())->toBe(1); +}); + +it('tracereplay:prune dry-run does not delete traces', function () { + Trace::factory()->create(['started_at' => now()->subDays(60)]); + + $this->artisan('tracereplay:prune', ['--days' => 30, '--dry-run' => true]) + ->assertExitCode(0); + + expect(Trace::count())->toBe(1); +}); + +it('tracereplay:export outputs JSON for a trace', function () { + $trace = Trace::factory()->create(['name' => 'Exportable Trace']); + + $this->artisan('tracereplay:export', ['id' => $trace->id, '--format' => 'json']) + ->assertExitCode(0); +}); + +// ── Export CSV ──────────────────────────────────────────────────────────────── + +it('tracereplay:export outputs CSV for a trace', function () { + $trace = Trace::factory()->create(['name' => 'CSV, with "commas"']); + + $this->artisan('tracereplay:export', ['id' => $trace->id, '--format' => 'csv']) + ->assertExitCode(0); +}); + +it('tracereplay:export rejects unsupported format', function () { + $trace = Trace::factory()->create(); + + $this->artisan('tracereplay:export', ['id' => $trace->id, '--format' => 'xml']) + ->assertExitCode(1); +}); + +it('tracereplay:export returns failure for nonexistent trace', function () { + $this->artisan('tracereplay:export', ['id' => 'nonexistent-id']) + ->assertExitCode(1); +}); + +it('tracereplay:export filters by status', function () { + Trace::factory()->create(['status' => 'success']); + Trace::factory()->create(['status' => 'error']); + + $this->artisan('tracereplay:export', ['--status' => 'error', '--format' => 'json']) + ->assertExitCode(0); +}); + +// ── Prune Command Extra ────────────────────────────────────────────────────── + +it('tracereplay:prune rejects zero days', function () { + $this->artisan('tracereplay:prune', ['--days' => 0]) + ->assertExitCode(1); +}); + +it('tracereplay:prune filters by status', function () { + Trace::factory()->create(['started_at' => now()->subDays(60), 'status' => 'success']); + Trace::factory()->create(['started_at' => now()->subDays(60), 'status' => 'error']); + + $this->artisan('tracereplay:prune', ['--days' => 30, '--status' => 'success']) + ->assertExitCode(0); + + expect(Trace::count())->toBe(1) + ->and(Trace::first()->status)->toBe('error'); +}); + +it('tracereplay:prune shows no-op when no traces match', function () { + $this->artisan('tracereplay:prune', ['--days' => 30]) + ->expectsOutput('No traces found matching the criteria.') + ->assertExitCode(0); +}); + +// ── Dashboard Controller ───────────────────────────────────────────────────── + +it('dashboard index page loads', function () { + Trace::factory()->count(3)->create(); + + $response = $this->get('/tracereplay'); + + $response->assertOk(); + $response->assertSee('Traces'); +}); + +it('dashboard index filters by status', function () { + Trace::factory()->create(['status' => 'error', 'name' => 'Error Trace']); + Trace::factory()->create(['status' => 'success', 'name' => 'Good Trace']); + + $response = $this->get('/tracereplay?status=error'); + + $response->assertOk(); + $response->assertSee('Error Trace'); +}); + +it('dashboard index search works', function () { + Trace::factory()->create(['name' => 'Login Flow']); + Trace::factory()->create(['name' => 'Checkout Process']); + + $response = $this->get('/tracereplay?search=Login'); + + $response->assertOk(); + $response->assertSee('Login Flow'); +}); + +it('dashboard show page loads for a valid trace', function () { + $trace = Trace::factory()->create(['name' => 'Detail Test']); + + $response = $this->get("/tracereplay/traces/{$trace->id}"); + + $response->assertOk(); + $response->assertSee('Detail Test'); +}); + +it('dashboard show returns 404 for nonexistent trace', function () { + $response = $this->get('/tracereplay/traces/nonexistent-uuid'); + + $response->assertNotFound(); +}); + +it('dashboard stats endpoint returns JSON', function () { + Trace::factory()->create(['status' => 'success', 'duration_ms' => 100]); + Trace::factory()->create(['status' => 'error', 'duration_ms' => 200]); + + $response = $this->getJson('/tracereplay/stats'); + + $response->assertOk(); + $response->assertJsonStructure(['total', 'success', 'failed', 'today', 'failure_rate', 'avg_duration', 'slowest']); + $response->assertJson(['total' => 2, 'success' => 1, 'failed' => 1]); +}); + +it('dashboard export downloads JSON file', function () { + $trace = Trace::factory()->create(['name' => 'Export Me']); + + $response = $this->get("/tracereplay/traces/{$trace->id}/export"); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'application/json'); + $response->assertHeader('Content-Disposition'); +}); + +// ── MCP API Controller ─────────────────────────────────────────────────────── + +it('MCP list traces returns paginated results', function () { + Trace::factory()->count(3)->create(); + + $response = $this->getJson('/api/tracereplay/mcp/traces'); + + $response->assertOk(); + $response->assertJsonPath('status', 'success'); +}); + +it('MCP list traces filters by error', function () { + Trace::factory()->create(['status' => 'success']); + Trace::factory()->create(['status' => 'error']); + + $response = $this->getJson('/api/tracereplay/mcp/traces?filter_by_error=1'); + + $response->assertOk(); + $response->assertJsonPath('data.total', 1); +}); + +it('MCP get context returns trace details', function () { + $trace = Trace::factory()->create(['status' => 'success']); + + $response = $this->getJson("/api/tracereplay/mcp/traces/{$trace->id}/context"); + + $response->assertOk(); + $response->assertJsonPath('status', 'success'); + $response->assertJsonStructure(['data' => ['trace', 'completion_percentage', 'total_duration', 'error_step']]); +}); + +it('MCP generate fix prompt returns prompt', function () { + TraceReplay::start('MCP Prompt Test'); + try { + TraceReplay::step('Fail', fn () => throw new Exception('mcp error')); + } catch (Exception) { + } + TraceReplay::end('error'); + + $trace = Trace::latest()->first(); + + $response = $this->getJson("/api/tracereplay/mcp/traces/{$trace->id}/fix-prompt"); + + $response->assertOk(); + $response->assertJsonPath('status', 'success'); +}); + +it('MCP RPC list_traces method works', function () { + Trace::factory()->count(2)->create(); + + $response = $this->postJson('/api/tracereplay/mcp', [ + 'method' => 'list_traces', + 'params' => [], + 'id' => 1, + ]); + + $response->assertOk(); + $response->assertJsonPath('jsonrpc', '2.0'); + $response->assertJsonPath('id', 1); +}); + +it('MCP RPC returns error for unknown method', function () { + $response = $this->postJson('/api/tracereplay/mcp', [ + 'method' => 'unknown_method', + 'params' => [], + 'id' => 42, + ]); + + $response->assertOk(); + $response->assertJsonPath('jsonrpc', '2.0'); + $response->assertJsonPath('error.code', -32601); + $response->assertJsonPath('id', 42); +}); + +it('MCP RPC get_trace_context method works', function () { + $trace = Trace::factory()->create(['status' => 'error']); + + $response = $this->postJson('/api/tracereplay/mcp', [ + 'method' => 'get_trace_context', + 'params' => ['trace_id' => $trace->id], + 'id' => 2, + ]); + + $response->assertOk(); + $response->assertJsonPath('jsonrpc', '2.0'); +}); + +it('MCP RPC generate_fix_prompt method works', function () { + TraceReplay::start('RPC Prompt'); + try { + TraceReplay::step('Err', fn () => throw new Exception('rpc fail')); + } catch (Exception) { + } + TraceReplay::end('error'); + + $trace = Trace::latest()->first(); + + $response = $this->postJson('/api/tracereplay/mcp', [ + 'method' => 'generate_fix_prompt', + 'params' => ['trace_id' => $trace->id], + 'id' => 3, + ]); + + $response->assertOk(); + $response->assertJsonPath('jsonrpc', '2.0'); +}); + +// ── TraceMiddleware ────────────────────────────────────────────────────────── + +it('TraceMiddleware skips tracereplay dashboard routes', function () { + // Dashboard route should not create a trace + $this->get('/tracereplay'); + + // The middleware skips routes starting with 'tracereplay' + // Traces created should only be for the dashboard itself, not from middleware + // Since dashboard creates no traces itself, count should be 0 from middleware + expect(Trace::where('name', 'like', 'HTTP GET /tracereplay%')->count())->toBe(0); +}); + +it('TraceMiddleware respects disabled config', function () { + config(['tracereplay.enabled' => false]); + + $this->get('/tracereplay'); + + expect(Trace::count())->toBe(0); +}); + +// ── Auth Middleware ────────────────────────────────────────────────────────── + +it('TraceReplayAuthMiddleware allows access when no IPs configured', function () { + config(['tracereplay.allowed_ips' => []]); + + $response = $this->get('/tracereplay'); + + $response->assertOk(); +}); + +it('TraceReplayAuthMiddleware blocks access from unauthorized IPs', function () { + config(['tracereplay.allowed_ips' => ['192.168.1.100']]); + + $response = $this->get('/tracereplay'); + + $response->assertForbidden(); +}); + +// ── captureResponseOnLastStep ──────────────────────────────────────────────── + +it('captureResponseOnLastStep attaches response to last step', function () { + TraceReplay::start('Response Capture Test'); + TraceReplay::step('Some Work', fn () => null); + + TraceReplay::captureResponseOnLastStep(['status' => 200, 'body' => ['ok' => true]], 200); + + $step = TraceReplay::getCurrentTrace()->steps()->orderBy('step_order', 'desc')->first(); + + expect($step->response_payload)->toMatchArray(['status' => 200, 'body' => ['ok' => true]]); +}); + +it('captureResponseOnLastStep does nothing without active trace', function () { + // Should not throw + TraceReplay::captureResponseOnLastStep(['data' => 'test']); + + expect(true)->toBeTrue(); +}); + +// ── Context Accumulation ───────────────────────────────────────────────────── + +it('context() accumulates across multiple calls', function () { + TraceReplay::start('Multi Context'); + TraceReplay::context(['a' => 1]); + TraceReplay::context(['b' => 2]); + + TraceReplay::step('Work', fn () => null); + + $snapshot = TraceReplay::getCurrentTrace()->steps()->first()->state_snapshot; + + expect($snapshot)->toMatchArray(['a' => 1, 'b' => 2]); +}); + +it('context() resets after step consumes it', function () { + TraceReplay::start('Context Reset'); + TraceReplay::context(['temp' => 'val']); + TraceReplay::step('Step 1', fn () => null); + TraceReplay::step('Step 2', fn () => null); + + $steps = TraceReplay::getCurrentTrace()->steps()->orderBy('step_order')->get(); + + expect($steps[0]->state_snapshot)->toMatchArray(['temp' => 'val']) + ->and($steps[1]->state_snapshot)->toBe([]); +}); + +it('context() resets after checkpoint consumes it', function () { + TraceReplay::start('Context Checkpoint Reset'); + TraceReplay::context(['ctx' => 'data']); + TraceReplay::checkpoint('CP1'); + TraceReplay::checkpoint('CP2'); + + $steps = TraceReplay::getCurrentTrace()->steps()->orderBy('step_order')->get(); + + expect($steps[0]->state_snapshot)->toMatchArray(['ctx' => 'data']) + ->and($steps[1]->state_snapshot)->toBe([]); +}); + +// ── TraceStep Model ────────────────────────────────────────────────────────── + +it('TraceStep duration_color accessor returns correct color', function () { + TraceReplay::start('Color Test'); + TraceReplay::step('Fast', fn () => null); + + $step = TraceReplay::getCurrentTrace()->steps()->first(); + $step->update(['duration_ms' => 10]); + expect($step->fresh()->duration_color)->toBe('green'); + + $step->update(['duration_ms' => 100]); + expect($step->fresh()->duration_color)->toBe('yellow'); + + $step->update(['duration_ms' => 500]); + expect($step->fresh()->duration_color)->toBe('orange'); + + $step->update(['duration_ms' => 2000]); + expect($step->fresh()->duration_color)->toBe('red'); +}); + +it('TraceStep belongs to a trace', function () { + TraceReplay::start('Relation Test'); + TraceReplay::step('Step', fn () => null); + + $step = TraceStep::first(); + $trace = $step->trace; + + expect($trace)->toBeInstanceOf(Trace::class); +}); + +// ── Workspace & Project Models ─────────────────────────────────────────────── + +it('Workspace has many projects', function () { + $workspace = Workspace::create(['id' => Str::uuid(), 'name' => 'Test WS']); + $project = Project::create(['id' => Str::uuid(), 'workspace_id' => $workspace->id, 'name' => 'Test Proj']); + + expect($workspace->projects)->toHaveCount(1) + ->and($workspace->projects->first()->name)->toBe('Test Proj') + ->and($project->workspace->name)->toBe('Test WS'); +}); + +it('Project has many traces', function () { + $workspace = Workspace::create(['id' => Str::uuid(), 'name' => 'WS']); + $project = Project::create(['id' => Str::uuid(), 'workspace_id' => $workspace->id, 'name' => 'Proj']); + + Trace::factory()->create(['project_id' => $project->id]); + Trace::factory()->create(['project_id' => $project->id]); + + expect($project->traces)->toHaveCount(2); +}); + +it('Trace belongs to a project', function () { + $workspace = Workspace::create(['id' => Str::uuid(), 'name' => 'WS']); + $project = Project::create(['id' => Str::uuid(), 'workspace_id' => $workspace->id, 'name' => 'My Project']); + + $trace = Trace::factory()->create(['project_id' => $project->id]); + + expect($trace->project)->toBeInstanceOf(Project::class) + ->and($trace->project->name)->toBe('My Project'); +}); + +// ── Accessors (extra coverage) ─────────────────────────────────────────────── + +it('total_memory_usage accessor sums step memory', function () { + TraceReplay::start('Memory Test'); + TraceReplay::step('Step A', fn () => null); + TraceReplay::step('Step B', fn () => null); + + $trace = TraceReplay::getCurrentTrace(); + $steps = $trace->steps()->orderBy('step_order')->get(); + $steps[0]->update(['memory_usage' => 1024]); + $steps[1]->update(['memory_usage' => 2048]); + + expect($trace->total_memory_usage)->toBe(3072); +}); + +it('completion_percentage returns 100 for successful traces', function () { + $trace = Trace::factory()->create(['status' => 'success']); + expect($trace->completion_percentage)->toBe(100); +}); + +it('completion_percentage returns 0 for trace with no steps', function () { + $trace = Trace::factory()->create(['status' => 'error']); + expect($trace->completion_percentage)->toBe(0); +}); + +it('completion_percentage calculates correctly for error trace with steps', function () { + TraceReplay::start('Partial'); + TraceReplay::step('Step 1', fn () => null); + TraceReplay::step('Step 2', fn () => null); + try { + TraceReplay::step('Step 3', fn () => throw new Exception('fail')); + } catch (Exception) { + } + TraceReplay::end('error'); + + $trace = Trace::latest()->first(); + // 3 steps total, error at step_order 3. Percentage = ((3-1)/3)*100 = 66.67 ≈ 67 + expect($trace->completion_percentage)->toBe(67); +}); + +// ── PayloadMasker Edge Cases ───────────────────────────────────────────────── + +it('PayloadMasker returns non-array values unchanged', function () { + $masker = new PayloadMasker; + + expect($masker->mask('string'))->toBe('string') + ->and($masker->mask(42))->toBe(42) + ->and($masker->mask(null))->toBeNull(); +}); + +it('PayloadMasker is case-insensitive', function () { + config(['tracereplay.mask_fields' => ['Authorization']]); + $masker = new PayloadMasker; + + $result = $masker->mask(['AUTHORIZATION' => 'Bearer xyz', 'name' => 'test']); + + expect($result['AUTHORIZATION'])->toBe('********') + ->and($result['name'])->toBe('test'); +}); + +// ── NotificationService ────────────────────────────────────────────────────── + +it('NotificationService sends mail on failure', function () { + // Mail::raw cannot be asserted via Mail::fake() (known Laravel limitation). + // Instead, spy on the facade to verify it was called. + Mail::shouldReceive('raw') + ->once() + ->withArgs(function (string $body, Closure $callback) { + // Verify the body contains the trace name + return str_contains($body, 'Notified Trace'); + }); + + config([ + 'tracereplay.notifications.on_failure' => true, + 'tracereplay.notifications.channels' => ['mail'], + 'tracereplay.notifications.mail.to' => 'test@example.com', + ]); + + $trace = Trace::factory()->create(['status' => 'error', 'name' => 'Notified Trace']); + + $service = app(NotificationService::class); + $service->notifyFailure($trace); +}); + +it('NotificationService skips mail when no recipient configured', function () { + Mail::fake(); + + config([ + 'tracereplay.notifications.channels' => ['mail'], + 'tracereplay.notifications.mail.to' => null, + ]); + + $trace = Trace::factory()->create(['status' => 'error']); + app(NotificationService::class)->notifyFailure($trace); + + Mail::assertNothingSent(); +}); + +it('NotificationService sends slack notification', function () { + Http::fake(['*' => Http::response([], 200)]); + + config([ + 'tracereplay.notifications.channels' => ['slack'], + 'tracereplay.notifications.slack.webhook_url' => 'https://hooks.slack.test/webhook', + ]); + + $trace = Trace::factory()->create(['status' => 'error', 'name' => 'Slack Trace']); + app(NotificationService::class)->notifyFailure($trace); + + Http::assertSent(fn ($request) => $request->url() === 'https://hooks.slack.test/webhook'); +}); + +it('NotificationService skips slack when no webhook configured', function () { + Http::fake(); + + config([ + 'tracereplay.notifications.channels' => ['slack'], + 'tracereplay.notifications.slack.webhook_url' => null, + ]); + + $trace = Trace::factory()->create(['status' => 'error']); + app(NotificationService::class)->notifyFailure($trace); + + Http::assertNothingSent(); +}); + +// ── Step DB query tracking ─────────────────────────────────────────────────── + +it('step records db query count when tracking is enabled', function () { + config(['tracereplay.track_db_queries' => true]); + + TraceReplay::start('DB Tracking'); + TraceReplay::step('Query Step', function () { + // This query will be tracked + Trace::count(); + }); + + $step = TraceReplay::getCurrentTrace()->steps()->first(); + + expect($step->db_query_count)->toBeGreaterThanOrEqual(1); +}); + +it('step does not track queries when disabled', function () { + config(['tracereplay.track_db_queries' => false]); + + TraceReplay::start('No DB Tracking'); + TraceReplay::step('Silent Step', function () { + Trace::count(); + }); + + $step = TraceReplay::getCurrentTrace()->steps()->first(); + + expect($step->db_query_count)->toBe(0); +}); + +// ── step() without active trace ────────────────────────────────────────────── + +it('step() executes callback even without active trace', function () { + // No TraceReplay::start() called + $result = TraceReplay::step('Orphan', fn () => 'orphan-result'); + + expect($result)->toBe('orphan-result'); +}); + +it('checkpoint() does nothing without active trace', function () { + // Should not throw + TraceReplay::checkpoint('No-op'); + + expect(TraceStep::count())->toBe(0); +}); + +it('end() does nothing without active trace', function () { + // Should not throw + TraceReplay::end(); + + expect(true)->toBeTrue(); +}); + +// ── Facade context() returns self ──────────────────────────────────────────── + +it('context() returns the manager for chaining', function () { + TraceReplay::start('Chain Test'); + + $result = app('tracereplay')->context(['x' => 1]); + + expect($result)->toBeInstanceOf(TraceReplayManager::class); +}); + +// ── Duration Precision ────────────────────────────────────────────────────── + +it('end() records a positive duration_ms', function () { + $trace = TraceReplay::start('Duration Test'); + + // Simulate a tiny bit of work + usleep(5000); // 5 ms + + TraceReplay::end('success'); + + $fresh = $trace->fresh(); + expect($fresh->duration_ms)->toBeGreaterThan(0) + ->and($fresh->duration_ms)->toBeLessThan(5000); // sanity upper bound +}); + +// ── Export Command — invalid directory ────────────────────────────────────── + +it('tracereplay:export fails on invalid output directory', function () { + $trace = Trace::factory()->create(); + + $this->artisan('tracereplay:export', [ + 'id' => $trace->id, + '--format' => 'json', + '--output' => '/nonexistent/dir/trace.json', + ])->assertExitCode(1); +}); + +// ── Prune Command — invalid status ───────────────────────────────────────── + +it('tracereplay:prune rejects invalid status', function () { + $this->artisan('tracereplay:prune', ['--days' => 30, '--status' => 'invalid']) + ->assertExitCode(1); +}); + +// ── Dashboard — invalid status ignored ───────────────────────────────────── + +it('dashboard index ignores invalid status filter', function () { + Trace::factory()->create(['status' => 'success', 'name' => 'Valid Trace']); + + $response = $this->get('/tracereplay?status=nonexistent'); + + $response->assertOk(); + // Invalid status is ignored — all traces should be shown + $response->assertSee('Valid Trace'); +}); + +// ── Dashboard — replay endpoint ──────────────────────────────────────────── + +it('dashboard replay endpoint returns error for trace without request payload', function () { + $trace = Trace::factory()->create(); + + $response = $this->postJson("/tracereplay/traces/{$trace->id}/replay"); + + $response->assertStatus(400); + $response->assertJsonPath('status', 'error'); +}); + +// ── Dashboard — AI prompt endpoint ───────────────────────────────────────── + +it('dashboard AI prompt endpoint works for error trace', function () { + TraceReplay::start('AI Dashboard Test'); + try { + TraceReplay::step('Fail', fn () => throw new Exception('prompt test error')); + } catch (Exception) { + } + TraceReplay::end('error'); + + $trace = Trace::latest()->first(); + + // No OpenAI key configured, so ai_response will be null + $response = $this->postJson("/tracereplay/traces/{$trace->id}/ai-prompt"); + + $response->assertOk(); + $response->assertJsonPath('status', 'success'); + $response->assertJsonStructure(['data' => ['prompt', 'ai_response']]); +}); + +// ── AiPromptService::callOpenAI ──────────────────────────────────────────── + +it('AiPromptService callOpenAI returns null when no key configured', function () { + config(['tracereplay.ai.openai_api_key' => null]); + + $result = app(AiPromptService::class)->callOpenAI('test prompt'); + + expect($result)->toBeNull(); +}); + +it('AiPromptService callOpenAI returns response when key configured', function () { + config(['tracereplay.ai.openai_api_key' => 'test-key']); + + Http::fake([ + 'api.openai.com/*' => Http::response([ + 'choices' => [['message' => ['content' => 'AI fix suggestion']]], + ]), + ]); + + $result = app(AiPromptService::class)->callOpenAI('test prompt'); + + expect($result)->toBe('AI fix suggestion'); +}); + +it('AiPromptService callOpenAI returns null on API failure', function () { + config(['tracereplay.ai.openai_api_key' => 'test-key']); + + Http::fake([ + 'api.openai.com/*' => Http::response([], 500), + ]); + + $result = app(AiPromptService::class)->callOpenAI('test prompt'); + + expect($result)->toBeNull(); +}); + +// ── TraceBar Component ────────────────────────────────────────────────────── + +it('TraceBar renders empty when disabled', function () { + config(['tracereplay.enabled' => false]); + + $component = new TraceBar; + $result = $component->render(); + + expect($result)->toBe(''); +}); + +it('TraceBar renders empty when show is false', function () { + $component = new TraceBar(show: false); + $result = $component->render(); + + expect($result)->toBe(''); +}); + +// ── Export Command — output to file ───────────────────────────────────────── + +it('tracereplay:export writes to output file', function () { + $trace = Trace::factory()->create(['name' => 'File Export']); + $tmpFile = tempnam(sys_get_temp_dir(), 'tr_export_'); + + $this->artisan('tracereplay:export', [ + 'id' => $trace->id, + '--format' => 'json', + '--output' => $tmpFile, + ])->assertExitCode(0); + + $content = file_get_contents($tmpFile); + expect($content)->toContain('File Export'); + + @unlink($tmpFile); +}); + +// ── ExportTraceCommand — status validation ───────────────────────────────── + +it('tracereplay:export rejects invalid status', function () { + $this->artisan('tracereplay:export', ['--status' => 'invalid', '--format' => 'json']) + ->assertExitCode(1); +}); + +// ── AiPromptService — null duration_ms ──────────────────────────────────── + +it('AiPromptService handles null duration_ms without crash', function () { + $trace = Trace::factory()->create([ + 'status' => 'error', + 'duration_ms' => null, + ]); + + // Create a step with error to trigger the prompt path + TraceStep::create([ + 'trace_id' => $trace->id, + 'label' => 'Broken', + 'status' => 'error', + 'error_reason' => 'test error', + 'step_order' => 1, + 'duration_ms' => 0, + ]); + + $prompt = app(AiPromptService::class)->generateFixPrompt($trace->load('steps')); + + // Should not crash and should contain "0.00 ms" for null duration + expect($prompt)->toContain('0.00 ms') + ->and($prompt)->toContain('Broken'); +}); + +// ── NotificationService — null duration_ms ──────────────────────────────── + +it('NotificationService handles null duration_ms in mail', function () { + Mail::shouldReceive('raw') + ->once() + ->withArgs(function (string $body) { + // Should show "0 ms" not " ms" + return str_contains($body, '0 ms'); + }); + + config([ + 'tracereplay.notifications.channels' => ['mail'], + 'tracereplay.notifications.mail.to' => 'test@example.com', + ]); + + $trace = Trace::factory()->create([ + 'status' => 'error', + 'name' => 'Null Duration', + 'duration_ms' => null, + ]); + + app(NotificationService::class)->notifyFailure($trace); +}); + +it('NotificationService handles null duration_ms in slack', function () { + Http::fake(['*' => Http::response([], 200)]); + + config([ + 'tracereplay.notifications.channels' => ['slack'], + 'tracereplay.notifications.slack.webhook_url' => 'https://hooks.slack.test/webhook', + ]); + + $trace = Trace::factory()->create([ + 'status' => 'error', + 'duration_ms' => null, + ]); + + app(NotificationService::class)->notifyFailure($trace); + + Http::assertSent(fn ($request) => str_contains(json_encode($request->data()), '0 ms') + ); +}); + +// ── MCP RPC trigger_replay ────────────────────────────────────────────────── + +it('MCP RPC trigger_replay returns error for trace without payload', function () { + $trace = Trace::factory()->create(); + + $response = $this->postJson('/api/tracereplay/mcp', [ + 'method' => 'trigger_replay', + 'params' => ['trace_id' => $trace->id], + 'id' => 10, + ]); + + $response->assertOk(); + $response->assertJsonPath('jsonrpc', '2.0'); + // Should have an error because no request payload + $response->assertJsonStructure(['error' => ['code', 'message']]); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..8aa7667 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,13 @@ +in('Feature'); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..bbef4a5 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,37 @@ +loadMigrationsFrom(__DIR__.'/../database/migrations'); + } + + protected function getPackageProviders($app) + { + return [ + TraceReplayServiceProvider::class, + ]; + } + + protected function getEnvironmentSetUp($app) + { + // Setup default database to use sqlite :memory: + $app['config']->set('database.default', 'testbench'); + $app['config']->set('database.connections.testbench', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + $app['config']->set('tracereplay.enabled', true); + $app['config']->set('app.key', 'base64:'.base64_encode(str_repeat('a', 32))); + } +}