(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.services import ServiceLoggerPayload
from litellm.types.utils import StandardLoggingPayload 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 DD_MAX_BATCH_SIZE = 1000 # max number of logs DD API can accept
class DataDogLogger( class DataDogLogger(
CustomBatchLogger, CustomBatchLogger,
HealthCheckIntegration, AdditionalLoggingUtils,
): ):
# Class variables or attributes # Class variables or attributes
def __init__( def __init__(
@ -543,3 +543,13 @@ class DataDogLogger(
status="unhealthy", status="unhealthy",
error_message=str(e), 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 asyncio
import json
import os import os
import uuid import uuid
from datetime import datetime from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Dict, List, Optional from typing import TYPE_CHECKING, Any, Dict, List, Optional
from urllib.parse import quote
from litellm._logging import verbose_logger 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.integrations.gcs_bucket.gcs_bucket_base import GCSBucketBase
from litellm.proxy._types import CommonProxyErrors 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.integrations.gcs_bucket import *
from litellm.types.utils import StandardLoggingPayload from litellm.types.utils import StandardLoggingPayload
@ -20,7 +24,7 @@ GCS_DEFAULT_BATCH_SIZE = 2048
GCS_DEFAULT_FLUSH_INTERVAL_SECONDS = 20 GCS_DEFAULT_FLUSH_INTERVAL_SECONDS = 20
class GCSBucketLogger(GCSBucketBase): class GCSBucketLogger(GCSBucketBase, AdditionalLoggingUtils):
def __init__(self, bucket_name: Optional[str] = None) -> None: def __init__(self, bucket_name: Optional[str] = None) -> None:
from litellm.proxy.proxy_server import premium_user from litellm.proxy.proxy_server import premium_user
@ -39,6 +43,7 @@ class GCSBucketLogger(GCSBucketBase):
batch_size=self.batch_size, batch_size=self.batch_size,
flush_interval=self.flush_interval, flush_interval=self.flush_interval,
) )
AdditionalLoggingUtils.__init__(self)
if premium_user is not True: if premium_user is not True:
raise ValueError( raise ValueError(
@ -150,11 +155,16 @@ class GCSBucketLogger(GCSBucketBase):
""" """
Get the object name to use for the current payload 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: 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: 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 # used for testing
_litellm_params = kwargs.get("litellm_params", None) or {} _litellm_params = kwargs.get("litellm_params", None) or {}
@ -163,3 +173,65 @@ class GCSBucketLogger(GCSBucketBase):
object_name = _metadata["gcs_log_id"] object_name = _metadata["gcs_log_id"]
return object_name 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 import litellm
from litellm._logging import verbose_logger from litellm._logging import verbose_logger
from litellm.integrations.additional_logging_utils import AdditionalLoggingUtils
from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.custom_logger import CustomLogger
@ -220,3 +221,36 @@ class LoggingCallbackManager:
litellm._async_success_callback = [] litellm._async_success_callback = []
litellm._async_failure_callback = [] litellm._async_failure_callback = []
litellm.callbacks = [] 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_key: my-fake-key
api_base: https://exampleopenaiendpoint-production.up.railway.app/ api_base: https://exampleopenaiendpoint-production.up.railway.app/
litellm_settings:
cache: True general_settings:
cache_params: store_model_in_db: true
type: redis
litellm_settings:
callbacks: ["gcs_bucket"]

View file

@ -2,6 +2,7 @@
import collections import collections
import os import os
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from functools import lru_cache
from typing import TYPE_CHECKING, Any, List, Optional from typing import TYPE_CHECKING, Any, List, Optional
import fastapi import fastapi
@ -1759,6 +1760,56 @@ async def ui_view_spend_logs( # noqa: PLR0915
raise handle_exception_on_proxy(e) 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( @router.get(
"/spend/logs", "/spend/logs",
tags=["Budget & Spend Tracking"], tags=["Budget & Spend Tracking"],

View file

@ -11,6 +11,7 @@ sys.path.insert(0, os.path.abspath("../.."))
@pytest.fixture() @pytest.fixture()
@pytest.mark.skip(reason="Traceloop use `otel` integration instead")
def exporter(): def exporter():
from traceloop.sdk import Traceloop 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.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): def test_traceloop_logging(exporter, model):
litellm.completion( litellm.completion(
model=model, 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; 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 moment from "moment";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueries, useQueryClient } from "@tanstack/react-query";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { uiSpendLogsCall } from "../networking"; import { uiSpendLogsCall, uiSpendLogDetailsCall } from "../networking";
import { DataTable } from "./table"; import { DataTable } from "./table";
import { columns, LogEntry } from "./columns"; import { columns, LogEntry } from "./columns";
import { Row } from "@tanstack/react-table"; import { RequestViewer } from "./request_viewer";
import { prefetchLogDetails } from "./prefetch";
interface SpendLogsTableProps { interface SpendLogsTableProps {
accessToken: string | null; accessToken: string | null;
@ -54,6 +55,8 @@ export default function SpendLogsTable({
const [selectedKeyHash, setSelectedKeyHash] = useState(""); const [selectedKeyHash, setSelectedKeyHash] = useState("");
const [selectedFilter, setSelectedFilter] = useState("Team ID"); const [selectedFilter, setSelectedFilter] = useState("Team ID");
const queryClient = useQueryClient();
// Close dropdown when clicking outside // Close dropdown when clicking outside
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
@ -82,6 +85,7 @@ export default function SpendLogsTable({
document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("mousedown", handleClickOutside);
}, []); }, []);
// Update the logs query to use the imported prefetchLogDetails
const logs = useQuery<PaginatedResponse>({ const logs = useQuery<PaginatedResponse>({
queryKey: [ queryKey: [
"logs", "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 formattedStartTime = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss");
const formattedEndTime = isCustomDate const formattedEndTime = isCustomDate
? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss") ? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss")
: moment().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, accessToken,
selectedKeyHash || undefined, selectedKeyHash || undefined,
selectedTeamId || undefined, selectedTeamId || undefined,
@ -121,30 +133,62 @@ export default function SpendLogsTable({
currentPage, currentPage,
pageSize 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, enabled: !!accessToken && !!token && !!userRole && !!userID,
refetchInterval: 5000, refetchInterval: 5000,
refetchIntervalInBackground: true, 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) { if (!accessToken || !token || !userRole || !userID) {
console.log( console.log("got None values for one of accessToken, token, userRole, userID");
"got None values for one of accessToken, token, userRole, userID",
);
return null; return null;
} }
const filteredData = // Consolidate log details from queries
logs.data?.data?.filter((log) => { const logDetails: Record<string, any> = {};
const matchesSearch = logDetailsQueries.forEach((q, index) => {
!searchTerm || const log = logs.data?.data[index];
log.request_id.includes(searchTerm) || if (log && q.data) {
log.model.includes(searchTerm) || logDetails[log.request_id] = q.data;
(log.user && log.user.includes(searchTerm)); }
});
// No need for additional filtering since we're now handling this in the API call // Modify the filtered data to include log details
return matchesSearch; 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 // Add this function to handle manual refresh
const handleRefresh = () => { const handleRefresh = () => {
@ -529,155 +573,3 @@ export default function SpendLogsTable({
</div> </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>
);
}