Skip to content
Open
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
62 changes: 53 additions & 9 deletions pdb/api/correlation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ export interface LogEntry {
ts: string;
method: string;
path: string;
url: string;
status: number;
ms: number;
reqHeaders: Record<string, string>;
reqBody: string | null;
resHeaders: Record<string, string>;
resBody: string | null;
clientIp: string;
Expand Down Expand Up @@ -146,7 +148,11 @@ export class FlowCorrelation {
const flow: PaymentFlow = {
id,
protocol,
resource: entry.path,
resource: entry.url,
requestMethod: entry.method,
requestUrl: entry.url,
requestHeaders: entry.reqHeaders,
requestBody: entry.reqBody,
status: "payment-required",
clientIp: entry.clientIp,
startedAt: now,
Expand All @@ -164,11 +170,14 @@ export class FlowCorrelation {
message: `402 Payment Required`,
detail:
protocol === "mpp"
? `www-authenticate: ${truncate(resHeader(entry, "www-authenticate"), 120)}`
: `x-payment-required: ${truncate(resHeader(entry, "x-payment-required"), 120)}`,
? formatHeaderDetail("www-authenticate", resHeader(entry, "www-authenticate"))
: formatHeaderDetail("x-payment-required", resHeader(entry, "x-payment-required")),
},
],
challengeHeaders: entry.resHeaders,
responseStatus: entry.status,
responseHeaders: entry.resHeaders,
responseBody: entry.resBody,
};

this.addFlow(flow);
Expand All @@ -183,7 +192,7 @@ export class FlowCorrelation {
if (!flow || flow.status !== "payment-required") {
// Path-only fallback: find most recent pending flow for this path
flow = [...this.flows].reverse().find(
(f) => f.resource === entry.path && f.status === "payment-required"
(f) => pathFromUrl(f.requestUrl) === entry.path && f.status === "payment-required"
) ?? null;
}

Expand All @@ -209,8 +218,12 @@ export class FlowCorrelation {

// Update flow
flow.paymentHeaders = entry.reqHeaders;
flow.responseStatus = entry.status;
flow.responseHeaders = entry.resHeaders;
flow.responseBody = entry.resBody;
if (!flow.requestBody && entry.reqBody) {
flow.requestBody = entry.reqBody;
}
flow.updatedAt = now;
flow.durationMs =
new Date(now).getTime() - new Date(flow.startedAt).getTime();
Expand All @@ -222,8 +235,8 @@ export class FlowCorrelation {
message: `Payment accepted`,
detail:
protocol === "mpp"
? `payment-receipt: ${truncate(resHeader(entry, "payment-receipt"), 120)}`
: `x-payment-response verified`,
? formatHeaderDetail("payment-receipt", resHeader(entry, "payment-receipt"))
: formatHeaderDetail("x-payment-response", "verified"),
});
flow.events.push({
ts: now,
Expand Down Expand Up @@ -289,7 +302,11 @@ export class FlowCorrelation {
const flow: PaymentFlow = {
id,
protocol,
resource: entry.path,
resource: entry.url,
requestMethod: entry.method,
requestUrl: entry.url,
requestHeaders: entry.reqHeaders,
requestBody: entry.reqBody,
status: "resource-delivered",
clientIp: entry.clientIp,
startedAt: now,
Expand All @@ -303,6 +320,7 @@ export class FlowCorrelation {
detail: "Payment flow completed (challenge not captured)",
},
],
responseStatus: entry.status,
responseHeaders: entry.resHeaders,
responseBody: entry.resBody,
};
Expand Down Expand Up @@ -335,9 +353,9 @@ export class FlowCorrelation {
this.flows.push(flow);
if (this.flows.length > MAX_FLOWS) {
const removed = this.flows.shift()!;
this.flowIndex.delete(flowKey(removed.clientIp, removed.resource));
this.flowIndex.delete(flowKey(removed.clientIp, pathFromUrl(removed.requestUrl)));
}
this.flowIndex.set(flowKey(flow.clientIp, flow.resource), flow);
this.flowIndex.set(flowKey(flow.clientIp, pathFromUrl(flow.requestUrl)), flow);
}

private emit(msg: SSEMessage): void {
Expand Down Expand Up @@ -415,3 +433,29 @@ function resHeader(entry: LogEntry, key: string): string {
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max) + "…" : s;
}

function formatHeaderDetail(name: string, value: string): string {
return `${name}: ${truncate(redactHeaderValue(name, value), 120)}`;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey @dvansari65!
In my mind, the whole point of this UI is to help debugging payments, specifically the information that are being redacted here.
What was the rationale motivating this move?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my mind, the whole point of this UI is to help debugging payments- yes

Copy link
Copy Markdown
Author

@dvansari65 dvansari65 May 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes to help debugging payments

Copy link
Copy Markdown
Author

@dvansari65 dvansari65 May 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Built a replay and debugging interface for captured HTTP flows, making it easier to inspect and troubleshoot requests. Added support for viewing raw requests and responses, replaying requests as curl or HTTPie commands, and automatically redacting sensitive payment-related headers for safer debugging.

}

function redactHeaderValue(name: string, value: string): string {
switch (name.toLowerCase()) {
case "authorization":
case "www-authenticate":
case "payment-receipt":
case "x-payment":
case "x-payment-response":
case "x-payment-required":
return "[REDACTED]";
default:
return value;
}
}

function pathFromUrl(url: string): string {
try {
return new URL(url).pathname;
} catch {
return url;
}
}
21 changes: 21 additions & 0 deletions pdb/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ async function createApp() {
if (req.path === "/" || req.path.startsWith("/__402/pdb"))
return next();
const start = Date.now();
const requestUrl = `${req.protocol}://${req.get("host") || "localhost"}${req.originalUrl}`;

const chunks: Buffer[] = [];
let writeHeadHeaders: Record<string, string> = {};
Expand Down Expand Up @@ -152,6 +153,7 @@ async function createApp() {
for (const [k, v] of Object.entries(req.headers)) {
if (typeof v === "string") reqHeaders[k] = v;
}
const reqBody = serializeRequestBody(req.method, req.body);
const resHeaders: Record<string, string> = { ...writeHeadHeaders };
for (const [k, v] of Object.entries(res.getHeaders())) {
if (v != null) resHeaders[k] = String(v);
Expand All @@ -171,9 +173,11 @@ async function createApp() {
ts: new Date().toISOString(),
method: req.method,
path: req.path,
url: requestUrl,
status: res.statusCode,
ms: Date.now() - start,
reqHeaders,
reqBody,
resHeaders,
resBody,
clientIp,
Expand Down Expand Up @@ -568,6 +572,23 @@ function toWebRequest(req: express.Request): globalThis.Request {
});
}

function serializeRequestBody(method: string, body: unknown): string | null {
if (method === "GET" || method === "HEAD" || body == null) {
return null;
}

if (typeof body === "string") {
return body.length > 4096 ? `${body.slice(0, 4096)}…` : body;
}

try {
const serialized = JSON.stringify(body);
return serialized.length > 4096 ? `${serialized.slice(0, 4096)}…` : serialized;
} catch {
return null;
}
}

// ── Local dev server ──
const isVercel = !!process.env.VERCEL;
if (!isVercel) {
Expand Down
5 changes: 5 additions & 0 deletions pdb/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export interface PaymentFlow {
id: string; // "flow-1", "flow-2", …
protocol: Protocol;
resource: string; // URL path, e.g. "/mpp/quote/GOOG"
requestMethod: string;
requestUrl: string;
requestHeaders?: Record<string, string>;
requestBody?: string | null;
status: FlowStatus;
clientIp: string;
startedAt: string; // ISO
Expand All @@ -45,6 +49,7 @@ export interface PaymentFlow {
// Raw data for detail inspection
challengeHeaders?: Record<string, string>;
paymentHeaders?: Record<string, string>;
responseStatus?: number;
responseHeaders?: Record<string, string>;
responseBody?: string | null;
}
Expand Down
129 changes: 125 additions & 4 deletions pdb/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -369,14 +369,18 @@ body {
background: var(--bg-raised);
display: grid;
grid-template-columns: 220px 1fr 1fr;
height: clamp(220px, 48vh, 500px);
min-height: 220px;
max-height: 500px;
overflow: hidden;
animation: slideDown 0.15s ease;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

.flow-detail > * {
min-height: 0;
}

/* ── Sequence Diagram ── */

.sequence-diagram {
Expand Down Expand Up @@ -454,10 +458,16 @@ body {
/* ── Detail Panel (tabs + content) ── */

.detail-panel {
display: flex;
flex-direction: column;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
min-height: 0;
border-right: 1px solid var(--border);
}

.detail-content {
min-height: 0;
overflow: hidden;
}

.detail-tabs {
Expand Down Expand Up @@ -492,14 +502,125 @@ body {
border-bottom-color: var(--accent);
}

.replay-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
min-height: 0;
}

.replay-card {
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
}

.replay-header h3,
.raw-http-header h3 {
font-size: 11px;
font-weight: 600;
color: var(--fg-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}

.replay-header p,
.raw-http-header span,
.replay-note {
color: var(--fg-muted);
font-size: 12px;
line-height: 1.5;
}

.replay-actions {
display: flex;
flex-wrap: nowrap;
gap: 8px;
margin-top: 14px;
min-width: 0;
}

.replay-button {
background: var(--bg);
border: 1px solid var(--border);
color: var(--fg);
font-family: var(--font);
font-size: 12px;
font-weight: 500;
padding: 8px 12px;
border-radius: var(--radius);
cursor: pointer;
transition: all 0.15s ease;
flex: 1 1 0;
min-width: 0;
white-space: nowrap;
}

.replay-button:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}

.replay-button:disabled {
opacity: 0.55;
cursor: not-allowed;
}

.replay-button.copied {
border-color: var(--green);
color: var(--green);
}

.replay-note {
margin-top: 10px;
}

.replay-panel .splits {
border-right: none;
padding-top: 14px;
}

.raw-http-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
height: 100%;
padding: 16px 20px;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
overscroll-behavior: contain;
}

.raw-http-header {
flex-shrink: 0;
margin-bottom: 12px;
}

.raw-http-pre {
margin: 0;
padding: 12px 14px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg);
color: var(--fg);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
min-height: 0;
overflow-x: auto;
overflow-y: visible;
white-space: pre-wrap;
word-break: break-word;
}

/* ── Payment Splits ── */

.splits {
padding: 16px 20px;
overflow-y: auto;
min-height: 0;
flex: 1;
border-right: 1px solid var(--border);
}

.splits h3 {
Expand Down
Loading