[Feat] AI Gateway - Add Tracing for MCP Calls running through AI Gateway (#21018)

* commit new expansion

* fix MCP

* fix: LiteLLMProxyRequestSetup

* _process_mcp_tools_without_openai_transform

* UI fixes

* UI refactor view logs/sessions

* index

* _add_mcp_tool_metadata_to_final_chunk

* add badges

* add getEventDisplayName

* ui fixes

* backend fix

* fix

* UI fix

* UI fix

* fix row

* fix: address Greptile review feedback on PR #21018 (#21057)

- Fix session time range calculation: use Math.min/Math.max across all
  entries instead of relying on array order (sessionLogs is sorted by
  type, not time).

Other Greptile comments were already addressed in the branch:
- LogDetailContent.tsx exists
- Clipboard call already wrapped in try/catch
- Dedup already uses O(1) Map lookup
- model_dump() serialization is documented
- GROUP BY performance comment already present

---------

Co-authored-by: shin-bot-litellm <shin-bot-litellm@berri.ai>
This commit is contained in:
Ishaan Jaff
2026-02-12 10:02:03 -08:00
committed by GitHub
co-authored by shin-bot-litellm
parent 3d9b145b04
commit 89565c97cc
15 changed files with 1075 additions and 768 deletions
@@ -41,6 +41,9 @@ from litellm.proxy._experimental.mcp_server.utils import (
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
from litellm.proxy.litellm_pre_call_utils import (
LiteLLMProxyRequestSetup,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPInfo, MCPServer
from litellm.types.utils import CallTypes, StandardLoggingMCPToolCall
@@ -842,6 +845,7 @@ if MCP_AVAILABLE:
raw_headers: Optional[Dict[str, str]] = None,
log_list_tools_to_spendlogs: bool = False,
list_tools_log_source: Optional[str] = None,
litellm_trace_id: Optional[str] = None,
) -> List[MCPTool]:
"""
Helper method to fetch tools from MCP servers based on server filtering criteria.
@@ -879,6 +883,7 @@ if MCP_AVAILABLE:
"model": "MCP: list_tools",
"call_type": CallTypes.list_mcp_tools.value,
"litellm_call_id": list_tools_call_id,
"litellm_trace_id": litellm_trace_id,
"metadata": {
"spend_logs_metadata": spend_logs_metadata,
},
@@ -894,13 +899,14 @@ if MCP_AVAILABLE:
],
}
# Attach user identifiers when available (matches call_mcp_tool style)
# Attach user identifiers using the standard helper
if user_api_key_auth is not None:
user_api_key = getattr(user_api_key_auth, "api_key", None)
if user_api_key:
cast(dict, list_tools_request_data["metadata"])[
"user_api_key"
] = user_api_key
LiteLLMProxyRequestSetup.add_user_api_key_auth_to_request_metadata(
data=list_tools_request_data,
user_api_key_dict=user_api_key_auth,
_metadata_variable_name="metadata",
)
user_identifier = getattr(
user_api_key_auth, "end_user_id", None
@@ -1878,13 +1878,15 @@ async def ui_view_spend_logs( # noqa: PLR0915
verbose_proxy_logger.debug("data= %s", json.dumps(data, indent=4, default=str))
return {
"data": data,
"total": total_records,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
}
return await _build_ui_spend_logs_response(
prisma_client,
data,
total_records,
page,
page_size,
total_pages,
enrich_session_counts=not is_v2,
)
except Exception as e:
verbose_proxy_logger.exception(f"Error in ui_view_spend_logs: {e}")
raise handle_exception_on_proxy(e)
@@ -3129,6 +3131,87 @@ async def ui_view_session_spend_logs(
)
async def _build_ui_spend_logs_response(
prisma_client: "PrismaClient",
data: list,
total_records: int,
page: int,
page_size: int,
total_pages: int,
enrich_session_counts: bool = True,
) -> dict:
"""
Build the paginated response for the UI spend-logs endpoint.
When ``enrich_session_counts`` is ``True`` (the default for the v1/UI
endpoint), each row is enriched with ``session_total_count`` so the
frontend knows which sessions are expandable (multi-call sessions).
For every row that carries a ``session_id``, a single ``GROUP BY`` query
fetches the total number of logs in each referenced session. Rows without
a ``session_id`` default to ``1``.
When ``enrich_session_counts`` is ``False`` (v2 endpoint), rows are
serialised without the extra query.
Args:
prisma_client: The connected Prisma client instance.
data: A list of Prisma model instances (must support ``.model_dump()``
and have a ``session_id`` attribute).
total_records: Total number of matching records (for pagination).
page: Current page number.
page_size: Number of items per page.
total_pages: Total number of pages.
enrich_session_counts: Whether to add ``session_total_count`` to each
row. Defaults to ``True``.
Returns:
A dict with ``data`` (enriched rows), ``total``, ``page``,
``page_size``, and ``total_pages``.
"""
count_map: dict[str, int] = {}
if enrich_session_counts:
session_ids = list(
{row.session_id for row in data if getattr(row, "session_id", None)}
)
if session_ids:
# NOTE: This GROUP BY runs on every v1/UI page load. The IN clause
# is bounded by page_size (typically 25-50 distinct session IDs).
# If performance degrades at scale, consider short-lived caching or
# folding the count into the main query via a window function.
counts = await prisma_client.db.litellm_spendlogs.group_by(
by=["session_id"],
where={"session_id": {"in": session_ids}},
count={"session_id": True},
)
count_map = {
r["session_id"]: r["_count"]["session_id"]
for r in counts
if r.get("session_id")
}
if enrich_session_counts:
enriched: List[dict] = []
for row in data:
row_dict = row.model_dump()
sid = row_dict.get("session_id")
row_dict["session_total_count"] = count_map.get(sid, 1) if sid else 1
enriched.append(row_dict)
response_data: list = enriched
else:
# v2 path: return raw Prisma model instances so FastAPI applies its
# own Pydantic-aware serialisation (preserves alias handling, custom
# serializers, etc.).
response_data = data # type: ignore[assignment]
return {
"data": response_data,
"total": total_records,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
}
def _build_status_filter_condition(status_filter: Optional[str]) -> Dict[str, Any]:
"""
Helper function to build the status filter condition for database queries.
+1
View File
@@ -181,6 +181,7 @@ async def aresponses_api_with_mcp(
) = await LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform(
user_api_key_auth=user_api_key_auth,
mcp_tools_with_litellm_proxy=mcp_tools_with_litellm_proxy,
litellm_trace_id=kwargs.get("litellm_trace_id"),
)
openai_tools = LiteLLM_Proxy_MCP_Handler._transform_mcp_tools_to_openai(
original_mcp_tools
@@ -127,6 +127,7 @@ async def acompletion_with_mcp( # noqa: PLR0915
) = await LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform(
user_api_key_auth=user_api_key_auth,
mcp_tools_with_litellm_proxy=mcp_tools_with_litellm_proxy,
litellm_trace_id=kwargs.get("litellm_trace_id"),
)
openai_tools = LiteLLM_Proxy_MCP_Handler._transform_mcp_tools_to_openai(
@@ -235,7 +236,10 @@ async def acompletion_with_mcp( # noqa: PLR0915
def _add_mcp_list_tools_to_chunk(self, chunk: ModelResponseStream) -> ModelResponseStream:
"""Add mcp_list_tools to the first chunk."""
from litellm.types.utils import StreamingChoices, add_provider_specific_fields
from litellm.types.utils import (
StreamingChoices,
add_provider_specific_fields,
)
if not self.openai_tools:
return chunk
@@ -258,7 +262,10 @@ async def acompletion_with_mcp( # noqa: PLR0915
def _add_mcp_tool_metadata_to_final_chunk(self, chunk: ModelResponseStream) -> ModelResponseStream:
"""Add mcp_tool_calls and mcp_call_results to the final chunk."""
from litellm.types.utils import StreamingChoices, add_provider_specific_fields
from litellm.types.utils import (
StreamingChoices,
add_provider_specific_fields,
)
if hasattr(chunk, "choices") and chunk.choices:
for choice in chunk.choices:
@@ -6,10 +6,10 @@ from typing import (
Dict,
Iterable,
List,
Literal,
Optional,
Tuple,
Union,
Literal,
)
from litellm._logging import verbose_logger
@@ -29,6 +29,7 @@ from litellm.utils import Rules, function_setup
if TYPE_CHECKING:
from mcp.types import Tool as MCPTool
from litellm.proxy.utils import ProxyLogging
else:
MCPTool = Any
@@ -97,6 +98,7 @@ class LiteLLM_Proxy_MCP_Handler:
async def _get_mcp_tools_from_manager(
user_api_key_auth: Any,
mcp_tools_with_litellm_proxy: Optional[Iterable[ToolParam]],
litellm_trace_id: Optional[str] = None,
) -> tuple[List[MCPTool], List[str]]:
"""
Get available tools from the MCP server manager.
@@ -109,13 +111,13 @@ class LiteLLM_Proxy_MCP_Handler:
List of MCP tools
List names of allowed MCP servers
"""
from litellm.proxy._experimental.mcp_server.server import (
_get_tools_from_mcp_servers,
_get_allowed_mcp_servers_from_mcp_server_names,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._experimental.mcp_server.server import (
_get_allowed_mcp_servers_from_mcp_server_names,
_get_tools_from_mcp_servers,
)
mcp_servers: List[str] = []
if mcp_tools_with_litellm_proxy:
@@ -136,6 +138,7 @@ class LiteLLM_Proxy_MCP_Handler:
mcp_server_auth_headers=None,
log_list_tools_to_spendlogs=True,
list_tools_log_source="responses",
litellm_trace_id=litellm_trace_id,
)
allowed_mcp_server_ids = (
await global_mcp_server_manager.get_allowed_mcp_servers(user_api_key_auth)
@@ -239,7 +242,9 @@ class LiteLLM_Proxy_MCP_Handler:
@staticmethod
async def _process_mcp_tools_to_openai_format(
user_api_key_auth: Any, mcp_tools_with_litellm_proxy: List[ToolParam]
user_api_key_auth: Any,
mcp_tools_with_litellm_proxy: List[ToolParam],
litellm_trace_id: Optional[str] = None,
) -> tuple[List[Any], dict[str, str]]:
"""
Centralized method to process MCP tools through the complete pipeline.
@@ -247,6 +252,7 @@ class LiteLLM_Proxy_MCP_Handler:
Args:
user_api_key_auth: User authentication info for access control
mcp_tools_with_litellm_proxy: ToolParam objects with server_url starting with "litellm_proxy"
litellm_trace_id: Optional trace ID for linking list_mcp_tools spend logs to parent request
Returns:
List of tools in OpenAI format ready to be sent to the LLM
@@ -258,6 +264,7 @@ class LiteLLM_Proxy_MCP_Handler:
) = await LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform(
user_api_key_auth,
mcp_tools_with_litellm_proxy,
litellm_trace_id=litellm_trace_id,
)
openai_tools = LiteLLM_Proxy_MCP_Handler._transform_mcp_tools_to_openai(
@@ -268,7 +275,9 @@ class LiteLLM_Proxy_MCP_Handler:
@staticmethod
async def _process_mcp_tools_without_openai_transform(
user_api_key_auth: Any, mcp_tools_with_litellm_proxy: List[ToolParam]
user_api_key_auth: Any,
mcp_tools_with_litellm_proxy: List[ToolParam],
litellm_trace_id: Optional[str] = None,
) -> tuple[List[Any], dict[str, str]]:
"""
Process MCP tools through filtering and deduplication pipeline without OpenAI transformation.
@@ -291,6 +300,7 @@ class LiteLLM_Proxy_MCP_Handler:
) = await LiteLLM_Proxy_MCP_Handler._get_mcp_tools_from_manager(
user_api_key_auth=user_api_key_auth,
mcp_tools_with_litellm_proxy=mcp_tools_with_litellm_proxy,
litellm_trace_id=litellm_trace_id,
)
# Step 2: Filter tools based on allowed_tools parameter
@@ -495,14 +505,13 @@ class LiteLLM_Proxy_MCP_Handler:
"""Execute tool calls and return results."""
from fastapi import HTTPException
from litellm._uuid import uuid
from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy.proxy_server import proxy_logging_obj
from litellm._uuid import uuid
tool_results = []
tool_call_id: Optional[str] = None
rules_obj = Rules()
@@ -1025,7 +1034,6 @@ class LiteLLM_Proxy_MCP_Handler:
List of MCP tool execution events for streaming
"""
from litellm._uuid import uuid
from litellm.responses.mcp.mcp_streaming_iterator import create_mcp_call_events
tool_execution_events: List[Any] = []
@@ -1108,8 +1116,8 @@ class LiteLLM_Proxy_MCP_Handler:
"""Add custom output elements to the final response for MCP tool execution."""
# Import the required classes for creating output items
import json
from litellm._uuid import uuid
from litellm._uuid import uuid
from litellm.types.responses.main import GenericResponseOutputItem, OutputText
# Create output element for initial MCP tools
@@ -0,0 +1,442 @@
import { useState } from "react";
import { Typography, Descriptions, Card, Tag, Tabs, Alert, Collapse, Radio, Space } from "antd";
import moment from "moment";
import { LogEntry } from "../columns";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import GuardrailViewer from "../GuardrailViewer/GuardrailViewer";
import { CostBreakdownViewer } from "../CostBreakdownViewer";
import { ConfigInfoMessage } from "../ConfigInfoMessage";
import { VectorStoreViewer } from "../VectorStoreViewer";
import { TruncatedValue } from "./TruncatedValue";
import { TokenFlow } from "./TokenFlow";
import { JsonViewer } from "./JsonViewer";
import {
formatData,
checkHasMessages,
checkHasResponse,
normalizeGuardrailEntries,
calculateTotalMaskedEntities,
getGuardrailLabel,
checkHasVectorStoreData,
} from "./utils";
import {
DRAWER_CONTENT_PADDING,
API_BASE_MAX_WIDTH,
METADATA_MAX_HEIGHT,
TAB_REQUEST,
TAB_RESPONSE,
FONT_SIZE_SMALL,
FONT_FAMILY_MONO,
SPACING_XLARGE,
SPACING_MEDIUM,
} from "./constants";
import { ToolsSection } from "../ToolsSection";
import { PrettyMessagesView } from "./PrettyMessagesView";
const { Text } = Typography;
export interface LogDetailContentProps {
logEntry: LogEntry;
onOpenSettings?: () => void;
}
/**
* The scrollable detail content for a single log entry.
* Renders request details, metrics, cost breakdown, request/response,
* guardrails, vector store data, and metadata.
*
* Designed to be placed inside LogDetailsDrawer's right panel so it can
* be reused for both single-log and session-mode views.
*/
export function LogDetailContent({ logEntry, onOpenSettings }: LogDetailContentProps) {
const metadata = logEntry.metadata || {};
const hasError = metadata.status === "failure";
const errorInfo = hasError ? metadata.error_information : null;
const hasMessages = checkHasMessages(logEntry.messages);
const hasResponse = checkHasResponse(logEntry.response);
const missingData = !hasMessages && !hasResponse && !hasError;
// Guardrail data
const guardrailInfo = metadata?.guardrail_information;
const guardrailEntries = normalizeGuardrailEntries(guardrailInfo);
const hasGuardrailData = guardrailEntries.length > 0;
const totalMaskedEntities = calculateTotalMaskedEntities(guardrailEntries);
const primaryGuardrailLabel = getGuardrailLabel(guardrailEntries);
// Vector store data
const hasVectorStoreData = checkHasVectorStoreData(metadata);
const getRawRequest = () => {
return formatData(logEntry.proxy_server_request || logEntry.messages);
};
const getFormattedResponse = () => {
if (hasError && errorInfo) {
return {
error: {
message: errorInfo.error_message || "An error occurred",
type: errorInfo.error_class || "error",
code: errorInfo.error_code || "unknown",
param: null,
},
};
}
return formatData(logEntry.response);
};
return (
<div style={{ padding: `${DRAWER_CONTENT_PADDING} ${DRAWER_CONTENT_PADDING} 0` }}>
{/* Error Alert */}
{hasError && errorInfo && (
<Alert
type="error"
showIcon
message="Request Failed"
description={<ErrorDescription errorInfo={errorInfo} />}
className="mb-6"
/>
)}
{/* Tags */}
{logEntry.request_tags && Object.keys(logEntry.request_tags).length > 0 && (
<TagsSection tags={logEntry.request_tags} />
)}
{/* Request Details */}
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
<Card title="Request Details" size="small" bordered={false} style={{ marginBottom: 0 }}>
<Descriptions column={2} size="small">
<Descriptions.Item label="Model">{logEntry.model}</Descriptions.Item>
<Descriptions.Item label="Provider">{logEntry.custom_llm_provider || "-"}</Descriptions.Item>
<Descriptions.Item label="Call Type">{logEntry.call_type}</Descriptions.Item>
<Descriptions.Item label="Model ID">
<TruncatedValue value={logEntry.model_id} />
</Descriptions.Item>
<Descriptions.Item label="API Base">
<TruncatedValue value={logEntry.api_base} maxWidth={API_BASE_MAX_WIDTH} />
</Descriptions.Item>
{logEntry.requester_ip_address && (
<Descriptions.Item label="IP Address">{logEntry.requester_ip_address}</Descriptions.Item>
)}
{hasGuardrailData && (
<Descriptions.Item label="Guardrail">
<GuardrailLabel label={primaryGuardrailLabel} maskedCount={totalMaskedEntities} />
</Descriptions.Item>
)}
</Descriptions>
</Card>
</div>
{/* Metrics */}
<MetricsSection logEntry={logEntry} metadata={metadata} />
{/* Cost Breakdown */}
<CostBreakdownViewer costBreakdown={metadata?.cost_breakdown} totalSpend={logEntry.spend || 0} />
{/* Tools */}
<ToolsSection log={logEntry} />
{/* Configuration Info Message */}
{missingData && (
<div className="mb-6">
<ConfigInfoMessage show={missingData} onOpenSettings={onOpenSettings} />
</div>
)}
{/* Request/Response JSON */}
<RequestResponseSection
hasResponse={hasResponse}
hasError={hasError}
getRawRequest={getRawRequest}
getFormattedResponse={getFormattedResponse}
logEntry={logEntry}
/>
{/* Guardrail Data */}
{hasGuardrailData && <GuardrailViewer data={guardrailInfo} />}
{/* Vector Store Data */}
{hasVectorStoreData && <VectorStoreViewer data={metadata.vector_store_request_metadata} />}
{/* Metadata */}
{logEntry.metadata && Object.keys(logEntry.metadata).length > 0 && (
<MetadataSection metadata={logEntry.metadata} />
)}
{/* Bottom spacing */}
<div style={{ height: DRAWER_CONTENT_PADDING }} />
</div>
);
}
// ============================================================================
// Helper Components
// ============================================================================
function ErrorDescription({ errorInfo }: { errorInfo: any }) {
return (
<div>
{errorInfo.error_code && (
<div>
<Text strong>Error Code:</Text> {errorInfo.error_code}
</div>
)}
{errorInfo.error_message && (
<div>
<Text strong>Message:</Text> {errorInfo.error_message}
</div>
)}
</div>
);
}
function TagsSection({ tags }: { tags: Record<string, any> }) {
return (
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden p-4 mb-6">
<Text strong style={{ display: "block", marginBottom: 8, fontSize: 16 }}>
Tags
</Text>
<Space size={SPACING_MEDIUM} wrap>
{Object.entries(tags).map(([key, value]) => (
<Tag key={key}>
{key}: {String(value)}
</Tag>
))}
</Space>
</div>
);
}
function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: number }) {
return (
<Space size={SPACING_MEDIUM}>
<span>{label}</span>
{maskedCount > 0 && (
<Tag color="blue">
{maskedCount} masked
</Tag>
)}
</Space>
);
}
function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata: Record<string, any> }) {
const hasCacheActivity =
logEntry.cache_hit ||
(metadata?.additional_usage_values?.cache_read_input_tokens &&
metadata.additional_usage_values.cache_read_input_tokens > 0);
return (
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
<Card title="Metrics" size="small" bordered={false} style={{ marginBottom: 0 }}>
<Descriptions column={2} size="small">
<Descriptions.Item label="Tokens">
<TokenFlow
prompt={logEntry.prompt_tokens}
completion={logEntry.completion_tokens}
total={logEntry.total_tokens}
/>
</Descriptions.Item>
<Descriptions.Item label="Cost">${formatNumberWithCommas(logEntry.spend || 0, 8)}</Descriptions.Item>
<Descriptions.Item label="Duration">{logEntry.duration?.toFixed(3)} s</Descriptions.Item>
{hasCacheActivity && (
<>
<Descriptions.Item label="Cache Hit">
<Tag color={logEntry.cache_hit ? "green" : "default"}>{logEntry.cache_hit || "None"}</Tag>
</Descriptions.Item>
{metadata?.additional_usage_values?.cache_read_input_tokens > 0 && (
<Descriptions.Item label="Cache Read Tokens">
{formatNumberWithCommas(metadata.additional_usage_values.cache_read_input_tokens)}
</Descriptions.Item>
)}
{metadata?.additional_usage_values?.cache_creation_input_tokens > 0 && (
<Descriptions.Item label="Cache Creation Tokens">
{formatNumberWithCommas(metadata.additional_usage_values.cache_creation_input_tokens)}
</Descriptions.Item>
)}
</>
)}
{metadata?.litellm_overhead_time_ms !== undefined && metadata.litellm_overhead_time_ms !== null && (
<Descriptions.Item label="LiteLLM Overhead">
{metadata.litellm_overhead_time_ms.toFixed(2)} ms
</Descriptions.Item>
)}
<Descriptions.Item label="Start Time">
{moment(logEntry.startTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
</Descriptions.Item>
<Descriptions.Item label="End Time">
{moment(logEntry.endTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
</Descriptions.Item>
</Descriptions>
</Card>
</div>
);
}
interface RequestResponseSectionProps {
hasResponse: boolean;
hasError: boolean;
getRawRequest: () => any;
getFormattedResponse: () => any;
logEntry: LogEntry;
}
function RequestResponseSection({
hasResponse,
hasError,
getRawRequest,
getFormattedResponse,
logEntry,
}: RequestResponseSectionProps) {
const [activeTab, setActiveTab] = useState<typeof TAB_REQUEST | typeof TAB_RESPONSE>(TAB_REQUEST);
const [viewMode, setViewMode] = useState<'pretty' | 'json'>('pretty');
const getCopyText = () => {
const data = activeTab === TAB_REQUEST ? getRawRequest() : getFormattedResponse();
return JSON.stringify(data, null, 2);
};
const totalSpend = logEntry.spend || 0;
const promptTokens = logEntry.prompt_tokens || 0;
const completionTokens = logEntry.completion_tokens || 0;
const totalTokens = promptTokens + completionTokens;
const inputCost = totalTokens > 0 ? (totalSpend * promptTokens) / totalTokens : 0;
const outputCost = totalTokens > 0 ? (totalSpend * completionTokens) / totalTokens : 0;
return (
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
<Collapse
defaultActiveKey={["1"]}
expandIconPosition="start"
items={[
{
key: "1",
label: (
<div
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.closest('.ant-radio-group')) {
e.stopPropagation();
}
}}
>
<h3 className="text-lg font-medium text-gray-900" style={{ margin: 0 }}>Request & Response</h3>
<Radio.Group
size="small"
value={viewMode}
onChange={(e) => setViewMode(e.target.value)}
>
<Radio.Button value="pretty">Pretty</Radio.Button>
<Radio.Button value="json">JSON</Radio.Button>
</Radio.Group>
</div>
),
children: (
<div>
{viewMode === 'pretty' ? (
<PrettyMessagesView
request={getRawRequest()}
response={getFormattedResponse()}
metrics={{
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
input_cost: inputCost,
output_cost: outputCost,
}}
/>
) : (
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)}
tabBarExtraContent={
<Text
copyable={{
text: getCopyText(),
tooltips: ["Copy JSON", "Copied!"]
}}
disabled={activeTab === TAB_RESPONSE && !hasResponse && !hasError}
/>
}
items={[
{
key: TAB_REQUEST,
label: "Request",
children: (
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
<JsonViewer data={getRawRequest()} mode="formatted" />
</div>
),
},
{
key: TAB_RESPONSE,
label: "Response",
children: (
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
{hasResponse || hasError ? (
<JsonViewer data={getFormattedResponse()} mode="formatted" />
) : (
<div style={{ textAlign: "center", padding: 20, color: "#999", fontStyle: "italic" }}>
Response data not available
</div>
)}
</div>
),
},
]}
/>
)}
</div>
),
},
]}
/>
</div>
);
}
function MetadataSection({ metadata }: { metadata: Record<string, any> }) {
return (
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
<Collapse
defaultActiveKey={["1"]}
expandIconPosition="start"
items={[
{
key: "1",
label: <h3 className="text-lg font-medium text-gray-900">Metadata</h3>,
children: (
<div>
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 8 }}>
<Text
copyable={{
text: JSON.stringify(metadata, null, 2),
tooltips: ["Copy Metadata", "Copied!"]
}}
/>
</div>
<pre
style={{
maxHeight: METADATA_MAX_HEIGHT,
overflowY: "auto",
fontSize: FONT_SIZE_SMALL,
fontFamily: FONT_FAMILY_MONO,
whiteSpace: "pre-wrap",
wordBreak: "break-all",
margin: 0,
}}
>
{JSON.stringify(metadata, null, 2)}
</pre>
</div>
),
},
]}
/>
</div>
);
}
@@ -1,52 +1,92 @@
import { useState } from "react";
import { Drawer, Typography, Descriptions, Card, Tag, Tabs, Alert, Collapse, Radio, Space } from "antd";
import moment from "moment";
import { useEffect, useMemo, useState } from "react";
import { Button, Drawer } from "antd";
import {
CheckOutlined,
CopyOutlined,
LeftOutlined,
RightOutlined,
} from "@ant-design/icons";
import { Sparkles, Wrench } from "lucide-react";
import { LogEntry } from "../columns";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import GuardrailViewer from "../GuardrailViewer/GuardrailViewer";
import { CostBreakdownViewer } from "../CostBreakdownViewer";
import { ConfigInfoMessage } from "../ConfigInfoMessage";
import { VectorStoreViewer } from "../VectorStoreViewer";
import { TruncatedValue } from "./TruncatedValue";
import { TokenFlow } from "./TokenFlow";
import { JsonViewer } from "./JsonViewer";
import { MCP_CALL_TYPES } from "../constants";
import { getEventDisplayName } from "../utils";
import { DrawerHeader } from "./DrawerHeader";
import { useKeyboardNavigation } from "./useKeyboardNavigation";
import {
formatData,
checkHasMessages,
checkHasResponse,
normalizeGuardrailEntries,
calculateTotalMaskedEntities,
getGuardrailLabel,
checkHasVectorStoreData,
} from "./utils";
import {
DRAWER_WIDTH,
DRAWER_CONTENT_PADDING,
API_BASE_MAX_WIDTH,
METADATA_MAX_HEIGHT,
TAB_REQUEST,
TAB_RESPONSE,
FONT_SIZE_SMALL,
FONT_FAMILY_MONO,
SPACING_XLARGE,
SPACING_MEDIUM,
} from "./constants";
import { ToolsSection } from "../ToolsSection";
import { PrettyMessagesView } from "./PrettyMessagesView";
const { Text } = Typography;
import { LogDetailContent } from "./LogDetailContent";
import { sessionSpendLogsCall } from "../../networking";
import { useQuery } from "@tanstack/react-query";
import { getSpendString } from "@/utils/dataUtils";
import { DRAWER_WIDTH } from "./constants";
export interface LogDetailsDrawerProps {
open: boolean;
onClose: () => void;
logEntry: LogEntry | null;
sessionId?: string | null;
accessToken?: string | null;
onOpenSettings?: () => void;
allLogs?: LogEntry[];
onSelectLog?: (log: LogEntry) => void;
}
const SIDEBAR_WIDTH_PX = 224;
/* ------------------------------------------------------------------ */
/* TraceEventRow — compact event row used in both session & non- */
/* session sidebar lists. Extracted to avoid JSX duplication. */
/* ------------------------------------------------------------------ */
interface TraceEventRowProps {
row: LogEntry;
isSelected: boolean;
onClick: () => void;
}
function TraceEventRow({ row, isSelected, onClick }: TraceEventRowProps) {
const isMcp = MCP_CALL_TYPES.includes(row.call_type);
const durationValue =
row.duration != null
? row.duration.toFixed(3)
: row.startTime && row.endTime
? ((Date.parse(row.endTime) - Date.parse(row.startTime)) / 1000).toFixed(3)
: "-";
return (
<button
type="button"
className={`w-full text-left pl-8 pr-2 py-1 transition-colors ${
isSelected ? "bg-blue-50" : "hover:bg-slate-100"
}`}
onClick={onClick}
>
<div className="flex items-center gap-1">
{isMcp ? (
<Wrench size={12} className="text-slate-500 flex-shrink-0" />
) : (
<Sparkles size={12} className="text-slate-500 flex-shrink-0" />
)}
<span className="text-xs font-medium text-slate-900 truncate">
{getEventDisplayName(row.call_type, row.model)}
</span>
</div>
<div className="text-[10px] text-slate-500 mt-0 flex items-center gap-1.5 font-mono">
<span>{durationValue}s</span>
{row.spend ? (
<>
<span>·</span>
<span>{getSpendString(row.spend)}</span>
</>
) : null}
{row.total_tokens ? (
<>
<span>·</span>
<span>{row.total_tokens} tok</span>
</>
) : null}
</div>
</button>
);
}
/**
* Right-side drawer panel for displaying detailed log information.
* Features:
@@ -61,64 +101,118 @@ export function LogDetailsDrawer({
open,
onClose,
logEntry,
sessionId,
accessToken,
onOpenSettings,
allLogs = [],
onSelectLog,
}: LogDetailsDrawerProps) {
const [activeTab, setActiveTab] = useState<typeof TAB_REQUEST | typeof TAB_RESPONSE>(TAB_REQUEST);
const isSessionMode = Boolean(sessionId);
const [selectedSessionRequestId, setSelectedSessionRequestId] = useState<string | null>(null);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [copiedLeftPanelId, setCopiedLeftPanelId] = useState(false);
const { data: sessionLogs = [] } = useQuery({
queryKey: ["sessionLogs", sessionId],
queryFn: async () => {
if (!sessionId || !accessToken) return [];
const response = await sessionSpendLogsCall(accessToken, sessionId);
const allSessionLogs: LogEntry[] = response.data || response || [];
return allSessionLogs
.map((row) => ({
...row,
duration: (Date.parse(row.endTime) - Date.parse(row.startTime)) / 1000,
}))
.sort((a, b) => {
const aIsMcp = MCP_CALL_TYPES.includes(a.call_type) ? 1 : 0;
const bIsMcp = MCP_CALL_TYPES.includes(b.call_type) ? 1 : 0;
if (aIsMcp !== bIsMcp) return aIsMcp - bIsMcp;
return new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
});
},
enabled: Boolean(open && isSessionMode && sessionId && accessToken),
});
const currentLog = useMemo(() => {
if (!isSessionMode) return logEntry;
if (!sessionLogs.length) return null;
if (selectedSessionRequestId) {
return sessionLogs.find((row) => row.request_id === selectedSessionRequestId) || sessionLogs[0];
}
if (logEntry?.request_id) {
const clickedLog = sessionLogs.find((row) => row.request_id === logEntry.request_id);
return clickedLog || sessionLogs[0];
}
return sessionLogs[0];
}, [isSessionMode, logEntry, selectedSessionRequestId, sessionLogs]);
useEffect(() => {
if (!isSessionMode || !sessionLogs.length) return;
if (!selectedSessionRequestId || !sessionLogs.some((row) => row.request_id === selectedSessionRequestId)) {
const fallbackRequestId = logEntry?.request_id && sessionLogs.some((row) => row.request_id === logEntry.request_id)
? logEntry.request_id
: sessionLogs[0].request_id;
setSelectedSessionRequestId(fallbackRequestId);
}
}, [isSessionMode, logEntry, selectedSessionRequestId, sessionLogs]);
// Reset transient UI state when the drawer opens or closes.
useEffect(() => {
if (open) {
setIsSidebarCollapsed(false);
} else {
if (isSessionMode) setSelectedSessionRequestId(null);
setCopiedLeftPanelId(false);
}
}, [open, isSessionMode]);
// Keyboard navigation
const { selectNextLog, selectPreviousLog } = useKeyboardNavigation({
isOpen: open,
currentLog: logEntry,
allLogs,
currentLog,
allLogs: isSessionMode ? sessionLogs : allLogs,
onClose,
onSelectLog,
onSelectLog: (selected) => {
if (isSessionMode) {
setSelectedSessionRequestId(selected.request_id);
}
onSelectLog?.(selected);
},
});
if (!logEntry) return null;
const metadata = logEntry.metadata || {};
const hasError = metadata.status === "failure";
const errorInfo = hasError ? metadata.error_information : null;
// Check if request/response data is present
const hasMessages = checkHasMessages(logEntry.messages);
const hasResponse = checkHasResponse(logEntry.response);
const missingData = !hasMessages && !hasResponse && !hasError;
// Guardrail data
const guardrailInfo = metadata?.guardrail_information;
const guardrailEntries = normalizeGuardrailEntries(guardrailInfo);
const hasGuardrailData = guardrailEntries.length > 0;
const totalMaskedEntities = calculateTotalMaskedEntities(guardrailEntries);
const primaryGuardrailLabel = getGuardrailLabel(guardrailEntries);
// Vector store data
const hasVectorStoreData = checkHasVectorStoreData(metadata);
const metadata = currentLog?.metadata || {};
// Status display values
const statusLabel = metadata.status === "failure" ? "Failure" : "Success";
const statusColor = metadata.status === "failure" ? ("error" as const) : ("success" as const);
const environment = metadata?.user_api_key_team_alias || "default";
const getRawRequest = () => {
return formatData(logEntry.proxy_server_request || logEntry.messages);
const totalSessionCost = sessionLogs.reduce((sum, row) => sum + (row.spend || 0), 0);
const sessionStart = sessionLogs.length > 0
? new Date(Math.min(...sessionLogs.map((r) => new Date(r.startTime).getTime())))
: null;
const sessionEnd = sessionLogs.length > 0
? new Date(Math.max(...sessionLogs.map((r) => new Date(r.endTime).getTime())))
: null;
const sessionDurationSeconds =
sessionStart && sessionEnd ? ((sessionEnd.getTime() - sessionStart.getTime()) / 1000).toFixed(2) : "0.00";
const llmCount = sessionLogs.filter((row) => !MCP_CALL_TYPES.includes(row.call_type)).length;
const mcpCount = sessionLogs.filter((row) => MCP_CALL_TYPES.includes(row.call_type)).length;
const logsForList = isSessionMode ? sessionLogs : currentLog ? [currentLog] : [];
const leftPanelId = isSessionMode ? sessionId || "" : currentLog?.request_id || "";
const leftPanelDisplayId =
leftPanelId.length > 14 ? `${leftPanelId.slice(0, 11)}...` : leftPanelId;
const handleCopyLeftPanelId = async () => {
if (!leftPanelId) return;
try {
await navigator.clipboard.writeText(leftPanelId);
setCopiedLeftPanelId(true);
setTimeout(() => setCopiedLeftPanelId(false), 1200);
} catch { /* clipboard unavailable in non-secure contexts */ }
};
const getFormattedResponse = () => {
if (hasError && errorInfo) {
return {
error: {
message: errorInfo.error_message || "An error occurred",
type: errorInfo.error_class || "error",
code: errorInfo.error_code || "unknown",
param: null,
},
};
}
return formatData(logEntry.response);
};
if (!currentLog) return null;
return (
<Drawer
@@ -135,376 +229,133 @@ export function LogDetailsDrawer({
header: { display: "none" },
}}
>
<DrawerHeader
log={logEntry}
onClose={onClose}
onPrevious={selectPreviousLog}
onNext={selectNextLog}
statusLabel={statusLabel}
statusColor={statusColor}
environment={environment}
/>
<div style={{ height: "100%" }} className="flex relative">
{!isSidebarCollapsed ? (
<Button
type="text"
size="small"
icon={<LeftOutlined />}
onClick={() => setIsSidebarCollapsed(true)}
className="absolute top-2 left-2 z-20 !bg-white !border !border-slate-200 !rounded-md"
aria-label="Collapse trace sidebar"
/>
) : (
<Button
type="text"
size="small"
icon={<RightOutlined />}
onClick={() => setIsSidebarCollapsed(false)}
className="absolute top-2 left-2 z-20 !bg-white !border !border-slate-200 !rounded-md"
aria-label="Expand trace sidebar"
/>
)}
{!isSidebarCollapsed && (
<div
className="border-r border-slate-200 bg-slate-50 flex flex-col"
style={{ width: SIDEBAR_WIDTH_PX }}
>
<div className="pl-12 pr-3 py-2 border-b border-slate-200 bg-white">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-[10px] uppercase tracking-wide text-slate-500">
{isSessionMode ? "Session" : "Trace"}
</div>
<div className="font-mono text-[12px] text-slate-900 leading-tight flex items-center gap-1">
<span className="truncate">{leftPanelDisplayId}</span>
<button
type="button"
onClick={handleCopyLeftPanelId}
className="text-slate-400 hover:text-slate-600"
aria-label="Copy trace id"
>
{copiedLeftPanelId ? (
<CheckOutlined className="text-[11px]" />
) : (
<CopyOutlined className="text-[11px]" />
)}
</button>
</div>
</div>
</div>
<div className="mt-1 text-[11px] text-slate-500 font-mono">
{logsForList.length} req
<span className="mx-1.5">·</span>
{isSessionMode
? `${llmCount} LLM`
: `${logsForList.filter((row) => !MCP_CALL_TYPES.includes(row.call_type)).length} LLM`}
<span className="mx-1.5">·</span>
{isSessionMode
? `${mcpCount} MCP`
: `${logsForList.filter((row) => MCP_CALL_TYPES.includes(row.call_type)).length} MCP`}
<span className="mx-1.5">·</span>
{isSessionMode
? getSpendString(totalSessionCost)
: getSpendString(currentLog.spend || 0)}
{isSessionMode && (
<>
<span className="mx-1.5">·</span>
{sessionDurationSeconds}s
</>
)}
</div>
</div>
<div style={{ height: "calc(100vh - 100px)", overflowY: "auto", padding: `${DRAWER_CONTENT_PADDING} ${DRAWER_CONTENT_PADDING} 0` }}>
{/* Error Alert - Show prominently at top for failures */}
{hasError && errorInfo && (
<Alert
type="error"
showIcon
message="Request Failed"
description={<ErrorDescription errorInfo={errorInfo} />}
className="mb-6"
/>
)}
{/* Tags - Only show if present */}
{logEntry.request_tags && Object.keys(logEntry.request_tags).length > 0 && (
<TagsSection tags={logEntry.request_tags} />
)}
{/* Request Details Section */}
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
<Card title="Request Details" size="small" bordered={false} style={{ marginBottom: 0 }}>
<Descriptions column={2} size="small">
<Descriptions.Item label="Model">{logEntry.model}</Descriptions.Item>
<Descriptions.Item label="Provider">{logEntry.custom_llm_provider || "-"}</Descriptions.Item>
<Descriptions.Item label="Call Type">{logEntry.call_type}</Descriptions.Item>
<Descriptions.Item label="Model ID">
<TruncatedValue value={logEntry.model_id} />
</Descriptions.Item>
<Descriptions.Item label="API Base">
<TruncatedValue value={logEntry.api_base} maxWidth={API_BASE_MAX_WIDTH} />
</Descriptions.Item>
{logEntry.requester_ip_address && (
<Descriptions.Item label="IP Address">{logEntry.requester_ip_address}</Descriptions.Item>
)}
{hasGuardrailData && (
<Descriptions.Item label="Guardrail">
<GuardrailLabel label={primaryGuardrailLabel} maskedCount={totalMaskedEntities} />
</Descriptions.Item>
)}
</Descriptions>
</Card>
</div>
{/* Metrics Section */}
<MetricsSection logEntry={logEntry} metadata={metadata} />
{/* Cost Breakdown - Show if cost breakdown data is available */}
<CostBreakdownViewer costBreakdown={metadata?.cost_breakdown} totalSpend={logEntry.spend || 0} />
{/* Tools Section - Show if tools are present in request */}
<ToolsSection log={logEntry} />
{/* Configuration Info Message - Show when data is missing */}
{missingData && (
<div className="mb-6">
<ConfigInfoMessage show={missingData} onOpenSettings={onOpenSettings} />
<div className="flex-1 overflow-y-auto">
{isSessionMode ? (
<div className="py-1">
{/* Child events — vertical tree line with horizontal connectors */}
<div className="relative pl-2">
<div className="absolute left-4 top-1 bottom-1 border-l border-slate-300" />
{logsForList.map((row, idx) => {
const isLast = idx === logsForList.length - 1;
return (
<div key={row.request_id} className="relative">
<div className="absolute left-4 top-3 w-3 border-t border-slate-300" />
{isLast && <div className="absolute left-4 top-3 bottom-0 w-px bg-slate-50" />}
<TraceEventRow
row={row}
isSelected={row.request_id === currentLog.request_id}
onClick={() => {
setSelectedSessionRequestId(row.request_id);
onSelectLog?.(row);
}}
/>
</div>
);
})}
</div>
</div>
) : (
<div className="py-1">
{logsForList.map((row) => (
<TraceEventRow
key={row.request_id}
row={row}
isSelected={row.request_id === currentLog.request_id}
onClick={() => onSelectLog?.(row)}
/>
))}
</div>
)}
</div>
</div>
)}
)}
{/* Request/Response JSON - Collapsible */}
<RequestResponseSection
hasResponse={hasResponse}
hasError={hasError}
getRawRequest={getRawRequest}
getFormattedResponse={getFormattedResponse}
logEntry={logEntry}
/>
{/* Guardrail Data - Show only if present */}
{hasGuardrailData && <GuardrailViewer data={guardrailInfo} />}
{/* Vector Store Request Data - Show only if present */}
{hasVectorStoreData && <VectorStoreViewer data={metadata.vector_store_request_metadata} />}
{/* Metadata Card - Only show if there's metadata */}
{logEntry.metadata && Object.keys(logEntry.metadata).length > 0 && (
<MetadataSection metadata={logEntry.metadata} />
)}
{/* Bottom spacing for scroll area */}
<div style={{ height: DRAWER_CONTENT_PADDING }} />
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<DrawerHeader
log={currentLog}
onClose={onClose}
onPrevious={selectPreviousLog}
onNext={selectNextLog}
statusLabel={statusLabel}
statusColor={statusColor}
environment={environment}
/>
<div className="flex-1 overflow-y-auto">
<LogDetailContent logEntry={currentLog} onOpenSettings={onOpenSettings} />
</div>
</div>
</div>
</Drawer>
);
}
// ============================================================================
// Helper Components
// ============================================================================
function ErrorDescription({ errorInfo }: { errorInfo: any }) {
return (
<div>
{errorInfo.error_code && (
<div>
<Text strong>Error Code:</Text> {errorInfo.error_code}
</div>
)}
{errorInfo.error_message && (
<div>
<Text strong>Message:</Text> {errorInfo.error_message}
</div>
)}
</div>
);
}
function TagsSection({ tags }: { tags: Record<string, any> }) {
return (
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden p-4 mb-6">
<Text strong style={{ display: "block", marginBottom: 8, fontSize: 16 }}>
Tags
</Text>
<Space size={SPACING_MEDIUM} wrap>
{Object.entries(tags).map(([key, value]) => (
<Tag key={key}>
{key}: {String(value)}
</Tag>
))}
</Space>
</div>
);
}
function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: number }) {
return (
<Space size={SPACING_MEDIUM}>
<span>{label}</span>
{maskedCount > 0 && (
<Tag color="blue">
{maskedCount} masked
</Tag>
)}
</Space>
);
}
function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata: Record<string, any> }) {
const hasCacheActivity =
logEntry.cache_hit ||
(metadata?.additional_usage_values?.cache_read_input_tokens &&
metadata.additional_usage_values.cache_read_input_tokens > 0);
return (
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
<Card title="Metrics" size="small" bordered={false} style={{ marginBottom: 0 }}>
<Descriptions column={2} size="small">
<Descriptions.Item label="Tokens">
<TokenFlow
prompt={logEntry.prompt_tokens}
completion={logEntry.completion_tokens}
total={logEntry.total_tokens}
/>
</Descriptions.Item>
<Descriptions.Item label="Cost">${formatNumberWithCommas(logEntry.spend || 0, 8)}</Descriptions.Item>
<Descriptions.Item label="Duration">{logEntry.duration?.toFixed(3)} s</Descriptions.Item>
{/* Only show cache fields if there's cache activity */}
{hasCacheActivity && (
<>
<Descriptions.Item label="Cache Hit">
<Tag color={logEntry.cache_hit ? "green" : "default"}>{logEntry.cache_hit || "None"}</Tag>
</Descriptions.Item>
{metadata?.additional_usage_values?.cache_read_input_tokens > 0 && (
<Descriptions.Item label="Cache Read Tokens">
{formatNumberWithCommas(metadata.additional_usage_values.cache_read_input_tokens)}
</Descriptions.Item>
)}
{metadata?.additional_usage_values?.cache_creation_input_tokens > 0 && (
<Descriptions.Item label="Cache Creation Tokens">
{formatNumberWithCommas(metadata.additional_usage_values.cache_creation_input_tokens)}
</Descriptions.Item>
)}
</>
)}
{metadata?.litellm_overhead_time_ms !== undefined && metadata.litellm_overhead_time_ms !== null && (
<Descriptions.Item label="LiteLLM Overhead">
{metadata.litellm_overhead_time_ms.toFixed(2)} ms
</Descriptions.Item>
)}
<Descriptions.Item label="Start Time">
{moment(logEntry.startTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
</Descriptions.Item>
<Descriptions.Item label="End Time">
{moment(logEntry.endTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
</Descriptions.Item>
</Descriptions>
</Card>
</div>
);
}
interface RequestResponseSectionProps {
hasResponse: boolean;
hasError: boolean;
getRawRequest: () => any;
getFormattedResponse: () => any;
logEntry: LogEntry;
}
function RequestResponseSection({
hasResponse,
hasError,
getRawRequest,
getFormattedResponse,
logEntry,
}: RequestResponseSectionProps) {
const [activeTab, setActiveTab] = useState<typeof TAB_REQUEST | typeof TAB_RESPONSE>(TAB_REQUEST);
const [viewMode, setViewMode] = useState<'pretty' | 'json'>('pretty');
const getCopyText = () => {
const data = activeTab === TAB_REQUEST ? getRawRequest() : getFormattedResponse();
return JSON.stringify(data, null, 2);
};
// Calculate input and output costs
// Assume average cost if not explicitly provided
const totalSpend = logEntry.spend || 0;
const promptTokens = logEntry.prompt_tokens || 0;
const completionTokens = logEntry.completion_tokens || 0;
const totalTokens = promptTokens + completionTokens;
// Estimate input/output costs proportionally if not available
const inputCost = totalTokens > 0 ? (totalSpend * promptTokens) / totalTokens : 0;
const outputCost = totalTokens > 0 ? (totalSpend * completionTokens) / totalTokens : 0;
return (
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
<Collapse
defaultActiveKey={["1"]}
expandIconPosition="start"
items={[
{
key: "1",
label: (
<div
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}
onClick={(e) => {
// Only prevent if clicking on the Radio.Group area
const target = e.target as HTMLElement;
if (target.closest('.ant-radio-group')) {
e.stopPropagation();
}
}}
>
<h3 className="text-lg font-medium text-gray-900" style={{ margin: 0 }}>Request & Response</h3>
{/* View Mode Toggle - In the header */}
<Radio.Group
size="small"
value={viewMode}
onChange={(e) => setViewMode(e.target.value)}
>
<Radio.Button value="pretty">Pretty</Radio.Button>
<Radio.Button value="json">JSON</Radio.Button>
</Radio.Group>
</div>
),
children: (
<div>
{viewMode === 'pretty' ? (
<PrettyMessagesView
request={getRawRequest()}
response={getFormattedResponse()}
metrics={{
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
input_cost: inputCost,
output_cost: outputCost,
}}
/>
) : (
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)}
tabBarExtraContent={
<Text
copyable={{
text: getCopyText(),
tooltips: ["Copy JSON", "Copied!"]
}}
disabled={activeTab === TAB_RESPONSE && !hasResponse && !hasError}
/>
}
items={[
{
key: TAB_REQUEST,
label: "Request",
children: (
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
<JsonViewer data={getRawRequest()} mode="formatted" />
</div>
),
},
{
key: TAB_RESPONSE,
label: "Response",
children: (
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
{hasResponse || hasError ? (
<JsonViewer data={getFormattedResponse()} mode="formatted" />
) : (
<div style={{ textAlign: "center", padding: 20, color: "#999", fontStyle: "italic" }}>
Response data not available
</div>
)}
</div>
),
},
]}
/>
)}
</div>
),
},
]}
/>
</div>
);
}
function MetadataSection({ metadata }: { metadata: Record<string, any> }) {
return (
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
<Collapse
defaultActiveKey={["1"]}
expandIconPosition="start"
items={[
{
key: "1",
label: <h3 className="text-lg font-medium text-gray-900">Metadata</h3>,
children: (
<div>
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 8 }}>
<Text
copyable={{
text: JSON.stringify(metadata, null, 2),
tooltips: ["Copy Metadata", "Copied!"]
}}
/>
</div>
<pre
style={{
maxHeight: METADATA_MAX_HEIGHT,
overflowY: "auto",
fontSize: FONT_SIZE_SMALL,
fontFamily: FONT_FAMILY_MONO,
whiteSpace: "pre-wrap",
wordBreak: "break-all",
margin: 0,
}}
>
{JSON.stringify(metadata, null, 2)}
</pre>
</div>
),
},
]}
/>
</div>
);
}
@@ -1,2 +1,4 @@
export { LogDetailsDrawer } from "./LogDetailsDrawer";
export type { LogDetailsDrawerProps } from "./LogDetailsDrawer";
export { LogDetailContent } from "./LogDetailContent";
export type { LogDetailContentProps } from "./LogDetailContent";
@@ -1,193 +0,0 @@
import React, { useState } from "react";
import { LogEntry } from "./columns";
import { DataTable } from "./table";
import { columns } from "./columns";
import { Card, Title, Text, Metric, Button as TremorButton } from "@tremor/react";
import { RequestViewer } from "./index";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { ArrowLeftIcon } from "@heroicons/react/outline";
import { Button } from "antd";
import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
import { CheckIcon, CopyIcon } from "lucide-react";
import { Tooltip } from "antd";
interface SessionViewProps {
sessionId: string;
logs: LogEntry[];
onBack: () => void;
}
export const SessionView: React.FC<SessionViewProps> = ({ sessionId, logs, onBack }) => {
// Track which log row is expanded
const [expandedRequestId, setExpandedRequestId] = useState<string | null>(null);
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
// Calculate session metrics
const totalCost = logs.reduce((sum, log) => sum + (log.spend || 0), 0);
const totalTokens = logs.reduce((sum, log) => sum + (log.total_tokens || 0), 0);
// Calculate cache token totals from metadata
const totalCacheReadTokens = logs.reduce((sum, log) => {
const cacheReadTokens = log.metadata?.additional_usage_values?.cache_read_input_tokens || 0;
return sum + cacheReadTokens;
}, 0);
const totalCacheCreationTokens = logs.reduce((sum, log) => {
const cacheCreationTokens = log.metadata?.additional_usage_values?.cache_creation_input_tokens || 0;
return sum + cacheCreationTokens;
}, 0);
// Calculate total tokens including cache tokens
const totalTokensWithCache = totalTokens + totalCacheReadTokens + totalCacheCreationTokens;
const startTime = logs.length > 0 ? new Date(logs[0].startTime) : new Date();
const endTime = logs.length > 0 ? new Date(logs[logs.length - 1].endTime) : new Date();
const durationMs = endTime.getTime() - startTime.getTime();
const durationSec = (durationMs / 1000).toFixed(2);
// Prepare data for the timeline chart
const timelineData = logs.map((log) => ({
time: new Date(log.startTime).toISOString(),
tokens: log.total_tokens || 0,
cost: log.spend || 0,
}));
const copyToClipboard = async (text: string, key: string) => {
const success = await utilCopyToClipboard(text);
if (success) {
setCopiedStates((prev) => ({ ...prev, [key]: true }));
setTimeout(() => {
setCopiedStates((prev) => ({ ...prev, [key]: false }));
}, 2000);
}
};
return (
<div className="space-y-6">
{/* Header with back button */}
<div className="mb-8">
<TremorButton icon={ArrowLeftIcon} variant="light" onClick={onBack} className="mb-4">
Back to All Logs
</TremorButton>
<div className="mt-4">
<h1 className="text-2xl font-semibold text-gray-900">Session Details</h1>
<div className="space-y-2">
<div className="flex items-center cursor-pointer">
<p className="text-sm text-gray-500 font-mono">{sessionId}</p>
<Button
type="text"
size="small"
icon={copiedStates["session-id"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
onClick={() => copyToClipboard(sessionId, "session-id")}
className={`left-2 z-10 transition-all duration-200 ${
copiedStates["session-id"]
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
}`}
/>
</div>
<a
href="https://docs.litellm.ai/docs/proxy/ui_logs_sessions"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1"
>
Get started with session management here
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</div>
</div>
</div>
{/* Session Overview Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<Text>Total Requests</Text>
<Metric>{logs.length}</Metric>
</Card>
<Card>
<Text>Total Cost</Text>
<Metric>${formatNumberWithCommas(totalCost, 6)}</Metric>
</Card>
<Tooltip
title={
<div className="text-white min-w-[200px]">
<div className="text-lg font-medium mb-3">Usage breakdown</div>
<div className="space-y-4">
<div>
<div className="text-base font-medium mb-2">Input usage:</div>
<div className="space-y-2 text-sm text-gray-300">
<div className="flex justify-between">
<span>input:</span>
<span className="ml-8">
{formatNumberWithCommas(logs.reduce((sum, log) => sum + (log.prompt_tokens || 0), 0))}
</span>
</div>
{totalCacheReadTokens > 0 && (
<div className="flex justify-between">
<span>input_cached_tokens:</span>
<span className="ml-8">{formatNumberWithCommas(totalCacheReadTokens)}</span>
</div>
)}
{totalCacheCreationTokens > 0 && (
<div className="flex justify-between">
<span>input_cache_creation_tokens:</span>
<span className="ml-8">{formatNumberWithCommas(totalCacheCreationTokens)}</span>
</div>
)}
</div>
</div>
<div className="border-t border-gray-600 pt-3">
<div className="text-base font-medium mb-2">Output usage:</div>
<div className="space-y-2 text-sm text-gray-300">
<div className="flex justify-between">
<span>output:</span>
<span className="ml-8">
{formatNumberWithCommas(logs.reduce((sum, log) => sum + (log.completion_tokens || 0), 0))}
</span>
</div>
</div>
</div>
<div className="border-t border-gray-600 pt-3">
<div className="flex justify-between items-center">
<span className="text-base font-medium">Total usage:</span>
<span className="text-sm text-gray-300">{formatNumberWithCommas(totalTokensWithCache)}</span>
</div>
</div>
</div>
</div>
}
placement="top"
overlayStyle={{ minWidth: "300px" }}
>
<Card>
<div className="flex items-center justify-between">
<Text>Total Tokens</Text>
<span className="text-gray-400 text-sm"></span>
</div>
<Metric>{formatNumberWithCommas(totalTokensWithCache)}</Metric>
</Card>
</Tooltip>
</div>
{/* Request Timeline */}
<Title>Session Logs</Title>
<div className="mt-4">
<DataTable
columns={columns}
data={logs}
renderSubComponent={RequestViewer}
getRowCanExpand={() => true}
loadingMessage="Loading logs..."
noDataMessage="No logs found"
/>
</div>
</div>
);
};
@@ -0,0 +1,30 @@
/**
* Compact type-indicator badges for LLM and MCP log entries.
* Used in the request logs table and session type column.
*/
export const SparkleIcon = ({ size = 12 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0 text-gray-400">
<path d="M12 3l1.912 5.813a2 2 0 0 0 1.275 1.275L21 12l-5.813 1.912a2 2 0 0 0-1.275 1.275L12 21l-1.912-5.813a2 2 0 0 0-1.275-1.275L3 12l5.813-1.912a2 2 0 0 0 1.275-1.275L12 3z" />
</svg>
);
export const WrenchIcon = ({ size = 10 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
);
export const LlmBadge = ({ count }: { count?: number }) => (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-50 text-blue-700 border border-blue-200 rounded-full text-[11px] font-medium whitespace-nowrap">
<SparkleIcon />
{count != null ? count : "LLM"}
</span>
);
export const McpBadge = ({ count }: { count?: number }) => (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-amber-50 text-amber-700 border border-amber-200 rounded-full text-[11px] font-medium whitespace-nowrap">
<WrenchIcon />
{count != null ? count : "MCP"}
</span>
);
@@ -5,6 +5,8 @@ import { Tooltip } from "antd";
import React, { useState } from "react";
import { getProviderLogoAndName } from "../provider_info_helpers";
import { TimeCell } from "./time_cell";
import { MCP_CALL_TYPES } from "./constants";
import { LlmBadge, McpBadge, SparkleIcon, WrenchIcon } from "./TypeBadges";
// Helper to get the appropriate logo URL
const getLogoUrl = (row: LogEntry, provider: string) => {
@@ -44,6 +46,12 @@ export type LogEntry = {
session_id?: string;
status?: string;
duration?: number;
session_total_count?: number;
session_total_spend?: number;
mcp_tool_call_count?: number;
mcp_tool_call_spend?: number;
session_llm_count?: number;
session_mcp_count?: number;
onKeyHashClick?: (keyHash: string) => void;
onSessionClick?: (sessionId: string) => void;
};
@@ -54,6 +62,40 @@ export const columns: ColumnDef<LogEntry>[] = [
accessorKey: "startTime",
cell: (info: any) => <TimeCell utcTime={info.getValue()} />,
},
{
header: "Type",
id: "type",
cell: (info: any) => {
const row = info.row.original;
const sessionCount = row.session_total_count || 1;
const isMcp = MCP_CALL_TYPES.includes(row.call_type);
const sessionLlmCount = row.session_llm_count ?? (isMcp ? 0 : sessionCount);
const sessionMcpCount = row.session_mcp_count ?? (isMcp ? sessionCount : 0);
if (isMcp) return <McpBadge />;
if (sessionCount <= 1) return <LlmBadge />;
// Multi-call session — show total count, plus MCP indicator when mixed.
const sessionTypeBadge = (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-50 text-blue-700 border border-blue-200 rounded-full text-[11px] font-medium whitespace-nowrap">
<SparkleIcon />
<span>{sessionCount}</span>
{sessionMcpCount > 0 && (
<>
<span className="text-blue-300">·</span>
<WrenchIcon />
</>
)}
</span>
);
return (
<Tooltip title={`${sessionLlmCount} LLM • ${sessionMcpCount} MCP`}>
{sessionTypeBadge}
</Tooltip>
);
},
},
{
header: "Status",
accessorKey: "metadata.status",
@@ -105,11 +147,24 @@ export const columns: ColumnDef<LogEntry>[] = [
{
header: "Cost",
accessorKey: "spend",
cell: (info: any) => (
<Tooltip title={`$${String(info.getValue() || 0)} `}>
<span>{getSpendString(info.getValue() || 0)}</span>
</Tooltip>
),
cell: (info: any) => {
const row = info.row.original;
const mcpCount = row.mcp_tool_call_count || 0;
const mcpSpend = row.mcp_tool_call_spend || 0;
return (
<div className="flex flex-col">
<Tooltip title={`$${String(info.getValue() || 0)}`}>
<span>{getSpendString(info.getValue() || 0)}</span>
</Tooltip>
{mcpCount > 0 && mcpSpend > 0 && (
<span className="text-[10px] text-amber-600">
incl. {getSpendString(mcpSpend)} from {mcpCount} MCP
</span>
)}
</div>
);
},
},
{
header: "Duration (s)",
@@ -12,6 +12,9 @@ export const ERROR_CODE_OPTIONS: { label: string; value: string }[] = [
{ label: "529 - Overloaded", value: "529" },
];
/** Call types that represent MCP tool invocations (shared across columns, index, drawer). */
export const MCP_CALL_TYPES = ["call_mcp_tool", "list_mcp_tools"];
export const QUICK_SELECT_OPTIONS: { label: string; value: number; unit: string }[] = [
{ label: "Last 15 Minutes", value: 15, unit: "minutes" },
{ label: "Last Hour", value: 1, unit: "hours" },
@@ -17,12 +17,12 @@ import { fetchAllKeyAliases } from "../key_team_helpers/filter_helpers";
import { KeyResponse, Team } from "../key_team_helpers/key_list";
import { PaginatedModelSelect } from "../ModelSelect/PaginatedModelSelect/PaginatedModelSelect";
import FilterComponent, { FilterOption } from "../molecules/filter";
import { allEndUsersCall, keyInfoV1Call, keyListCall, sessionSpendLogsCall, uiSpendLogsCall } from "../networking";
import { allEndUsersCall, keyInfoV1Call, keyListCall, uiSpendLogsCall } from "../networking";
import KeyInfoView from "../templates/key_info_view";
import AuditLogs from "./audit_logs";
import { columns, LogEntry } from "./columns";
import { ConfigInfoMessage } from "./ConfigInfoMessage";
import { ERROR_CODE_OPTIONS, QUICK_SELECT_OPTIONS } from "./constants";
import { ERROR_CODE_OPTIONS, MCP_CALL_TYPES, QUICK_SELECT_OPTIONS } from "./constants";
import { CostBreakdownViewer } from "./CostBreakdownViewer";
import { ErrorViewer } from "./ErrorViewer";
import { useLogFilterLogic } from "./log_filter_logic";
@@ -30,7 +30,6 @@ import { LogDetailsDrawer } from "./LogDetailsDrawer";
import { getTimeRangeDisplay } from "./logs_utils";
import { prefetchLogDetails } from "./prefetch";
import { RequestResponsePanel } from "./RequestResponsePanel";
import { SessionView } from "./SessionView";
import SpendLogsSettingsModal from "./SpendLogsSettingsModal/SpendLogsSettingsModal";
import { DataTable } from "./table";
import { VectorStoreViewer } from "./VectorStoreViewer";
@@ -304,57 +303,70 @@ export default function SpendLogsTable({
}
}, [filters, accessToken, fetchKeyHashForAlias]);
// Fetch logs for a session if selected
const sessionLogs = useQuery<PaginatedResponse>({
queryKey: ["sessionLogs", selectedSessionId],
queryFn: async () => {
if (!accessToken || !selectedSessionId) return { data: [], total: 0, page: 1, page_size: 50, total_pages: 1 };
const response = await sessionSpendLogsCall(accessToken, selectedSessionId);
// If the API returns an array, wrap it in the same shape as PaginatedResponse
return {
data: response.data || response || [],
total: (response.data || response || []).length,
page: 1,
page_size: 1000,
total_pages: 1,
};
},
enabled: !!accessToken && !!selectedSessionId,
});
if (!accessToken || !token || !userRole || !userID) {
return null;
}
const searchedLogs = filteredLogs.data.filter((log) => {
const matchesSearch =
!searchTerm ||
log.request_id.includes(searchTerm) ||
log.model.includes(searchTerm) ||
(log.user && log.user.includes(searchTerm));
// No need for additional filtering since we're now handling this in the API call
return matchesSearch;
});
const sessionCompositionById = searchedLogs.reduce<Record<string, { llm: number; mcp: number }>>((acc, log) => {
if (!log.session_id) return acc;
if (!acc[log.session_id]) {
acc[log.session_id] = { llm: 0, mcp: 0 };
}
if (MCP_CALL_TYPES.includes(log.call_type)) {
acc[log.session_id].mcp += 1;
} else {
acc[log.session_id].llm += 1;
}
return acc;
}, {});
// Build a single-pass map of session_id → representative request_id.
// Prefers an LLM row over an MCP row as the representative.
const sessionRepresentativeMap = new Map<string, { requestId: string; isMcp: boolean }>();
for (const log of searchedLogs) {
if (!log.session_id || (log.session_total_count || 1) <= 1) continue;
const isMcp = MCP_CALL_TYPES.includes(log.call_type);
const existing = sessionRepresentativeMap.get(log.session_id);
if (!existing || (existing.isMcp && !isMcp)) {
sessionRepresentativeMap.set(log.session_id, { requestId: log.request_id, isMcp });
}
}
const filteredData =
filteredLogs.data
.filter((log) => {
const matchesSearch =
!searchTerm ||
log.request_id.includes(searchTerm) ||
log.model.includes(searchTerm) ||
(log.user && log.user.includes(searchTerm));
// No need for additional filtering since we're now handling this in the API call
return matchesSearch;
searchedLogs
.map((log) => {
const sessionComposition = log.session_id ? sessionCompositionById[log.session_id] : undefined;
return {
...log,
duration: (Date.parse(log.endTime) - Date.parse(log.startTime)) / 1000,
session_llm_count: sessionComposition?.llm ?? undefined,
session_mcp_count: sessionComposition?.mcp ?? undefined,
onKeyHashClick: (keyHash: string) => setSelectedKeyIdInfoView(keyHash),
onSessionClick: (sessionId: string) => {
if (sessionId) {
setSelectedSessionId(sessionId);
setSelectedLog(log);
setIsDrawerOpen(true);
}
},
};
})
.map((log) => ({
...log,
duration: (Date.parse(log.endTime) - Date.parse(log.startTime)) / 1000,
onKeyHashClick: (keyHash: string) => setSelectedKeyIdInfoView(keyHash),
onSessionClick: (sessionId: string) => {
if (sessionId) setSelectedSessionId(sessionId);
},
})) || [];
// For session logs, add onKeyHashClick/onSessionClick as well
const sessionData =
sessionLogs.data?.data?.map((log) => ({
...log,
onKeyHashClick: (keyHash: string) => setSelectedKeyIdInfoView(keyHash),
onSessionClick: (sessionId: string) => { },
})) || [];
// Deduplicate multi-call sessions using the pre-built map (O(1) per row).
.filter((log) => {
if (!log.session_id || (log.session_total_count || 1) <= 1) return true;
return sessionRepresentativeMap.get(log.session_id)?.requestId === log.request_id;
}) || [];
// Add this function to handle manual refresh
const handleRefresh = () => {
@@ -362,13 +374,22 @@ export default function SpendLogsTable({
};
const handleRowClick = (log: LogEntry) => {
// Multi-call session row: open in the same right-side drawer (session mode)
if (log.session_id && (log.session_total_count || 1) > 1) {
setSelectedSessionId(log.session_id);
setSelectedLog(log);
setIsDrawerOpen(true);
return;
}
// Single-call row: open the detail drawer
setSelectedSessionId(null);
setSelectedLog(log);
setIsDrawerOpen(true);
};
const handleCloseDrawer = () => {
setIsDrawerOpen(false);
// Optionally keep selectedLog for animation purposes
setSelectedSessionId(null);
};
const handleSelectLog = (log: LogEntry) => {
@@ -462,19 +483,6 @@ export default function SpendLogsTable({
},
];
// When a session is selected, render the SessionView component
if (selectedSessionId && sessionLogs.data) {
return (
<div className="w-full p-6">
<SessionView
sessionId={selectedSessionId}
logs={sessionLogs.data.data}
onBack={() => setSelectedSessionId(null)}
/>
</div>
);
}
const formatTimeUnit = (value: number, unit: string) => {
if (value === 1) {
if (unit === "minutes") return "minute";
@@ -502,29 +510,12 @@ export default function SpendLogsTable({
<TabPanels>
<TabPanel>
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold">
{selectedSessionId ? (
<>
Session: <span className="font-mono">{selectedSessionId}</span>
<button
className="ml-4 px-3 py-1 text-sm border rounded hover:bg-gray-50"
onClick={() => setSelectedSessionId(null)}
>
Back to All Logs
</button>
</>
) : (
"Request Logs"
)}
</h1>
{!selectedSessionId && (
<NewBadge dot><Button
icon={<SettingOutlined />}
onClick={() => setIsSpendLogsSettingsModalVisible(true)}
title="Spend Logs Settings"
/></NewBadge>
)}
<h1 className="text-xl font-semibold">Request Logs</h1>
<NewBadge dot><Button
icon={<SettingOutlined />}
onClick={() => setIsSpendLogsSettingsModalVisible(true)}
title="Spend Logs Settings"
/></NewBadge>
</div>
{selectedKeyInfo && selectedKeyIdInfoView && selectedKeyInfo.api_key === selectedKeyIdInfoView ? (
<KeyInfoView
@@ -534,14 +525,6 @@ export default function SpendLogsTable({
onClose={() => setSelectedKeyIdInfoView(null)}
backButtonText="Back to Logs"
/>
) : selectedSessionId ? (
<div className="bg-white rounded-lg shadow">
<DataTable
columns={columns}
data={sessionData}
onRowClick={handleRowClick}
/>
</div>
) : (
<>
<FilterComponent
@@ -763,6 +746,8 @@ export default function SpendLogsTable({
open={isDrawerOpen}
onClose={handleCloseDrawer}
logEntry={selectedLog}
sessionId={selectedSessionId}
accessToken={accessToken}
onOpenSettings={() => setIsSpendLogsSettingsModalVisible(true)}
allLogs={filteredData}
onSelectLog={handleSelectLog}
@@ -7,8 +7,10 @@ interface DataTableProps<TData, TValue> {
data: TData[];
columns: ColumnDef<TData, TValue>[];
onRowClick?: (row: TData) => void;
// Legacy props for backward compatibility (audit logs)
/** Renders inside a single colspan cell (used by audit logs) */
renderSubComponent?: (props: { row: Row<TData> }) => React.ReactElement;
/** Renders directly in tbody as sibling table rows (used by MCP children) */
renderChildRows?: (props: { row: Row<TData> }) => React.ReactNode;
getRowCanExpand?: (row: Row<TData>) => boolean;
isLoading?: boolean;
loadingMessage?: string;
@@ -20,24 +22,24 @@ export function DataTable<TData, TValue>({
columns,
onRowClick,
renderSubComponent,
renderChildRows,
getRowCanExpand,
isLoading = false,
loadingMessage = "🚅 Loading logs...",
noDataMessage = "No logs found",
}: DataTableProps<TData, TValue>) {
// Determine if we're in legacy expansion mode or new drawer mode
const isLegacyMode = !!renderSubComponent && !!getRowCanExpand;
const supportsExpansion = !!(renderSubComponent || renderChildRows) && !!getRowCanExpand;
const table = useReactTable<TData>({
data,
columns,
...(isLegacyMode && { getRowCanExpand }),
...(supportsExpansion && { getRowCanExpand }),
getRowId: (row: TData, index: number) => {
const _row: any = row as any;
return _row?.request_id ?? String(index);
},
getCoreRowModel: getCoreRowModel(),
...(isLegacyMode && { getExpandedRowModel: getExpandedRowModel() }),
...(supportsExpansion && { getExpandedRowModel: getExpandedRowModel() }),
});
return (
@@ -69,8 +71,8 @@ export function DataTable<TData, TValue>({
table.getRowModel().rows.map((row) => (
<Fragment key={row.id}>
<TableRow
className={`h-8 ${!isLegacyMode ? "cursor-pointer hover:bg-gray-50" : ""}`}
onClick={() => !isLegacyMode && onRowClick?.(row.original)}
className={`h-8 ${onRowClick ? "cursor-pointer hover:bg-gray-50" : ""}`}
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap">
@@ -79,8 +81,13 @@ export function DataTable<TData, TValue>({
))}
</TableRow>
{/* Legacy expansion mode for audit logs */}
{isLegacyMode && row.getIsExpanded() && renderSubComponent && (
{/* Child rows rendered as real table rows (MCP children) */}
{supportsExpansion && row.getIsExpanded() && renderChildRows && (
renderChildRows({ row })
)}
{/* Legacy sub-component in colspan cell (audit logs) */}
{supportsExpansion && row.getIsExpanded() && renderSubComponent && !renderChildRows && (
<TableRow>
<TableCell colSpan={row.getVisibleCells().length} className="p-0">
<div className="w-full max-w-full overflow-hidden box-border">{renderSubComponent({ row })}</div>
@@ -0,0 +1,20 @@
import { MCP_CALL_TYPES } from "./constants";
/**
* Derive a short, human-readable display name for a log entry.
* Strips provider prefixes, date suffixes, and version tags.
*/
export function getEventDisplayName(callType: string, model: string): string {
const raw = (model || "").trim();
const isMcp = MCP_CALL_TYPES.includes(callType);
if (isMcp) {
return raw.replace(/^mcp:\s*/i, "").split("/").pop() || raw || "mcp_tool";
}
const lastSegment = raw.split("/").pop() || raw;
const noSuffix = lastSegment.replace(/-20\d{6}.*$/i, "").replace(/:.*$/, "");
const claudeMatch = noSuffix.match(/claude-[a-z0-9-]+/i);
if (claudeMatch) return claudeMatch[0];
return noSuffix || "llm_call";
}