${content}
@@ -213,28 +541,133 @@ function renderAlignedJson(lines, originalJson) {
return '
Empty body
';
}
- const content =
- lines.map(line => {
-
- const className = line.state ? `diff-line diff-line-${line.state}` : '';
-
- const text = line.text ? escapeHtml(line.text) : ' ';
-
- return `
${text}
`;
- }).join('');
+ const content = lines.map(line => {
+ const className = line.state ? `diff-line diff-line-${line.state}` : '';
+ const text = line.text ? escapeHtml(line.text) : ' ';
+ return `
${text}
`;
+ }).join('');
return `
-
-
-
+
${content}
`;
}
+function compareHeaders(localHeaders = {}, remoteHeaders = {}, path, ignored) {
+ const keys = unionKeys(localHeaders, remoteHeaders).filter(isUsefulHeader).sort((a, b) => a.localeCompare(b));
+
+ return keys.map(key => {
+ const local = normalizeScalar(localHeaders[key], `${path}.${key}`, key);
+ const remote = normalizeScalar(remoteHeaders[key], `${path}.${key}`, key);
+ const changed = local.normalized !== remote.normalized;
+
+ if (changed && local.dynamic && remote.dynamic) {
+ ignored.push({ path: `${path}.${key}`, local: localHeaders[key], remote: remoteHeaders[key] });
+ return row(key, localHeaders[key], remoteHeaders[key], 'Ignored dynamic', true);
+ }
+
+ return row(key, local.normalized, remote.normalized, 'Changed');
+ });
+}
+
+function normalizeBody(value, path, ignored, side, otherValue) {
+ if (!value || !value.trim()) {
+ return { normalized: '' };
+ }
+
+ try {
+ const current = expandJsonStrings(JSON.parse(value));
+ const other = otherValue && otherValue.trim() ? expandJsonStrings(JSON.parse(otherValue)) : null;
+ const normalized = normalizeJsonValue(current, path, other, ignored);
+ return { normalized: JSON.stringify(normalized, null, 2) };
+ } catch {
+ const current = normalizeScalar(value, path, path);
+ const other = normalizeScalar(otherValue, path, path);
+ if (value !== otherValue && current.normalized === other.normalized && current.dynamic) {
+ ignored.push({ path, local: side === 'local' ? value : otherValue, remote: side === 'remote' ? value : otherValue });
+ }
+ return { normalized: current.normalized };
+ }
+}
+
+function normalizeJsonValue(value, path, otherValue, ignored) {
+ if (Array.isArray(value)) {
+ return value.map((item, index) => normalizeJsonValue(item, `${path}[${index}]`, Array.isArray(otherValue) ? otherValue[index] : undefined, ignored));
+ }
+
+ if (value && typeof value === 'object') {
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [
+ key,
+ normalizeJsonValue(child, `${path}.${key}`, otherValue && typeof otherValue === 'object' ? otherValue[key] : undefined, ignored)
+ ]));
+ }
+
+ const key = lastPathSegment(path);
+ const current = normalizeScalar(value, path, key);
+ const other = normalizeScalar(otherValue, path, key);
+
+ if (value !== otherValue && current.normalized === other.normalized && current.dynamic) {
+ ignored.push({ path, local: value, remote: otherValue });
+ }
+
+ return current.normalized;
+}
+
+function normalizeScalar(value, path, key) {
+ if (value === undefined || value === null) {
+ return { normalized: value, dynamic: false };
+ }
+
+ const text = String(value);
+ const lowerKey = String(key || '').toLowerCase();
+
+ if (isSensitiveKey(lowerKey)) {
+ return { normalized: '[ignored dynamic value: sensitive]', dynamic: true };
+ }
+
+ if (isDynamicKey(lowerKey)) {
+ return { normalized: `[ignored dynamic value: ${dynamicReason(lowerKey)}]`, dynamic: true };
+ }
+
+ if (isDynamicValue(text)) {
+ return { normalized: '[ignored dynamic value]', dynamic: true };
+ }
+
+ return { normalized: value, dynamic: false };
+}
+
+function isSensitiveKey(key) {
+ return key.includes('authorization') || key.includes('cookie') || key.includes('token') || key.includes('secret') || key.includes('password') || key.includes('apikey') || key.includes('api-key');
+}
+
+function isDynamicKey(key) {
+ if (['id', 'guid', 'uuid', 'timestamp', 'createdat', 'updatedat', 'requestid', 'correlationid', 'traceid', 'spanid', 'nonce', 'signature'].includes(key)) return true;
+ return /(^|[_-])(id|guid|uuid|token|nonce|signature)$/.test(key) ||
+ /(id|guid|uuid|timestamp|requestid|correlationid|traceid|spanid)$/.test(key) ||
+ key.includes('date') ||
+ key.endsWith('time') ||
+ key.endsWith('at');
+}
+
+function dynamicReason(key) {
+ if (key.includes('date') || key.includes('time') || key.endsWith('at')) return 'timestamp';
+ if (key.includes('token') || key.includes('secret')) return 'token';
+ return 'id';
+}
+
+function isDynamicValue(value) {
+ const text = String(value).trim();
+ if (!text) return false;
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(text)) return true;
+ if (/^\d{4}-\d{2}-\d{2}(t|\s)\d{2}:\d{2}:\d{2}/i.test(text)) return true;
+ if (/^\d{13,}$/.test(text)) return true;
+ if (/^eyJ[a-z0-9_-]+\.[a-z0-9_-]+\.[a-z0-9_-]+$/i.test(text)) return true;
+ if (/^[a-f0-9]{24,}$/i.test(text)) return true;
+ return /^[a-z0-9_-]{32,}$/i.test(text) && /[0-9]/.test(text) && /[a-z]/i.test(text);
+}
+
function normalizeJsonPayload(value) {
if (!value || !value.trim()) {
return '';
@@ -250,18 +683,13 @@ function normalizeJsonPayload(value) {
function expandJsonStrings(value) {
if (typeof value === 'string') {
const trimmed = value.trim();
-
- if (
- (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
- (trimmed.startsWith('[') && trimmed.endsWith(']'))
- ) {
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try {
return expandJsonStrings(JSON.parse(trimmed));
} catch {
return value;
}
}
-
return value;
}
@@ -270,34 +698,165 @@ function expandJsonStrings(value) {
}
if (value && typeof value === 'object') {
- return Object.fromEntries(
- Object.entries(value).map(([key, child]) => [key, expandJsonStrings(child)])
- );
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, expandJsonStrings(child)]));
}
return value;
}
function formatCopyValue(json, lines) {
-
try {
-
return JSON.stringify(JSON.parse(json), null, 2);
+ } catch {
+ return lines.map(line => line.text).join('\n');
+ }
+}
+
+function row(field, local, remote, status, forceChanged = false) {
+ return {
+ field,
+ local: local ?? '',
+ remote: remote ?? '',
+ status,
+ changed: forceChanged || String(local ?? '') !== String(remote ?? '')
+ };
+}
+
+function isChangedRow(item) {
+ return item.changed || String(item.local ?? '') !== String(item.remote ?? '');
+}
+
+function countChangedRows(rows) {
+ return rows.filter(isChangedRow).length;
+}
+
+function countMeaningfulRows(rows) {
+ return rows.filter(item => isChangedRow(item) && item.status !== 'Ignored dynamic').length;
+}
+
+function uniqueIgnoredItems(items) {
+ const seen = new Set();
+ return items.filter(item => {
+ const key = `${item.path}|${item.local}|${item.remote}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+}
+
+function summaryItem(label, detail, status, severity = 'info') {
+ return { label, detail, status, severity };
+}
+
+function durationDelta(localMs, remoteMs) {
+ const local = Number(localMs || 0);
+ const remote = Number(remoteMs || 0);
+ const delta = remote - local;
+ return { delta, isSlower: delta >= 500 && remote >= local * 1.5 };
+}
+
+function durationLabel(localMs, remoteMs) {
+ const delta = durationDelta(localMs, remoteMs);
+ if (delta.isSlower) return 'Slower';
+ return Number(localMs || 0) === Number(remoteMs || 0) ? 'Same' : 'Changed';
+}
+
+function statusLabel(localStatus, remoteStatus) {
+ if (localStatus === remoteStatus) return 'Same';
+ if (!localStatus) return 'Missing locally';
+ if (!remoteStatus) return 'Missing remotely';
+ if (localStatus >= 500 || remoteStatus >= 500) return 'Failed';
+ return 'Changed';
+}
+
+function displayCall(call) {
+ return `${call.method || ''} ${normalizeUrlPath(call.url || '')}`.trim();
+}
+function normalizeUrlPath(value) {
+ if (!value) return '';
+
+ try {
+ const url = new URL(value, 'http://debugprobe.local');
+ const query = normalizeQuery(url.search);
+ const path = normalizePathSegments(url.pathname);
+ return `${path}${query ? `?${query}` : ''}`;
} catch {
+ return normalizeQueryInText(value);
+ }
+}
- return lines.map(line => line.text).join('\n');
+function normalizeUrlHost(value) {
+ try {
+ return new URL(value, 'http://debugprobe.local').host;
+ } catch {
+ return '';
}
}
-function getChangedCount(rows) {
+function normalizePathSegments(path) {
+ return String(path || '')
+ .split('/')
+ .map(segment => isDynamicValue(segment) ? '[ignored-dynamic]' : segment)
+ .join('/');
+}
- return rows.filter(x => x.local !== x.remote).length;
+function normalizeQuery(value) {
+ const query = String(value || '').replace(/^\?/, '');
+ if (!query) return '';
+
+ const params = new URLSearchParams(query);
+ return [...params.entries()]
+ .filter(([key]) => !isDynamicKey(key.toLowerCase()) && !isSensitiveKey(key.toLowerCase()))
+ .map(([key, val]) => [key, normalizeScalar(val, key, key).normalized])
+ .sort((a, b) => a[0].localeCompare(b[0]))
+ .map(([key, val]) => `${key}=${val}`)
+ .join('&');
}
-function escapeHtml(value) {
+function normalizeQueryInText(value) {
+ const parts = String(value).split('?');
+ return parts.length === 1 ? value : `${parts[0]}?${normalizeQuery(parts.slice(1).join('?'))}`;
+}
+
+function isUsefulHeader(key) {
+ const lower = String(key || '').toLowerCase();
+ return !['date', 'server', 'x-powered-by', 'content-length'].includes(lower);
+}
+
+function shortException(value) {
+ if (!value) return '';
+ return String(value).split('\n')[0].slice(0, 180);
+}
+
+function lastPathSegment(path) {
+ const normalized = String(path || '').replace(/\[[0-9]+\]/g, '');
+ const parts = normalized.split('.');
+ return parts[parts.length - 1] || normalized;
+}
+
+function formatBytes(value) {
+ const bytes = Number(value || 0);
+ if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${bytes} B`;
+}
+
+function formatTime(value) {
+ if (!value) return '';
+ try {
+ return new Date(value).toLocaleString();
+ } catch {
+ return value;
+ }
+}
- return String(value)
+function unionKeys(localObject = {}, remoteObject = {}) {
+ return [...new Set([...Object.keys(localObject || {}), ...Object.keys(remoteObject || {})])];
+}
+
+function escapeHtml(value) {
+ return String(value ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
diff --git a/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs b/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs
index 66fe2a8..16aa774 100644
--- a/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs
+++ b/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs
@@ -99,6 +99,19 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app)
}).ExcludeFromDescription();
+ webApp.MapGet("/compare", (string? baseUrl, string? traceId, string? localTraceId) =>
+ {
+ if (string.IsNullOrWhiteSpace(localTraceId))
+ {
+ return Results.BadRequest("Missing local trace id");
+ }
+
+ var html = HtmlRenderer.RenderComparePage(localTraceId, baseUrl ?? "", traceId ?? "");
+
+ return Results.Content(html, "text/html");
+
+ }).ExcludeFromDescription();
+
webApp.MapGet("/debug/compare/{id}", async (string id, string baseUrl, string remoteTraceId,
DebugEntryStore store,
DebugProbeOptions options) =>
@@ -155,6 +168,10 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app)
return Results.Ok(new
{
+ localTrace = localEntry,
+ remoteTrace = remoteEntry,
+ localEnvironment,
+ remoteEnvironment,
method = new { local = localEntry.Method, remote = remoteEntry.Method },
path = new { local = localEntry.Path, remote = remoteEntry.Path },
status = new { local = localEntry.StatusCode, remote = remoteEntry.StatusCode },
diff --git a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs
index 730674e..2d2c7fa 100644
--- a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs
+++ b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs
@@ -129,6 +129,31 @@ public static string RenderDetailsPage(DebugEntry x, DebugEnvironment e, string
return BuildLayout(content);
}
+ public static string RenderComparePage(string localTraceId, string baseUrl, string traceId)
+ {
+ var content = $@"
+
";
+
+ return BuildLayout(content);
+ }
+
private static string BuildOutgoingRequestCard(DebugOutgoingRequest request)
{
var classes = request.StatusCode >= 400 || !string.IsNullOrWhiteSpace(request.Exception)
diff --git a/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs b/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs
index af00c62..b261ebc 100644
--- a/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs
+++ b/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs
@@ -21,6 +21,7 @@ public class DebugProbeMiddleware
private static readonly string[] DefaultIgnorePaths =
[
"/debug",
+ "/compare",
"/swagger",
"/.well-known",
@@ -252,4 +253,4 @@ private static async Task
ReadAtMostAsync(Stream stream, int byteLimit)
return buffer.ToArray();
}
-}
\ No newline at end of file
+}