Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,14 @@ return [
// 0.1 = trace 10% of requests/jobs/commands
'sample_rate' => env('TRACE_REPLAY_SAMPLE_RATE', 1.0),

// Automatically mask these keys in payloads
'mask_fields' => ['password', 'token', 'api_key', 'authorization', 'secret'],
// Disable on low-cost production servers if query-log overhead is too high
'track_db_queries' => env('TRACE_REPLAY_TRACK_DB', true),

// Automatically mask these keys in payloads, headers, and URL query strings
'mask_fields' => ['password', 'token', 'api_key', 'authorization', 'cookie', 'secret'],

// Query bindings can contain PII; keep disabled in production unless needed
'track_db_query_bindings' => env('TRACE_REPLAY_TRACK_DB_BINDINGS', false),

// Dashbord security: only users passing the "view-trace-replay" gate can access
'middleware' => ['web', 'auth'],
Expand All @@ -136,6 +142,15 @@ return [
];
```

For low-cost production servers, start with sampling instead of tracing every
request:

```env
TRACE_REPLAY_SAMPLE_RATE=0.05
TRACE_REPLAY_TRACK_DB=false
TRACE_REPLAY_MAX_PAYLOAD_SIZE=16384
```

---

## 🚀 Usage
Expand Down
25 changes: 22 additions & 3 deletions config/trace-replay.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@
|--------------------------------------------------------------------------
| 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 Trace-Replay::start() calls are never sampled.
| A float between 0.0 and 1.0 controlling what fraction of new traces are
| recorded. 1.0 = trace every request/job, 0.1 = trace 10% at random.
| Pass forceSample: true to TraceReplay::start() for traces that must always
| be captured.
*/
'sample_rate' => env('TRACE_REPLAY_SAMPLE_RATE', 1.0),

Expand Down Expand Up @@ -74,6 +75,15 @@
*/
'track_db_queries' => env('TRACE_REPLAY_TRACK_DB', true),

/*
|--------------------------------------------------------------------------
| DB Query Binding Capture
|--------------------------------------------------------------------------
| Query bindings may contain PII or secrets. Keep this disabled in production
| unless the extra detail is explicitly needed.
*/
'track_db_query_bindings' => env('TRACE_REPLAY_TRACK_DB_BINDINGS', false),

/*
|--------------------------------------------------------------------------
| Data Masking
Expand All @@ -87,11 +97,20 @@
'token',
'api_key',
'authorization',
'cookie',
'secret',
'client_secret',
'credit_card',
'cvv',
'ssn',
'private_key',
'access_token',
'refresh_token',
'id_token',
'x-api-key',
'x-csrf-token',
'x-xsrf-token',
'set-cookie',
],

/*
Expand Down
2 changes: 1 addition & 1 deletion src/Http/Controllers/Api/McpController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public function __construct()
$this->middleware(function ($request, $next) {
$token = config('trace-replay.api.token');

if ($token && $request->header('Authorization') !== 'Bearer '.$token) {
if ($token && ! hash_equals('Bearer '.$token, $request->header('Authorization', ''))) {
return response()->json([
'status' => 'error',
'message' => 'Unauthorized: Invalid or missing API token.',
Expand Down
30 changes: 17 additions & 13 deletions src/Http/Controllers/DashboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,21 +102,25 @@ protected function getDashboardStats(): array
$todayCount = Trace::where('started_at', '>=', $today)->count();

// Operations breakdown (last 7 days)
$operations = TraceStep::join('tr_traces', 'tr_trace_steps.trace_id', '=', 'tr_traces.id')
->whereDate('tr_traces.started_at', '>=', now()->subDays(7))
->selectRaw("
$operationsQuery = TraceStep::join('tr_traces', 'tr_trace_steps.trace_id', '=', 'tr_traces.id')
->whereDate('tr_traces.started_at', '>=', now()->subDays(7));

$operations = (clone $operationsQuery)
->selectRaw('
SUM(COALESCE(db_query_count, 0)) as db_queries,
SUM(COALESCE(cache_hit_count, 0) + COALESCE(cache_miss_count, 0)) as cache_calls,
SUM(CASE WHEN http_calls IS NOT NULL AND http_calls != '[]' AND http_calls != 'null' THEN 1 ELSE 0 END) as http_calls,
SUM(CASE WHEN mail_calls IS NOT NULL AND mail_calls != '[]' AND mail_calls != 'null' THEN 1 ELSE 0 END) as mail_calls
")
SUM(COALESCE(cache_hit_count, 0) + COALESCE(cache_miss_count, 0)) as cache_calls
')
->first();

$operationsData = [
'db_queries' => (int) ($operations->db_queries ?? 0),
'cache_calls' => (int) ($operations->cache_calls ?? 0),
'http_calls' => (int) ($operations->http_calls ?? 0),
'mail_calls' => (int) ($operations->mail_calls ?? 0),
'http_calls' => (clone $operationsQuery)
->whereJsonLength('tr_trace_steps.http_calls', '>', 0)
->count('tr_trace_steps.id'),
'mail_calls' => (clone $operationsQuery)
->whereJsonLength('tr_trace_steps.mail_calls', '>', 0)
->count('tr_trace_steps.id'),
];

return [
Expand Down Expand Up @@ -173,13 +177,13 @@ public function generatePrompt(string $id, AiPromptService $promptService): Json

public function stats(): JsonResponse
{
$stats = Trace::selectRaw('
$stats = Trace::selectRaw("
count(*) as total,
count(case when status = "error" then 1 end) as failed,
count(case when status = "success" then 1 end) as success,
count(case when status = 'error' then 1 end) as failed,
count(case when status = 'success' then 1 end) as success,
avg(duration_ms) as avg_duration,
max(duration_ms) as slowest
')->first();
")->first();

$today = Trace::whereDate('started_at', now()->today())->count();

Expand Down
27 changes: 21 additions & 6 deletions src/Http/Middleware/TraceMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,6 @@ public function handle(Request $request, Closure $next): SymfonyResponse
return $next($request);
}

$masker = app(PayloadMasker::class);
$reqBody = $masker->mask($request->all());

// W3C Trace Context propagation (Recommendation 17)
if ($traceParent = $request->header('traceparent')) {
TraceReplay::setTraceParent($traceParent);
Expand All @@ -39,14 +36,16 @@ public function handle(Request $request, Closure $next): SymfonyResponse
return $next($request);
}

$masker = app(PayloadMasker::class);

// Capture the full request payload on the HTTP step
$requestPayload = [
'method' => $request->method(),
'uri' => $uri,
'full_url' => $request->fullUrl(),
'full_url' => $masker->maskUrl($request->fullUrl()),
'host' => $request->getSchemeAndHttpHost(),
'headers' => $masker->mask($request->headers->all()),
'body' => $reqBody,
'body' => $masker->mask($request->all()),
'query' => $masker->mask($request->query->all()),
];

Expand All @@ -70,6 +69,10 @@ public function terminate(Request $request, SymfonyResponse $response): void
return;
}

if (! TraceReplay::getCurrentTrace()) {
return;
}

$httpStatus = $response->getStatusCode();
$status = ($httpStatus >= 400) ? 'error' : 'success';

Expand All @@ -83,7 +86,19 @@ public function terminate(Request $request, SymfonyResponse $response): void

// Try to decode JSON body; fall back to truncated text (Recommendation 28)
$maxSize = (int) config('trace-replay.max_payload_size', 65536);
$content = $response->getContent();
try {
$content = $response->getContent();
} catch (Throwable) {
$content = false;
}

if ($content === false) {
$responsePayload['body'] = '[TraceReplay: Response body unavailable for streamed or binary response]';
TraceReplay::captureResponseOnLastStep($responsePayload, $httpStatus);
TraceReplay::end($status);

return;
}

if (strlen($content) > $maxSize) {
$content = substr($content, 0, $maxSize)."\n\n[TraceReplay: Payload truncated for size]";
Expand Down
75 changes: 73 additions & 2 deletions src/Services/PayloadMasker.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class PayloadMasker
public function __construct()
{
$this->fields = array_map(
'strtolower',
fn (string $field) => $this->normalizeKey($field),
config('trace-replay.mask_fields', [
'password', 'password_confirmation', 'token',
'api_key', 'authorization', 'secret', 'credit_card',
Expand All @@ -29,7 +29,7 @@ public function mask(mixed $data): mixed

$result = [];
foreach ($data as $key => $value) {
if (\in_array(strtolower((string) $key), $this->fields, true)) {
if (\in_array($this->normalizeKey((string) $key), $this->fields, true)) {
$result[$key] = '********';
} elseif (is_array($value)) {
$result[$key] = $this->mask($value);
Expand All @@ -40,4 +40,75 @@ public function mask(mixed $data): mixed

return $result;
}

public function maskUrl(?string $url): ?string
{
if ($url === null || $url === '') {
return $url;
}

$parts = parse_url($url);
if ($parts === false) {
return $url;
}

if (isset($parts['query'])) {
parse_str($parts['query'], $query);
$parts['query'] = http_build_query(
$this->mask($query),
'',
'&',
PHP_QUERY_RFC3986
);
} elseif (! isset($parts['pass'])) {
return $url;
}

return $this->buildUrl($parts);
}

protected function normalizeKey(string $key): string
{
return preg_replace('/[^a-z0-9]/', '', strtolower($key)) ?? strtolower($key);
}

/**
* @param array<string, mixed> $parts
*/
protected function buildUrl(array $parts): string
{
$url = '';

if (isset($parts['scheme'])) {
$url .= $parts['scheme'].'://';
}

if (isset($parts['user'])) {
$url .= $parts['user'];

if (isset($parts['pass'])) {
$url .= ':********';
}

$url .= '@';
}

$url .= $parts['host'] ?? '';

if (isset($parts['port'])) {
$url .= ':'.$parts['port'];
}

$url .= $parts['path'] ?? '';

if (array_key_exists('query', $parts) && $parts['query'] !== '') {
$url .= '?'.$parts['query'];
}

if (isset($parts['fragment'])) {
$url .= '#'.$parts['fragment'];
}

return $url;
}
}
37 changes: 33 additions & 4 deletions src/TraceReplayManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use TraceReplay\Models\Trace;
use TraceReplay\Models\TraceStep;
use TraceReplay\Services\NotificationService;
use TraceReplay\Services\PayloadMasker;

class TraceReplayManager
{
Expand Down Expand Up @@ -217,6 +218,7 @@ public function step(string $label, callable $callback, array $extra = []): mixe
$queries = [];
if ($trackDb && $connection) {
$queries = array_slice($connection->getQueryLog(), (int) $frame['db_queries_before']);
$queries = $this->sanitizeQueries($queries);
$queryCount = \count($queries);
$queryTimeMs = round(array_sum(array_column($queries, 'time')), 2);
}
Expand Down Expand Up @@ -477,14 +479,16 @@ public function recordEvent($event): void
$frame['cache_calls'][] = ['type' => $type, 'key' => $event->key, 'time' => microtime(true)];
} elseif ($event instanceof HttpRequestSending) {
$frame['http_calls'][] = [
'url' => $event->request->url(),
'url' => $this->masker()->maskUrl($event->request->url()),
'method' => $event->request->method(),
'start' => microtime(true),
];
} elseif ($event instanceof HttpResponseReceived) {
$url = $this->masker()->maskUrl($event->request->url());

for ($index = count($frame['http_calls']) - 1; $index >= 0; $index--) {
if (
($frame['http_calls'][$index]['url'] ?? null) === $event->request->url()
($frame['http_calls'][$index]['url'] ?? null) === $url
&& ($frame['http_calls'][$index]['method'] ?? null) === $event->request->method()
&& ! array_key_exists('status', $frame['http_calls'][$index])
) {
Expand Down Expand Up @@ -518,7 +522,7 @@ public function recordEvent($event): void
$frame['log_calls'][] = [
'level' => $event->level,
'message' => $event->message,
'context' => $event->context,
'context' => $this->masker()->mask($event->context),
'time' => microtime(true),
];
}
Expand All @@ -538,7 +542,7 @@ protected function persistStep(TraceStep $step): void

// Recommendation 28: Truncate oversized payloads to prevent DB bloat
$maxSize = (int) config('trace-replay.max_payload_size', 65536);
$keysToTruncate = ['request_payload', 'response_payload', 'state_snapshot', 'db_queries', 'cache_calls', 'http_calls', 'mail_calls'];
$keysToTruncate = ['request_payload', 'response_payload', 'state_snapshot', 'db_queries', 'cache_calls', 'http_calls', 'mail_calls', 'log_calls'];

foreach ($keysToTruncate as $key) {
if (isset($stepData[$key]) && ! empty($stepData[$key])) {
Expand Down Expand Up @@ -645,6 +649,31 @@ protected function determineProjectId(): ?string
return config('trace-replay.project_id');
}

/**
* @param array<int, array<string, mixed>> $queries
* @return array<int, array<string, mixed>>
*/
protected function sanitizeQueries(array $queries): array
{
$captureBindings = config('trace-replay.track_db_query_bindings', false);

return array_map(function (array $query) use ($captureBindings): array {
if (! $captureBindings && array_key_exists('bindings', $query)) {
$query['bindings'] = array_map(
static fn () => '********',
(array) $query['bindings']
);
}

return $this->masker()->mask($query);
}, $queries);
}

protected function masker(): PayloadMasker
{
return $this->app->make(PayloadMasker::class);
}

protected function supportsWorkspaceColumn(): bool
{
if ($this->supportsWorkspaceColumn !== null) {
Expand Down
Loading
Loading