(Feat) - Allow viewing Request/Response Logs stored in GCS Bucket (#8449)

* BaseRequestResponseFetchFromCustomLogger

* get_active_base_request_response_fetch_from_custom_logger

* get_request_response_payload

* ui_view_request_response_for_request_id

* fix uiSpendLogDetailsCall

* fix get_request_response_payload

* ui fix RequestViewer

* use 1 class AdditionalLoggingUtils

* ui_view_request_response_for_request_id

* cache the prefetch logs details

* refactor prefetch

* test view request/resp logs

* fix code quality

* fix get_request_response_payload

* uninstall posthog
prevent it from being added in ci/cd

* fix posthog

* fix traceloop test

* fix linting error
This commit is contained in:
Ishaan Jaff 2025-02-10 20:38:55 -08:00 committed by GitHub
parent 64a4229606
commit 00c596a852
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 706 additions and 201 deletions

View file

@ -0,0 +1,36 @@
"""
Base class for Additional Logging Utils for CustomLoggers
- Health Check for the logging util
- Get Request / Response Payload for the logging util
"""
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Optional
from litellm.types.integrations.base_health_check import IntegrationHealthCheckStatus
class AdditionalLoggingUtils(ABC):
def __init__(self):
super().__init__()
@abstractmethod
async def async_health_check(self) -> IntegrationHealthCheckStatus:
"""
Check if the service is healthy
"""
pass
@abstractmethod
async def get_request_response_payload(
self,
request_id: str,
start_time_utc: Optional[datetime],
end_time_utc: Optional[datetime],
) -> Optional[dict]:
"""
Get the request and response payload for a given `request_id`
"""
return None

View file

@ -1,19 +0,0 @@
"""
Base class for health check integrations
"""
from abc import ABC, abstractmethod
from litellm.types.integrations.base_health_check import IntegrationHealthCheckStatus
class HealthCheckIntegration(ABC):
def __init__(self):
super().__init__()
@abstractmethod
async def async_health_check(self) -> IntegrationHealthCheckStatus:
"""
Check if the service is healthy
"""
pass

View file

@ -38,14 +38,14 @@ from litellm.types.integrations.datadog import *
from litellm.types.services import ServiceLoggerPayload
from litellm.types.utils import StandardLoggingPayload
from ..base_health_check import HealthCheckIntegration
from ..additional_logging_utils import AdditionalLoggingUtils
DD_MAX_BATCH_SIZE = 1000 # max number of logs DD API can accept
class DataDogLogger(
CustomBatchLogger,
HealthCheckIntegration,
AdditionalLoggingUtils,
):
# Class variables or attributes
def __init__(
@ -543,3 +543,13 @@ class DataDogLogger(
status="unhealthy",
error_message=str(e),
)
async def get_request_response_payload(
self,
request_id: str,
start_time_utc: Optional[datetimeObj],
end_time_utc: Optional[datetimeObj],
) -> Optional[dict]:
raise NotImplementedError(
"Datdog Integration for getting request/response payloads not implemented as yet"
)

View file

@ -1,12 +1,16 @@
import asyncio
import json
import os
import uuid
from datetime import datetime
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from urllib.parse import quote
from litellm._logging import verbose_logger
from litellm.integrations.additional_logging_utils import AdditionalLoggingUtils
from litellm.integrations.gcs_bucket.gcs_bucket_base import GCSBucketBase
from litellm.proxy._types import CommonProxyErrors
from litellm.types.integrations.base_health_check import IntegrationHealthCheckStatus
from litellm.types.integrations.gcs_bucket import *
from litellm.types.utils import StandardLoggingPayload
@ -20,7 +24,7 @@ GCS_DEFAULT_BATCH_SIZE = 2048
GCS_DEFAULT_FLUSH_INTERVAL_SECONDS = 20
class GCSBucketLogger(GCSBucketBase):
class GCSBucketLogger(GCSBucketBase, AdditionalLoggingUtils):
def __init__(self, bucket_name: Optional[str] = None) -> None:
from litellm.proxy.proxy_server import premium_user
@ -39,6 +43,7 @@ class GCSBucketLogger(GCSBucketBase):
batch_size=self.batch_size,
flush_interval=self.flush_interval,
)
AdditionalLoggingUtils.__init__(self)
if premium_user is not True:
raise ValueError(
@ -150,11 +155,16 @@ class GCSBucketLogger(GCSBucketBase):
"""
Get the object name to use for the current payload
"""
current_date = datetime.now().strftime("%Y-%m-%d")
current_date = self._get_object_date_from_datetime(datetime.now(timezone.utc))
if logging_payload.get("error_str", None) is not None:
object_name = f"{current_date}/failure-{uuid.uuid4().hex}"
object_name = self._generate_failure_object_name(
request_date_str=current_date,
)
else:
object_name = f"{current_date}/{response_obj.get('id', '')}"
object_name = self._generate_success_object_name(
request_date_str=current_date,
response_id=response_obj.get("id", ""),
)
# used for testing
_litellm_params = kwargs.get("litellm_params", None) or {}
@ -163,3 +173,65 @@ class GCSBucketLogger(GCSBucketBase):
object_name = _metadata["gcs_log_id"]
return object_name
async def get_request_response_payload(
self,
request_id: str,
start_time_utc: Optional[datetime],
end_time_utc: Optional[datetime],
) -> Optional[dict]:
"""
Get the request and response payload for a given `request_id`
Tries current day, next day, and previous day until it finds the payload
"""
if start_time_utc is None:
raise ValueError(
"start_time_utc is required for getting a payload from GCS Bucket"
)
# Try current day, next day, and previous day
dates_to_try = [
start_time_utc,
start_time_utc + timedelta(days=1),
start_time_utc - timedelta(days=1),
]
date_str = None
for date in dates_to_try:
try:
date_str = self._get_object_date_from_datetime(datetime_obj=date)
object_name = self._generate_success_object_name(
request_date_str=date_str,
response_id=request_id,
)
encoded_object_name = quote(object_name, safe="")
response = await self.download_gcs_object(encoded_object_name)
if response is not None:
loaded_response = json.loads(response)
return loaded_response
except Exception as e:
verbose_logger.debug(
f"Failed to fetch payload for date {date_str}: {str(e)}"
)
continue
return None
def _generate_success_object_name(
self,
request_date_str: str,
response_id: str,
) -> str:
return f"{request_date_str}/{response_id}"
def _generate_failure_object_name(
self,
request_date_str: str,
) -> str:
return f"{request_date_str}/failure-{uuid.uuid4().hex}"
def _get_object_date_from_datetime(self, datetime_obj: datetime) -> str:
return datetime_obj.strftime("%Y-%m-%d")
async def async_health_check(self) -> IntegrationHealthCheckStatus:
raise NotImplementedError("GCS Bucket does not support health check")

View file

@ -1,7 +1,8 @@
from typing import Callable, List, Union
from typing import Callable, List, Set, Union
import litellm
from litellm._logging import verbose_logger
from litellm.integrations.additional_logging_utils import AdditionalLoggingUtils
from litellm.integrations.custom_logger import CustomLogger
@ -220,3 +221,36 @@ class LoggingCallbackManager:
litellm._async_success_callback = []
litellm._async_failure_callback = []
litellm.callbacks = []
def _get_all_callbacks(self) -> List[Union[CustomLogger, Callable, str]]:
"""
Get all callbacks from litellm.callbacks, litellm.success_callback, litellm.failure_callback, litellm._async_success_callback, litellm._async_failure_callback
"""
return (
litellm.callbacks
+ litellm.success_callback
+ litellm.failure_callback
+ litellm._async_success_callback
+ litellm._async_failure_callback
)
def get_active_additional_logging_utils_from_custom_logger(
self,
) -> Set[AdditionalLoggingUtils]:
"""
Get all custom loggers that are instances of the given class type
Args:
class_type: The class type to match against (e.g., AdditionalLoggingUtils)
Returns:
Set[CustomLogger]: Set of custom loggers that are instances of the given class type
"""
all_callbacks = self._get_all_callbacks()
matched_callbacks: Set[AdditionalLoggingUtils] = set()
for callback in all_callbacks:
if isinstance(callback, CustomLogger) and isinstance(
callback, AdditionalLoggingUtils
):
matched_callbacks.add(callback)
return matched_callbacks

View file

@ -5,8 +5,11 @@ model_list:
api_key: my-fake-key
api_base: https://exampleopenaiendpoint-production.up.railway.app/
litellm_settings:
cache: True
cache_params:
type: redis
general_settings:
store_model_in_db: true
litellm_settings:
callbacks: ["gcs_bucket"]

View file

@ -2,6 +2,7 @@
import collections
import os
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from typing import TYPE_CHECKING, Any, List, Optional
import fastapi
@ -1759,6 +1760,56 @@ async def ui_view_spend_logs( # noqa: PLR0915
raise handle_exception_on_proxy(e)
@lru_cache(maxsize=128)
@router.get(
"/spend/logs/ui/{request_id}",
tags=["Budget & Spend Tracking"],
dependencies=[Depends(user_api_key_auth)],
include_in_schema=False,
)
async def ui_view_request_response_for_request_id(
request_id: str,
start_date: Optional[str] = fastapi.Query(
default=None,
description="Time from which to start viewing key spend",
),
end_date: Optional[str] = fastapi.Query(
default=None,
description="Time till which to view key spend",
),
):
"""
View request / response for a specific request_id
- goes through all callbacks, checks if any of them have a @property -> has_request_response_payload
- if so, it will return the request and response payload
"""
custom_loggers = (
litellm.logging_callback_manager.get_active_additional_logging_utils_from_custom_logger()
)
start_date_obj: Optional[datetime] = None
end_date_obj: Optional[datetime] = None
if start_date is not None:
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d %H:%M:%S").replace(
tzinfo=timezone.utc
)
if end_date is not None:
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d %H:%M:%S").replace(
tzinfo=timezone.utc
)
for custom_logger in custom_loggers:
payload = await custom_logger.get_request_response_payload(
request_id=request_id,
start_time_utc=start_date_obj,
end_time_utc=end_date_obj,
)
if payload is not None:
return payload
return None
@router.get(
"/spend/logs",
tags=["Budget & Spend Tracking"],

View file

@ -11,6 +11,7 @@ sys.path.insert(0, os.path.abspath("../.."))
@pytest.fixture()
@pytest.mark.skip(reason="Traceloop use `otel` integration instead")
def exporter():
from traceloop.sdk import Traceloop
@ -27,6 +28,7 @@ def exporter():
@pytest.mark.parametrize("model", ["claude-3-5-haiku-20241022", "gpt-3.5-turbo"])
@pytest.mark.skip(reason="Traceloop use `otel` integration instead")
def test_traceloop_logging(exporter, model):
litellm.completion(
model=model,

View file

@ -0,0 +1,208 @@
import io
import os
import sys
sys.path.insert(0, os.path.abspath("../.."))
import asyncio
import json
import logging
import tempfile
import uuid
import json
from datetime import datetime, timedelta, timezone
from datetime import datetime
import pytest
import litellm
from litellm import completion
from litellm._logging import verbose_logger
from litellm.integrations.gcs_bucket.gcs_bucket import (
GCSBucketLogger,
StandardLoggingPayload,
)
from litellm.types.utils import StandardCallbackDynamicParams
# This is the response payload that GCS would return.
mock_response_data = {
"id": "chatcmpl-9870a859d6df402795f75dc5fca5b2e0",
"trace_id": None,
"call_type": "acompletion",
"cache_hit": None,
"stream": True,
"status": "success",
"custom_llm_provider": "openai",
"saved_cache_cost": 0.0,
"startTime": 1739235379.683053,
"endTime": 1739235379.84533,
"completionStartTime": 1739235379.84533,
"response_time": 0.1622769832611084,
"model": "my-fake-model",
"metadata": {
"user_api_key_hash": "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b",
"user_api_key_alias": None,
"user_api_key_team_id": None,
"user_api_key_org_id": None,
"user_api_key_user_id": "default_user_id",
"user_api_key_team_alias": None,
"spend_logs_metadata": None,
"requester_ip_address": "127.0.0.1",
"requester_metadata": {},
"user_api_key_end_user_id": None,
"prompt_management_metadata": None,
},
"cache_key": None,
"response_cost": 3.7500000000000003e-05,
"total_tokens": 21,
"prompt_tokens": 9,
"completion_tokens": 12,
"request_tags": [],
"end_user": "",
"api_base": "https://exampleopenaiendpoint-production.up.railway.app",
"model_group": "fake-openai-endpoint",
"model_id": "b68d56d76b0c24ac9462ab69541e90886342508212210116e300441155f37865",
"requester_ip_address": "127.0.0.1",
"messages": [
{"role": "user", "content": [{"type": "text", "text": "very gm to u"}]}
],
"response": {
"id": "chatcmpl-9870a859d6df402795f75dc5fca5b2e0",
"created": 1677652288,
"model": "gpt-3.5-turbo-0301",
"object": "chat.completion",
"system_fingerprint": "fp_44709d6fcb",
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {
"content": "\n\nHello there, how may I assist you today?",
"role": "assistant",
"tool_calls": None,
"function_call": None,
"refusal": None,
},
}
],
"usage": {
"completion_tokens": 12,
"prompt_tokens": 9,
"total_tokens": 21,
"completion_tokens_details": None,
"prompt_tokens_details": None,
},
"service_tier": None,
},
"model_parameters": {"stream": False, "max_retries": 0, "extra_body": {}},
"hidden_params": {
"model_id": "b68d56d76b0c24ac9462ab69541e90886342508212210116e300441155f37865",
"cache_key": None,
"api_base": "https://exampleopenaiendpoint-production.up.railway.app/",
"response_cost": 3.7500000000000003e-05,
"additional_headers": {},
"litellm_overhead_time_ms": 2.126,
},
"model_map_information": {
"model_map_key": "gpt-3.5-turbo-0301",
"model_map_value": {},
},
"error_str": None,
"error_information": {"error_code": "", "error_class": "", "llm_provider": ""},
"response_cost_failure_debug_info": None,
"guardrail_information": None,
}
@pytest.mark.asyncio
async def test_get_payload_current_day():
"""
Verify that the payload is returned when it is found on the current day.
"""
gcs_logger = GCSBucketLogger()
# Use January 1, 2024 as the current day
start_time = datetime(2024, 1, 1, tzinfo=timezone.utc)
request_id = mock_response_data["id"]
async def fake_download(object_name: str, **kwargs) -> bytes | None:
if "2024-01-01" in object_name:
return json.dumps(mock_response_data).encode("utf-8")
return None
gcs_logger.download_gcs_object = fake_download
payload = await gcs_logger.get_request_response_payload(
request_id, start_time, None
)
assert payload is not None
assert payload["id"] == request_id
@pytest.mark.asyncio
async def test_get_payload_next_day():
"""
Verify that if the payload is not found on the current day,
but is available on the next day, it is returned.
"""
gcs_logger = GCSBucketLogger()
start_time = datetime(2024, 1, 1, tzinfo=timezone.utc)
request_id = mock_response_data["id"]
async def fake_download(object_name: str, **kwargs) -> bytes | None:
if "2024-01-02" in object_name:
return json.dumps(mock_response_data).encode("utf-8")
return None
gcs_logger.download_gcs_object = fake_download
payload = await gcs_logger.get_request_response_payload(
request_id, start_time, None
)
assert payload is not None
assert payload["id"] == request_id
@pytest.mark.asyncio
async def test_get_payload_previous_day():
"""
Verify that if the payload is not found on the current or next day,
but is available on the previous day, it is returned.
"""
gcs_logger = GCSBucketLogger()
start_time = datetime(2024, 1, 1, tzinfo=timezone.utc)
request_id = mock_response_data["id"]
async def fake_download(object_name: str, **kwargs) -> bytes | None:
if "2023-12-31" in object_name:
return json.dumps(mock_response_data).encode("utf-8")
return None
gcs_logger.download_gcs_object = fake_download
payload = await gcs_logger.get_request_response_payload(
request_id, start_time, None
)
assert payload is not None
assert payload["id"] == request_id
@pytest.mark.asyncio
async def test_get_payload_not_found():
"""
Verify that if none of the three days contain the payload, None is returned.
"""
gcs_logger = GCSBucketLogger()
start_time = datetime(2024, 1, 1, tzinfo=timezone.utc)
request_id = mock_response_data["id"]
async def fake_download(object_name: str, **kwargs) -> bytes | None:
return None
gcs_logger.download_gcs_object = fake_download
payload = await gcs_logger.get_request_response_payload(
request_id, start_time, None
)
assert payload is None

View file

@ -3221,3 +3221,41 @@ export const getGuardrailsList = async (accessToken: String) => {
throw error;
}
};
export const uiSpendLogDetailsCall = async (
accessToken: string,
logId: string,
start_date: string
) => {
try {
// Construct base URL
let url = proxyBaseUrl
? `${proxyBaseUrl}/spend/logs/ui/${logId}?start_date=${encodeURIComponent(start_date)}`
: `/spend/logs/ui/${logId}?start_date=${encodeURIComponent(start_date)}`;
console.log("Fetching log details from:", url);
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
handleError(errorData);
throw new Error("Network response was not ok");
}
const data = await response.json();
console.log("Fetched log details:", data);
return data;
} catch (error) {
console.error("Failed to fetch log details:", error);
throw error;
}
};

View file

@ -1,11 +1,12 @@
import moment from "moment";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueries, useQueryClient } from "@tanstack/react-query";
import { useState, useRef, useEffect } from "react";
import { uiSpendLogsCall } from "../networking";
import { uiSpendLogsCall, uiSpendLogDetailsCall } from "../networking";
import { DataTable } from "./table";
import { columns, LogEntry } from "./columns";
import { Row } from "@tanstack/react-table";
import { RequestViewer } from "./request_viewer";
import { prefetchLogDetails } from "./prefetch";
interface SpendLogsTableProps {
accessToken: string | null;
@ -54,6 +55,8 @@ export default function SpendLogsTable({
const [selectedKeyHash, setSelectedKeyHash] = useState("");
const [selectedFilter, setSelectedFilter] = useState("Team ID");
const queryClient = useQueryClient();
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
@ -82,6 +85,7 @@ export default function SpendLogsTable({
document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Update the logs query to use the imported prefetchLogDetails
const logs = useQuery<PaginatedResponse>({
queryKey: [
"logs",
@ -105,13 +109,21 @@ export default function SpendLogsTable({
};
}
// Convert times to UTC before formatting
console.log("Fetching logs with params:", {
startTime,
endTime,
selectedTeamId,
selectedKeyHash,
currentPage,
pageSize
});
const formattedStartTime = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss");
const formattedEndTime = isCustomDate
? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss")
: moment().utc().format("YYYY-MM-DD HH:mm:ss");
return await uiSpendLogsCall(
const response = await uiSpendLogsCall(
accessToken,
selectedKeyHash || undefined,
selectedTeamId || undefined,
@ -121,30 +133,62 @@ export default function SpendLogsTable({
currentPage,
pageSize
);
console.log("Received logs response:", response);
// Update prefetchLogDetails call with new parameters
prefetchLogDetails(response.data, formattedStartTime, accessToken, queryClient);
return response;
},
enabled: !!accessToken && !!token && !!userRole && !!userID,
refetchInterval: 5000,
refetchIntervalInBackground: true,
});
// Move useQueries before the early return
const logDetailsQueries = useQueries({
queries: logs.data?.data?.map((log) => ({
queryKey: ["logDetails", log.request_id, moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss")],
queryFn: () => uiSpendLogDetailsCall(accessToken!, log.request_id, moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss")),
staleTime: 10 * 60 * 1000,
cacheTime: 10 * 60 * 1000,
enabled: !!log.request_id,
})) || []
});
if (!accessToken || !token || !userRole || !userID) {
console.log(
"got None values for one of accessToken, token, userRole, userID",
);
console.log("got None values for one of accessToken, token, userRole, userID");
return null;
}
const filteredData =
logs.data?.data?.filter((log) => {
const matchesSearch =
!searchTerm ||
log.request_id.includes(searchTerm) ||
log.model.includes(searchTerm) ||
(log.user && log.user.includes(searchTerm));
// Consolidate log details from queries
const logDetails: Record<string, any> = {};
logDetailsQueries.forEach((q, index) => {
const log = logs.data?.data[index];
if (log && q.data) {
logDetails[log.request_id] = q.data;
}
});
// No need for additional filtering since we're now handling this in the API call
return matchesSearch;
}) || [];
// Modify the filtered data to include log details
const filteredData =
logs.data?.data
?.filter((log) => {
const matchesSearch =
!searchTerm ||
log.request_id.includes(searchTerm) ||
log.model.includes(searchTerm) ||
(log.user && log.user.includes(searchTerm));
return matchesSearch;
})
.map(log => ({
...log,
// Include messages/response from cached details
messages: logDetails[log.request_id]?.messages || [],
response: logDetails[log.request_id]?.response || {},
})) || [];
// Add this function to handle manual refresh
const handleRefresh = () => {
@ -529,155 +573,3 @@ export default function SpendLogsTable({
</div>
);
}
function RequestViewer({ row }: { row: Row<LogEntry> }) {
const formatData = (input: any) => {
if (typeof input === "string") {
try {
return JSON.parse(input);
} catch {
return input;
}
}
return input;
};
return (
<div className="p-6 bg-gray-50 space-y-6">
{/* Combined Info Card */}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h3 className="text-lg font-medium ">Request Details</h3>
</div>
<div className="space-y-2 p-4 ">
<div className="flex">
<span className="font-medium w-1/3">Request ID:</span>
<span>{row.original.request_id}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Api Key:</span>
<span>{row.original.api_key}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Team ID:</span>
<span>{row.original.team_id}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Model:</span>
<span>{row.original.model}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Api Base:</span>
<span>{row.original.api_base}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Call Type:</span>
<span>{row.original.call_type}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Spend:</span>
<span>{row.original.spend}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Total Tokens:</span>
<span>{row.original.total_tokens}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Prompt Tokens:</span>
<span>{row.original.prompt_tokens}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Completion Tokens:</span>
<span>{row.original.completion_tokens}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Start Time:</span>
<span>{row.original.startTime}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">End Time:</span>
<span>{row.original.endTime}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Cache Hit:</span>
<span>{row.original.cache_hit}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Cache Key:</span>
<span>{row.original.cache_key}</span>
</div>
{row?.original?.requester_ip_address && (
<div className="flex">
<span className="font-medium w-1/3">Request IP Address:</span>
<span>{row?.original?.requester_ip_address}</span>
</div>
)}
</div>
</div>
{/* Request Card */}
<div className="bg-white rounded-lg shadow">
<div className="flex justify-between items-center p-4 border-b">
<h3 className="text-lg font-medium">Request Tags</h3>
</div>
<pre className="p-4 text-wrap overflow-auto text-sm">
{JSON.stringify(formatData(row.original.request_tags), null, 2)}
</pre>
</div>
{/* Request Card */}
<div className="bg-white rounded-lg shadow">
<div className="flex justify-between items-center p-4 border-b">
<h3 className="text-lg font-medium">Request</h3>
{/* <div>
<button className="mr-2 px-3 py-1 text-sm border rounded hover:bg-gray-50">
Expand
</button>
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">
JSON
</button>
</div> */}
</div>
<pre className="p-4 text-wrap overflow-auto text-sm">
{JSON.stringify(formatData(row.original.messages), null, 2)}
</pre>
</div>
{/* Response Card */}
<div className="bg-white rounded-lg shadow">
<div className="flex justify-between items-center p-4 border-b">
<h3 className="text-lg font-medium">Response</h3>
<div>
{/* <button className="mr-2 px-3 py-1 text-sm border rounded hover:bg-gray-50">
Expand
</button>
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">
JSON
</button> */}
</div>
</div>
<pre className="p-4 text-wrap overflow-auto text-sm">
{JSON.stringify(formatData(row.original.response), null, 2)}
</pre>
</div>
{/* Metadata Card */}
{row.original.metadata &&
Object.keys(row.original.metadata).length > 0 && (
<div className="bg-white rounded-lg shadow">
<div className="flex justify-between items-center p-4 border-b">
<h3 className="text-lg font-medium">Metadata</h3>
{/* <div>
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">
JSON
</button>
</div> */}
</div>
<pre className="p-4 text-wrap overflow-auto text-sm ">
{JSON.stringify(row.original.metadata, null, 2)}
</pre>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,23 @@
import { QueryClient } from "@tanstack/react-query";
import { uiSpendLogDetailsCall } from "../networking";
import { LogEntry } from "./columns";
export const prefetchLogDetails = (
logs: LogEntry[],
formattedStartTime: string,
accessToken: string,
queryClient: QueryClient
) => {
logs.forEach((log) => {
if (log.request_id) {
queryClient.prefetchQuery({
queryKey: ["logDetails", log.request_id, formattedStartTime],
queryFn: () => uiSpendLogDetailsCall(accessToken, log.request_id, formattedStartTime),
staleTime: 10 * 60 * 1000, // 10 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
}).catch((error) => {
console.error(`Failed to prefetch details for log: ${log.request_id}`, error);
});
}
});
};

View file

@ -0,0 +1,155 @@
import { Row } from "@tanstack/react-table";
import { LogEntry } from "./columns";
export function RequestViewer({ row }: { row: Row<LogEntry> }) {
const formatData = (input: any) => {
if (typeof input === "string") {
try {
return JSON.parse(input);
} catch {
return input;
}
}
return input;
};
return (
<div className="p-6 bg-gray-50 space-y-6">
{/* Combined Info Card */}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h3 className="text-lg font-medium ">Request Details</h3>
</div>
<div className="space-y-2 p-4 ">
<div className="flex">
<span className="font-medium w-1/3">Request ID:</span>
<span>{row.original.request_id}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Api Key:</span>
<span>{row.original.api_key}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Team ID:</span>
<span>{row.original.team_id}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Model:</span>
<span>{row.original.model}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Api Base:</span>
<span>{row.original.api_base}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Call Type:</span>
<span>{row.original.call_type}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Spend:</span>
<span>{row.original.spend}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Total Tokens:</span>
<span>{row.original.total_tokens}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Prompt Tokens:</span>
<span>{row.original.prompt_tokens}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Completion Tokens:</span>
<span>{row.original.completion_tokens}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Start Time:</span>
<span>{row.original.startTime}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">End Time:</span>
<span>{row.original.endTime}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Cache Hit:</span>
<span>{row.original.cache_hit}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Cache Key:</span>
<span>{row.original.cache_key}</span>
</div>
{row?.original?.requester_ip_address && (
<div className="flex">
<span className="font-medium w-1/3">Request IP Address:</span>
<span>{row?.original?.requester_ip_address}</span>
</div>
)}
</div>
</div>
{/* Request Card */}
<div className="bg-white rounded-lg shadow">
<div className="flex justify-between items-center p-4 border-b">
<h3 className="text-lg font-medium">Request Tags</h3>
</div>
<pre className="p-4 text-wrap overflow-auto text-sm">
{JSON.stringify(formatData(row.original.request_tags), null, 2)}
</pre>
</div>
{/* Request Card */}
<div className="bg-white rounded-lg shadow">
<div className="flex justify-between items-center p-4 border-b">
<h3 className="text-lg font-medium">Request</h3>
{/* <div>
<button className="mr-2 px-3 py-1 text-sm border rounded hover:bg-gray-50">
Expand
</button>
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">
JSON
</button>
</div> */}
</div>
<pre className="p-4 text-wrap overflow-auto text-sm">
{JSON.stringify(formatData(row.original.messages), null, 2)}
</pre>
</div>
{/* Response Card */}
<div className="bg-white rounded-lg shadow">
<div className="flex justify-between items-center p-4 border-b">
<h3 className="text-lg font-medium">Response</h3>
<div>
{/* <button className="mr-2 px-3 py-1 text-sm border rounded hover:bg-gray-50">
Expand
</button>
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">
JSON
</button> */}
</div>
</div>
<pre className="p-4 text-wrap overflow-auto text-sm">
{JSON.stringify(formatData(row.original.response), null, 2)}
</pre>
</div>
{/* Metadata Card */}
{row.original.metadata &&
Object.keys(row.original.metadata).length > 0 && (
<div className="bg-white rounded-lg shadow">
<div className="flex justify-between items-center p-4 border-b">
<h3 className="text-lg font-medium">Metadata</h3>
{/* <div>
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">
JSON
</button>
</div> */}
</div>
<pre className="p-4 text-wrap overflow-auto text-sm ">
{JSON.stringify(row.original.metadata, null, 2)}
</pre>
</div>
)}
</div>
);
}