Skip to content
Closed
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
13 changes: 1 addition & 12 deletions ui/app/workspace/logs/sheets/logDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { useGetUserAgentMappingsQuery } from "@/lib/store";
import { cn } from "@/lib/utils";
import { downloadAsJson } from "@/lib/utils/browser-download";
import { formatCompactNumber } from "@/lib/utils/numbers";
import { applyRedactionMapping, hasRedactionMappingEntries } from "@/lib/utils/redaction";
import { isJson } from "@/lib/utils/validation";
import { Link } from "@tanstack/react-router";
import { addMilliseconds, format } from "date-fns";
Expand Down Expand Up @@ -82,18 +83,6 @@ const getRealtimeTransportBadgeClass = (value: unknown): string => {
}
};

const hasRedactionMappingEntries = (mapping?: LogEntry["redaction_mapping"]): boolean =>
Boolean(mapping && (Object.keys(mapping.input ?? {}).length > 0 || Object.keys(mapping.output ?? {}).length > 0));

const applyRedactionMapping = (text: string | undefined, mapping?: Record<string, string>): string => {
if (!text || !mapping) return text || "";
let result = text;
for (const [key, value] of Object.entries(mapping)) {
result = result.replaceAll(`[${key}]`, value);
}
return result;
};

const formatRealtimeSource = (value: unknown): string => {
const source = String(value ?? "").trim();
switch (source.toLowerCase()) {
Expand Down
2 changes: 2 additions & 0 deletions ui/app/workspace/mcp-logs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export default function MCPLogsPage() {
const [showEmptyState, setShowEmptyState] = useState(false);
const hasCheckedEmptyState = useRef(false);
const hasDeleteAccess = useRbac(RbacResource.MCPLogs, RbacOperation.Delete);
const hasRevealAccess = useRbac(RbacResource.Logs, RbacOperation.Reveal);

const [deleteLogs] = useDeleteMCPLogsMutation();
// Lazy query kept only for handleLogNavigate (fetches adjacent pages on demand)
Expand Down Expand Up @@ -524,6 +525,7 @@ export default function MCPLogsPage() {
open={selectedLogId !== null}
onOpenChange={(open) => !open && setUrlState({ selected_log: "" }, { history: "replace" })}
handleDelete={hasDeleteAccess ? handleDelete : undefined}
canReveal={hasRevealAccess}
onNavigate={handleLogNavigate}
hasPrev={selectedLogIndex > 0 || (selectedLogIndex !== -1 && pagination.offset > 0)}
hasNext={selectedLogIndex !== -1 && (selectedLogIndex < logs.length - 1 || pagination.offset + pagination.limit < totalItems)}
Expand Down
56 changes: 42 additions & 14 deletions ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,26 @@ import {
} from "@/components/ui/dropdownMenu";
import { DottedSeparator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Status, StatusColors, Statuses } from "@/lib/constants/logs";
import { useGetMCPLogByIdQuery } from "@/lib/store";
import type { MCPToolLogEntry } from "@/lib/types/logs";
import { downloadAsJson } from "@/lib/utils/browser-download";
import { applyRedactionMappingToValue, hasRedactionMappingEntries, mergeRedactionMappings } from "@/lib/utils/redaction";
import { Link } from "@tanstack/react-router";
import { addMilliseconds, format, isValid } from "date-fns";
import { SheetNavigationButtons } from "@/components/sheetNavigationButtons";
import { useSheetNavigation } from "@/hooks/useSheetNavigation";
import { Download, Loader2, MoreVertical, Trash2 } from "lucide-react";
import { useState, type ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { toast } from "sonner";

interface MCPLogDetailSheetProps {
log: MCPToolLogEntry | null;
open: boolean;
onOpenChange: (open: boolean) => void;
handleDelete?: (log: MCPToolLogEntry) => Promise<void>;
canReveal?: boolean;
onNavigate?: (direction: "prev" | "next") => void;
hasPrev?: boolean;
hasNext?: boolean;
Expand Down Expand Up @@ -73,12 +76,14 @@ export function MCPLogDetailSheet({
open,
onOpenChange,
handleDelete,
canReveal = false,
onNavigate,
hasPrev = false,
hasNext = false,
}: MCPLogDetailSheetProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [showRevealedValues, setShowRevealedValues] = useState(false);
const {
data: fullLog,
isLoading,
Expand All @@ -95,10 +100,20 @@ export function MCPLogDetailSheet({
onNavigate: (direction) => onNavigate?.(direction),
});

if (!log) return null;
const isFullDataReady = Boolean(log) && (isError || (fullLog?.id === log?.id && !isLoading));
const displayLog = log ? (isFullDataReady && fullLog ? fullLog : log) : null;
const revealMapping = displayLog?.redaction_mapping;
const revealAvailable = canReveal && hasRedactionMappingEntries(revealMapping);
const revealEnabled = revealAvailable && showRevealedValues;
const inputRevealMapping = revealEnabled ? revealMapping?.input : undefined;
const outputRevealMapping = revealEnabled ? revealMapping?.output : undefined;
const mixedRevealMapping = revealEnabled ? mergeRedactionMappings(revealMapping) : undefined;

const isFullDataReady = isError || (fullLog?.id === log.id && !isLoading);
const displayLog = isFullDataReady && fullLog ? fullLog : log;
useEffect(() => {
setShowRevealedValues(false);
}, [displayLog?.id, revealAvailable]);

if (!log || !displayLog) return null;

if (!isFullDataReady) {
return (
Expand All @@ -113,6 +128,10 @@ export function MCPLogDetailSheet({
);
}

const displayedArguments = applyRedactionMappingToValue(displayLog.arguments, inputRevealMapping);
const displayedResult = applyRedactionMappingToValue(displayLog.result, outputRevealMapping);
const displayedErrorDetails = applyRedactionMappingToValue(displayLog.error_details, mixedRevealMapping);

return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-4 overflow-x-hidden p-8 sm:max-w-[60%]">
Expand All @@ -133,6 +152,19 @@ export function MCPLogDetailSheet({
nextKeys={nextKeys}
entityLabel="log"
/>
{revealAvailable && (
<div className="flex items-center gap-2 whitespace-nowrap">
<label htmlFor="mcplogdetails-reveal-toggle" className="text-muted-foreground text-[11px] font-medium">
Show original values
</label>
<Switch
id="mcplogdetails-reveal-toggle"
checked={revealEnabled}
onCheckedChange={(checked) => setShowRevealedValues(checked && revealAvailable)}
data-testid="mcplogdetails-reveal-toggle"
/>
</div>
)}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
Expand Down Expand Up @@ -301,19 +333,15 @@ export function MCPLogDetailSheet({
</div>

{/* Arguments */}
{displayLog.arguments && (
{displayedArguments && (
<div className="w-full rounded-sm border">
<div className="border-b px-6 py-2 text-sm font-medium">Arguments</div>
<CodeEditor
className="z-0 w-full"
shouldAdjustInitialHeight={true}
maxHeight={250}
wrap={true}
code={
typeof displayLog.arguments === "string"
? displayLog.arguments
: JSON.stringify(displayLog.arguments as Record<string, unknown>, null, 2)
}
code={typeof displayedArguments === "string" ? displayedArguments : JSON.stringify(displayedArguments, null, 2)}
lang="json"
readonly={true}
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
Expand All @@ -322,15 +350,15 @@ export function MCPLogDetailSheet({
)}

{/* Result */}
{displayLog.result && displayLog.status !== "processing" && (
{displayedResult && displayLog.status !== "processing" && (
<div className="w-full rounded-sm border">
<div className="border-b px-6 py-2 text-sm font-medium">Result</div>
<CodeEditor
className="z-0 w-full"
shouldAdjustInitialHeight={true}
maxHeight={350}
wrap={true}
code={typeof displayLog.result === "string" ? displayLog.result : JSON.stringify(displayLog.result, null, 2)}
code={typeof displayedResult === "string" ? displayedResult : JSON.stringify(displayedResult, null, 2)}
lang="json"
readonly={true}
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
Expand All @@ -351,15 +379,15 @@ export function MCPLogDetailSheet({
)}

{/* Error Details */}
{displayLog.error_details && (
{displayedErrorDetails && (
<div className="border-destructive/50 w-full rounded-sm border">
<div className="border-destructive/50 text-destructive border-b px-6 py-2 text-sm font-medium">Error Details</div>
<CodeEditor
className="z-0 w-full"
shouldAdjustInitialHeight={true}
maxHeight={250}
wrap={true}
code={JSON.stringify(displayLog.error_details, null, 2)}
code={JSON.stringify(displayedErrorDetails, null, 2)}
lang="json"
readonly={true}
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
Expand Down
11 changes: 7 additions & 4 deletions ui/lib/types/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,11 @@ export interface KeyAttemptRecord {
fail_reason?: string | null; // null/undefined on the final (successful or last) attempt
}

export interface RedactionMapping {
input?: Record<string, string>;
output?: Record<string, string>;
}

export interface LogEntry {
id: string;
object: string; // text.completion, chat.completion, embedding, audio.speech, audio.transcription
Expand Down Expand Up @@ -586,10 +591,7 @@ export interface LogEntry {
passthrough_request_body?: string; // Raw passthrough request body (UTF-8)
passthrough_response_body?: string; // Raw passthrough response body (UTF-8)
metadata?: Record<string, string>; // JSON metadata (e.g., isAsyncRequest)
redaction_mapping?: {
input?: Record<string, string>;
output?: Record<string, string>;
}; // Phase-scoped placeholder-to-original mappings, present only when caller has Logs:Reveal
redaction_mapping?: RedactionMapping; // Phase-scoped placeholder-to-original mappings, present only when caller has Logs:Reveal
user_agent?: string; // Raw HTTP User-Agent of the calling client
app?: string; // Backend-detected client app
}
Expand Down Expand Up @@ -1141,6 +1143,7 @@ export interface MCPToolLogEntry {
cost?: number; // Cost in dollars (per execution cost)
status: string; // "processing", "success", or "error"
metadata?: Record<string, string>;
redaction_mapping?: RedactionMapping; // Present on detail responses only when the caller has Logs:Reveal
created_at: string; // ISO string format
virtual_key?: VirtualKey;
user_agent?: string; // Raw HTTP User-Agent of the calling client
Expand Down
39 changes: 39 additions & 0 deletions ui/lib/utils/redaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { applyRedactionMapping, applyRedactionMappingToValue, hasRedactionMappingEntries, mergeRedactionMappings } from "./redaction";

describe("redaction reveal helpers", () => {
it("requires at least one phase mapping", () => {
expect(hasRedactionMappingEntries()).toBe(false);
expect(hasRedactionMappingEntries({ input: {}, output: {} })).toBe(false);
expect(hasRedactionMappingEntries({ input: { "EMAIL-1": "private@example.com" } })).toBe(true);
});

it("reveals placeholders without mutating structured log data", () => {
const source = { owner: "[EMAIL-1]", nested: ["hello [NAME-1]"] };
const revealed = applyRedactionMappingToValue(source, {
"EMAIL-1": "private@example.com",
"NAME-1": "Madhu",
});

expect(revealed).toEqual({ owner: "private@example.com", nested: ["hello Madhu"] });
expect(source).toEqual({ owner: "[EMAIL-1]", nested: ["hello [NAME-1]"] });
});

it("reveals each source placeholder once without reprocessing replacement text", () => {
expect(applyRedactionMapping("[A] [B]", { A: "[B]", B: "$&" })).toBe("[B] $&");
});

it("leaves conflicting phase placeholders redacted in mixed fields", () => {
const merged = mergeRedactionMappings({
input: { "SECRET-1": "input", "INPUT-ONLY": "request" },
output: { "SECRET-1": "output", "OUTPUT-ONLY": "response" },
});

expect(applyRedactionMapping("[SECRET-1] [INPUT-ONLY] [OUTPUT-ONLY]", merged)).toBe("[SECRET-1] request response");
});

it("keeps identical phase mappings revealable", () => {
const merged = mergeRedactionMappings({ input: { "SECRET-1": "same" }, output: { "SECRET-1": "same" } });
expect(applyRedactionMapping("[SECRET-1]", merged)).toBe("same");
});
});
41 changes: 41 additions & 0 deletions ui/lib/utils/redaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { RedactionMapping } from "@/lib/types/logs";

// hasRedactionMappingEntries reports whether a detail response contains anything the UI can reveal.
export function hasRedactionMappingEntries(mapping?: RedactionMapping): boolean {
return Boolean(mapping && (Object.keys(mapping.input ?? {}).length > 0 || Object.keys(mapping.output ?? {}).length > 0));
}

// applyRedactionMapping replaces reversible placeholders in display text without mutating source data.
export function applyRedactionMapping(text: string | undefined, mapping?: Record<string, string>): string {
if (!text || !mapping) return text || "";
return text.replace(/\[([^\]]+)\]/g, (placeholder, key: string) =>
Object.prototype.hasOwnProperty.call(mapping, key) ? mapping[key] : placeholder,
);
}

// mergeRedactionMappings combines phase maps for fields, such as errors, that can contain input and output content.
export function mergeRedactionMappings(mapping?: RedactionMapping): Record<string, string> | undefined {
if (!mapping) return undefined;
const merged = { ...mapping.input };
for (const [key, value] of Object.entries(mapping.output ?? {})) {
if (Object.prototype.hasOwnProperty.call(merged, key) && merged[key] !== value) {
delete merged[key];
continue;
}
merged[key] = value;
}
return Object.keys(merged).length > 0 ? merged : undefined;
}

// applyRedactionMappingToValue recursively reveals JSON-like display values while preserving the fetched object.
export function applyRedactionMappingToValue<T>(value: T, mapping?: Record<string, string>): T {
if (!mapping || value == null) return value;
if (typeof value === "string") return applyRedactionMapping(value, mapping) as T;
if (Array.isArray(value)) return value.map((item) => applyRedactionMappingToValue(item, mapping)) as T;
if (typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [applyRedactionMapping(key, mapping), applyRedactionMappingToValue(item, mapping)]),
) as T;
}
return value;
}
Loading