mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-15 18:21:44 +00:00
[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:
co-authored by
shin-bot-litellm
parent
3d9b145b04
commit
89565c97cc
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+293
-442
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user