From 05600cb6a073a2e15f19dd8f602c1f6295f0525c Mon Sep 17 00:00:00 2001 From: Chuck Pearce Date: Wed, 17 Jun 2026 09:03:35 -0400 Subject: [PATCH 01/16] feat(logging): add optional PostgreSQL request logging support This commit introduces a new feature for optional HTTP request logging to PostgreSQL. The following changes were made: - Added new environment variables for configuring request logging in `lemond.service.in` and `secrets.conf`. - Updated `AGENTS.md` to document new request log endpoints and configuration options. - Modified `CMakeLists.txt` to include PostgreSQL support and conditionally compile the request logging service. - Implemented request log handling in the server, including new API endpoints for retrieving recent logs, searching logs, and statistics. - Added tests for the request log parser to ensure functionality. This feature enhances the observability of the server by allowing users to log and review HTTP requests, improving debugging and monitoring capabilities. --- AGENTS.md | 8 +- CMakeLists.txt | 42 + data/lemond.service.in | 4 + data/secrets.conf | 4 + docs/guide/configuration/README.md | 4 + docs/guide/configuration/request-log.md | 203 +++++ examples/docker-compose.request-log.yml | 14 + examples/start-request-log-db.sh | 62 ++ sql/request_logs_init.sql | 29 + src/cpp/include/lemon/request_log_handlers.h | 21 + src/cpp/include/lemon/request_log_parser.h | 35 + src/cpp/include/lemon/request_log_service.h | 107 +++ src/cpp/include/lemon/server.h | 5 + src/cpp/server/request_log_handlers.cpp | 115 +++ src/cpp/server/request_log_parser.cpp | 340 ++++++++ src/cpp/server/request_log_service.cpp | 795 +++++++++++++++++++ src/cpp/server/server.cpp | 42 + test/cpp/test_request_log_parser.cpp | 101 +++ test/server_endpoints.py | 3 + test/server_request_log.py | 125 +++ 20 files changed, 2057 insertions(+), 2 deletions(-) create mode 100644 docs/guide/configuration/request-log.md create mode 100644 examples/docker-compose.request-log.yml create mode 100755 examples/start-request-log-db.sh create mode 100644 sql/request_logs_init.sql create mode 100644 src/cpp/include/lemon/request_log_handlers.h create mode 100644 src/cpp/include/lemon/request_log_parser.h create mode 100644 src/cpp/include/lemon/request_log_service.h create mode 100644 src/cpp/server/request_log_handlers.cpp create mode 100644 src/cpp/server/request_log_parser.cpp create mode 100644 src/cpp/server/request_log_service.cpp create mode 100644 test/cpp/test_request_log_parser.cpp create mode 100644 test/server_request_log.py diff --git a/AGENTS.md b/AGENTS.md index 71ccd1a1a..dc72888b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ All core endpoints are registered under **4 path prefixes**: - `/v0/` — Legacy short - `/v1/` — OpenAI SDK / LiteLLM compatibility -**Core endpoints:** `chat/completions`, `completions`, `embeddings`, `reranking`, `models`, `models/{id}`, `health`, `pull`, `load`, `unload`, `delete`, `params`, `install`, `uninstall`, `audio/transcriptions`, `audio/speech`, `images/generations`, `images/edits`, `images/variations`, `responses`, `stats`, `system-info`, `system-stats`, `log-level`, `logs/stream` +**Core endpoints:** `chat/completions`, `completions`, `embeddings`, `reranking`, `models`, `models/{id}`, `health`, `pull`, `load`, `unload`, `delete`, `params`, `install`, `uninstall`, `audio/transcriptions`, `audio/speech`, `images/generations`, `images/edits`, `images/variations`, `responses`, `stats`, `system-info`, `system-stats`, `log-level`, `logs/stream`, `request-log/recent`, `request-log/search`, `request-log/stats` **Ollama-compatible endpoints** (under `/api/` without version prefix): `chat`, `generate`, `tags`, `show`, `delete`, `pull`, `embed`, `embeddings`, `ps`, `version` @@ -69,7 +69,7 @@ Optional API key auth via `LEMONADE_API_KEY` env var (regular API endpoints) or ### Key Dependencies -**C++ (FetchContent):** cpp-httplib, nlohmann/json, CLI11, libcurl, zstd, libwebsockets, brotli (macOS). Platform SSL: Schannel (Windows), SecureTransport (macOS), OpenSSL (Linux). +**C++ (FetchContent):** cpp-httplib, nlohmann/json, CLI11, libcurl, zstd, libwebsockets, brotli (macOS). Platform SSL: Schannel (Windows), SecureTransport (macOS), OpenSSL (Linux). Optional **libpq** (PostgreSQL client) when `LEMONADE_REQUEST_LOG=ON` for HTTP request logging — see `docs/guide/configuration/request-log.md`. **Desktop app:** Tauri v2 (Rust), React 19, TypeScript 5.3, Webpack 5, markdown-it, highlight.js, katex. Rust crates: `tauri`, `tauri-plugin-{opener,clipboard-manager,single-instance,deep-link}`, `tokio`, `reqwest`, `serde`. @@ -133,6 +133,9 @@ python test/server_whisper.py # Image generation tests (slow) python test/server_sd.py + +# Request log review API tests (optional PostgreSQL) +python test/server_request_log.py ``` Test utilities in `test/utils/` with `server_base.py` as the base class. Test dependencies include `requests`, `httpx`, `openai`, `huggingface_hub`, `psutil`, `numpy`, `websockets`, and `ollama`. @@ -170,6 +173,7 @@ Test utilities in `test/utils/` with `server_base.py` as the base class. Test de | `src/cpp/server/anthropic_api.cpp` | Anthropic API compatibility | | `src/cpp/server/ollama_api.cpp` | Ollama API compatibility | | `src/cpp/server/mcp_server.cpp` | MCP gateway (POST /mcp) | +| `src/cpp/server/request_log_service.cpp` | PostgreSQL HTTP request logging (optional libpq) | | `src/cpp/include/lemon/websocket_server.h` | WebSocket Realtime API server | | `src/cpp/include/lemon/model_types.h` | Model type and device type enums | | `src/cpp/include/lemon/config_file.h` | config.json load/save/migrate | diff --git a/CMakeLists.txt b/CMakeLists.txt index 7e125642f..1b1b7466c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -625,6 +625,9 @@ set(SOURCES_CORE src/cpp/server/vad.cpp src/cpp/server/realtime_session.cpp src/cpp/server/websocket_server.cpp + src/cpp/server/request_log_parser.cpp + src/cpp/server/request_log_handlers.cpp + src/cpp/server/request_log_service.cpp ) # Add platform-specific source files to core @@ -650,6 +653,18 @@ endif() # ============================================================ # Server core OBJECT library (shared by lemond and Lemonade.exe) # ============================================================ +option(LEMONADE_REQUEST_LOG "Build PostgreSQL request logging support" ON) +if(LEMONADE_REQUEST_LOG) + find_package(PostgreSQL QUIET) + if(PostgreSQL_FOUND) + message(STATUS "PostgreSQL request logging enabled (libpq found)") + else() + message(STATUS "PostgreSQL libpq not found; request logging will compile without persistence") + endif() +else() + message(STATUS "PostgreSQL request logging disabled (LEMONADE_REQUEST_LOG=OFF)") +endif() + add_library(lemonade-server-core OBJECT ${SOURCES_CORE}) # ============================================================ @@ -726,6 +741,12 @@ else() target_include_directories(lemonade-server-core PUBLIC ${libwebsockets_BINARY_DIR}/include ${libwebsockets_SOURCE_DIR}/include) endif() +if(LEMONADE_REQUEST_LOG AND PostgreSQL_FOUND) + target_compile_definitions(lemonade-server-core PUBLIC LEMONADE_HAVE_REQUEST_LOG) + target_link_libraries(lemonade-server-core PUBLIC PostgreSQL::PostgreSQL) + target_include_directories(lemonade-server-core PUBLIC ${PostgreSQL_INCLUDE_DIRS}) +endif() + # Enable ARC (Automatic Reference Counting) for macOS Objective-C++ files if(APPLE) target_compile_options(lemonade-server-core PUBLIC -fobjc-arc) @@ -1720,3 +1741,24 @@ if(EXISTS "${_GGUF_CAPS_TEST_SRC}") include(CTest) add_test(NAME GgufCapabilitiesTest COMMAND test_gguf_capabilities) endif() + +set(_REQUEST_LOG_PARSER_TEST_SRC + "${CMAKE_CURRENT_SOURCE_DIR}/test/cpp/test_request_log_parser.cpp" +) +set(_REQUEST_LOG_PARSER_LIB_SRC + "${CMAKE_CURRENT_SOURCE_DIR}/src/cpp/server/request_log_parser.cpp" +) +if(EXISTS "${_REQUEST_LOG_PARSER_TEST_SRC}" AND EXISTS "${_REQUEST_LOG_PARSER_LIB_SRC}") + add_executable(test_request_log_parser + test/cpp/test_request_log_parser.cpp + src/cpp/server/request_log_parser.cpp + ) + target_include_directories(test_request_log_parser PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src/cpp/include + ${CMAKE_CURRENT_BINARY_DIR}/include + ) + target_link_libraries(test_request_log_parser PRIVATE nlohmann_json::nlohmann_json) + + include(CTest) + add_test(NAME RequestLogParserTest COMMAND test_request_log_parser) +endif() diff --git a/data/lemond.service.in b/data/lemond.service.in index 7bd816ba5..2aa90786a 100644 --- a/data/lemond.service.in +++ b/data/lemond.service.in @@ -17,6 +17,10 @@ WorkingDirectory=%S/lemonade # HF_TOKEN — HuggingFace authentication token # LEMONADE_API_KEY — require API-key auth on all routes # LEMONADE_ADMIN_API_KEY — API-key specific to internal routes +# LEMONADE_REQUEST_LOG_ENABLED — enable PostgreSQL HTTP request logging +# LEMONADE_REQUEST_LOG_DATABASE_URL — PostgreSQL URL for request logs +# LEMONADE_REQUEST_LOG_RETENTION_DAYS — retention days (-1 forever, 0 purge all hourly) +# LEMONADE_LOG_PROMPTS — store prompt/message content in request logs (default false) EnvironmentFile=-/etc/lemonade/conf.d/*.conf ExecStart=@CMAKE_INSTALL_FULL_BINDIR@/lemond ExecReload=/bin/kill -HUP $MAINPID diff --git a/data/secrets.conf b/data/secrets.conf index 4bb39691f..20cd3b8cb 100644 --- a/data/secrets.conf +++ b/data/secrets.conf @@ -1,3 +1,7 @@ # Installed as /etc/lemonade/conf.d/zz-secrets.conf so it loads after other # drop-ins and keeps secrets separate from the base config file. #LEMONADE_API_KEY= +#LEMONADE_REQUEST_LOG_ENABLED=true +#LEMONADE_REQUEST_LOG_DATABASE_URL=postgresql://lemonade:change-me@127.0.0.1:5433/lemonade_logs +#LEMONADE_REQUEST_LOG_RETENTION_DAYS=30 +#LEMONADE_LOG_PROMPTS=false diff --git a/docs/guide/configuration/README.md b/docs/guide/configuration/README.md index 93977148c..84f45af22 100644 --- a/docs/guide/configuration/README.md +++ b/docs/guide/configuration/README.md @@ -313,6 +313,10 @@ The `LEMONADE_ADMIN_API_KEY` environment variable provides elevated access to bo **Client Behavior:** Clients (CLI, tray app) automatically prefer `LEMONADE_ADMIN_API_KEY` if set, otherwise fall back to `LEMONADE_API_KEY`. +### Request logging (PostgreSQL) + +Optional HTTP request logging to PostgreSQL is configured via environment variables (not `config.json`). See [Request Logging](./request-log.md) for setup, retention, privacy behavior, review API endpoints, and Docker/database bootstrap. + ## Remote Server Connection To make Lemonade Server accessible from other machines on your network, set the host to `0.0.0.0`: diff --git a/docs/guide/configuration/request-log.md b/docs/guide/configuration/request-log.md new file mode 100644 index 000000000..7a7d272e6 --- /dev/null +++ b/docs/guide/configuration/request-log.md @@ -0,0 +1,203 @@ +# Request Logging (PostgreSQL) + +Lemonade can persist HTTP request metadata to PostgreSQL for client auditing and troubleshooting. This is useful for identifying which clients send inference requests, especially calls that set `keep_alive` or pin models in VRAM. + +## Prerequisites + +- `lemond` built with libpq support (default when `libpq` development headers are installed) +- PostgreSQL 16+ (local install or Docker) + +Linux packages: + +```bash +# Debian/Ubuntu +sudo apt install libpq-dev + +# Fedora/RHEL +sudo dnf install postgresql-devel +``` + +macOS: + +```bash +brew install libpq +export PKG_CONFIG_PATH="$(brew --prefix libpq)/lib/pkgconfig" +``` + +## Quick start with Docker + +From the repository root: + +```bash +./examples/start-request-log-db.sh +``` + +This starts PostgreSQL on port `5433` and prints the connection URL. + +Suggested env drop-in for systemd (`/etc/lemonade/conf.d/request-log.conf`): + +```ini +LEMONADE_REQUEST_LOG_ENABLED=true +LEMONADE_REQUEST_LOG_DATABASE_URL=postgresql://lemonade:change-me@127.0.0.1:5433/lemonade_logs +LEMONADE_REQUEST_LOG_RETENTION_DAYS=30 +LEMONADE_LOG_PROMPTS=false +``` + +Restart `lemond` after adding the env file. + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `LEMONADE_REQUEST_LOG_ENABLED` | `false` | Master switch for database request logging | +| `LEMONADE_REQUEST_LOG_DATABASE_URL` | (empty) | PostgreSQL URL, e.g. `postgresql://user:pass@host:5432/lemonade_logs` | +| `LEMONADE_REQUEST_LOG_RETENTION_DAYS` | `30` | Retention policy (see below) | +| `LEMONADE_LOG_PROMPTS` | `false` | When `true`, store prompt/message content in `redacted_body` | + +If logging is enabled but PostgreSQL is unreachable, Lemonade logs a warning and continues serving requests. + +## Retention behavior + +| Value | Behavior | +|-------|----------| +| `-1` | Keep rows forever (purge disabled) | +| `0` | Write rows normally, but purge **all** rows on each hourly purge cycle | +| `N > 0` | Delete rows older than `N` days during hourly purge | + +## Privacy and redaction + +- Authorization headers are never stored. +- JSON body fields matching sensitive key names (`api_key`, `token`, `password`, `secret`, etc.) are replaced with `[REDACTED]`. +- When `LEMONADE_LOG_PROMPTS=false` (default), only character counts are stored for `prompt` and `messages`. +- Full prompt/message content is stored only when `LEMONADE_LOG_PROMPTS=true`. + +## Review API + +All endpoints are registered under the standard quad-prefix paths (`/api/v0/`, `/api/v1/`, `/v0/`, `/v1/`). + +| Endpoint | Description | +|----------|-------------| +| `GET /api/v1/request-log/recent?limit=100` | Newest entries (max 1000) | +| `GET /api/v1/request-log/search?model=&client_ip=&path=&since=&keep_alive=` | Filtered search | +| `GET /api/v1/request-log/stats?since=24h` | Aggregates for the time window | + +When `LEMONADE_API_KEY` is set, these endpoints require Bearer authentication (admin key also accepted). + +### Example queries + +Recent entries: + +```bash +curl -s 'http://127.0.0.1:13305/api/v1/request-log/recent?limit=20' \ + -H "Authorization: Bearer $LEMONADE_API_KEY" | jq . +``` + +Find Ollama unload requests (`keep_alive=0`): + +```bash +curl -s 'http://127.0.0.1:13305/api/v1/request-log/search?keep_alive=0&limit=50' \ + -H "Authorization: Bearer $LEMONADE_API_KEY" | jq . +``` + +Stats for the last hour: + +```bash +curl -s 'http://127.0.0.1:13305/api/v1/request-log/stats?since=1h' \ + -H "Authorization: Bearer $LEMONADE_API_KEY" | jq . +``` + +Generate a sample request to verify logging: + +```bash +curl -s -X POST http://127.0.0.1:13305/api/chat \ + -H 'Content-Type: application/json' \ + -d '{"model":"llama3.2","messages":[],"keep_alive":0}' +``` + +## SQL examples + +Connect to the database: + +```bash +psql postgresql://lemonade:change-me@127.0.0.1:5433/lemonade_logs +``` + +Clients sending `keep_alive`: + +```sql +SELECT created_at, client_ip, path, model, keep_alive +FROM request_logs +WHERE keep_alive IS NOT NULL +ORDER BY created_at DESC +LIMIT 50; +``` + +Load requests with pinned models: + +```sql +SELECT created_at, client_ip, model, redacted_body->'_meta'->>'pinned' AS pinned +FROM request_logs +WHERE path LIKE '%/load' + AND redacted_body->'_meta'->>'pinned' = 'true' +ORDER BY created_at DESC; +``` + +## Build + +```bash +./setup.sh +cmake --build --preset default +``` + +The binary is `build/lemond`. + +To disable request-log support at compile time: + +```bash +cmake -DLEMONADE_REQUEST_LOG=OFF --preset default +``` + +## Safe systemd upgrade + +```bash +./examples/start-request-log-db.sh + +sudo tee /etc/lemonade/conf.d/request-log.conf <<'EOF' +LEMONADE_REQUEST_LOG_ENABLED=true +LEMONADE_REQUEST_LOG_DATABASE_URL=postgresql://lemonade:change-me@127.0.0.1:5433/lemonade_logs +LEMONADE_REQUEST_LOG_RETENTION_DAYS=30 +LEMONADE_LOG_PROMPTS=false +EOF + +sudo systemctl stop lemond.service +sudo cp build/lemond /usr/bin/lemond +sudo systemctl daemon-reload +sudo systemctl start lemond.service +sudo systemctl status lemond.service +``` + +## Patch workflow for upstream rebases + +Keep changes on a feature branch and export a patch: + +```bash +git checkout -b feature/request-log-db +# ... commit changes ... +git format-patch main --stdout > request-log.patch +``` + +Apply on a fresh branch: + +```bash +git checkout -b feature/request-log-db main +git apply request-log.patch +``` + +## Limitations + +- Streaming/chunked responses may report `response_body_bytes=0` because the body is not buffered. +- WebSocket traffic (`/realtime`, `/logs/stream`) is not logged by this subsystem. + +## Manual schema init + +The server creates the schema automatically on startup. You can also apply [`sql/request_logs_init.sql`](../../sql/request_logs_init.sql) manually. diff --git a/examples/docker-compose.request-log.yml b/examples/docker-compose.request-log.yml new file mode 100644 index 000000000..76a734a64 --- /dev/null +++ b/examples/docker-compose.request-log.yml @@ -0,0 +1,14 @@ +services: + lemonade-request-log-db: + image: postgres:16 + environment: + POSTGRES_DB: lemonade_logs + POSTGRES_USER: lemonade + POSTGRES_PASSWORD: change-me + volumes: + - lemonade_request_logs:/var/lib/postgresql/data + ports: + - "5433:5432" + +volumes: + lemonade_request_logs: diff --git a/examples/start-request-log-db.sh b/examples/start-request-log-db.sh new file mode 100755 index 000000000..aec42f222 --- /dev/null +++ b/examples/start-request-log-db.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +COMPOSE_FILE="${REPO_ROOT}/examples/docker-compose.request-log.yml" +SERVICE_NAME="lemonade-request-log-db" +DB_HOST="127.0.0.1" +DB_PORT="5433" +DB_USER="lemonade" +DB_PASSWORD="change-me" +DB_NAME="lemonade_logs" +DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}" + +if ! command -v docker >/dev/null 2>&1; then + echo "Error: docker is required but not installed." >&2 + exit 1 +fi + +if docker compose version >/dev/null 2>&1; then + COMPOSE=(docker compose) +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE=(docker-compose) +else + echo "Error: docker compose or docker-compose is required." >&2 + exit 1 +fi + +echo "Starting PostgreSQL request log database..." +"${COMPOSE[@]}" -f "${COMPOSE_FILE}" up -d + +echo "Waiting for PostgreSQL to accept connections on ${DB_HOST}:${DB_PORT}..." +ready=0 +for _ in $(seq 1 60); do + if "${COMPOSE[@]}" -f "${COMPOSE_FILE}" exec -T "${SERVICE_NAME}" \ + pg_isready -U "${DB_USER}" -d "${DB_NAME}" >/dev/null 2>&1; then + ready=1 + break + fi + sleep 1 +done + +if [[ "${ready}" -ne 1 ]]; then + echo "Error: PostgreSQL did not become ready in time." >&2 + exit 1 +fi + +cat < + +namespace lemon { + +class RequestLogService; + +void handle_request_log_recent(RequestLogService* service, + const httplib::Request& req, + httplib::Response& res); + +void handle_request_log_search(RequestLogService* service, + const httplib::Request& req, + httplib::Response& res); + +void handle_request_log_stats(RequestLogService* service, + const httplib::Request& req, + httplib::Response& res); + +} // namespace lemon diff --git a/src/cpp/include/lemon/request_log_parser.h b/src/cpp/include/lemon/request_log_parser.h new file mode 100644 index 000000000..a1c98032c --- /dev/null +++ b/src/cpp/include/lemon/request_log_parser.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +namespace lemon { + +struct ParsedRequestBody { + std::string model; + std::string keep_alive; + std::optional stream; + int prompt_chars = 0; + int messages_chars = 0; + nlohmann::json redacted_body; + bool has_redacted_body = false; +}; + +std::string classify_endpoint_type(const std::string& path, const std::string& method); + +std::string extract_forwarded_for(const std::string& x_forwarded_for, + const std::string& x_real_ip, + const std::string& forwarded); + +ParsedRequestBody parse_request_body(const std::string& body, + const std::string& path, + bool log_prompts); + +nlohmann::json redact_json(const nlohmann::json& value); + +std::string extract_response_error(const std::string& response_body, int status_code); + +bool should_skip_request_log_path(const std::string& path, const std::string& method); + +} // namespace lemon diff --git a/src/cpp/include/lemon/request_log_service.h b/src/cpp/include/lemon/request_log_service.h new file mode 100644 index 000000000..473bc2ce0 --- /dev/null +++ b/src/cpp/include/lemon/request_log_service.h @@ -0,0 +1,107 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace lemon { + +struct RequestLogEntry { + std::string client_ip; + std::string forwarded_for; + std::string method; + std::string path; + std::string query_string; + int status_code = 0; + int duration_ms = 0; + std::string user_agent; + std::string endpoint_type; + std::string model; + std::string keep_alive; + std::optional stream; + int request_body_bytes = 0; + int response_body_bytes = 0; + int prompt_chars = 0; + int messages_chars = 0; + std::string redacted_body_json; + bool has_redacted_body = false; + std::string error; + std::string request_body; +}; + +class RequestLogService { +public: + static std::unique_ptr from_env(); + + RequestLogService(bool enabled, + std::string database_url, + int retention_days, + bool log_prompts, + bool database_available); + + ~RequestLogService(); + + RequestLogService(const RequestLogService&) = delete; + RequestLogService& operator=(const RequestLogService&) = delete; + + bool is_enabled() const { return enabled_; } + bool is_database_available() const { return database_available_.load(); } + + void start(); + void stop(); + + void mark_request_start(); + void log_response(const httplib::Request& req, const httplib::Response& res); + + nlohmann::json get_recent(int limit) const; + nlohmann::json search(const httplib::Request& req) const; + nlohmann::json get_stats(const httplib::Request& req) const; + +private: + void writer_loop(); + void purge_loop(); + bool ensure_connection(); + void close_connection(); + bool init_schema(); + bool insert_entries(const std::vector& entries); + void run_purge(); + nlohmann::json row_to_json(int row) const; + +#ifdef LEMONADE_HAVE_REQUEST_LOG + mutable std::mutex db_mutex_; + void* pg_conn_ = nullptr; // PGconn*, opaque to avoid header dependency +#endif + + bool enabled_; + std::string database_url_; + int retention_days_; + bool log_prompts_; + std::atomic database_available_; + + std::atomic running_{false}; + std::thread writer_thread_; + std::thread purge_thread_; + + mutable std::mutex queue_mutex_; + std::condition_variable queue_cv_; + std::deque queue_; + static constexpr size_t kMaxQueueSize = 10000; + + std::atomic last_drop_warning_ms_{0}; +}; + +void request_log_mark_start(); +int64_t request_log_elapsed_ms(); + +} // namespace lemon diff --git a/src/cpp/include/lemon/server.h b/src/cpp/include/lemon/server.h index 5057d006f..63580548d 100644 --- a/src/cpp/include/lemon/server.h +++ b/src/cpp/include/lemon/server.h @@ -24,6 +24,7 @@ #include "websocket_server.h" #include "lemon/utils/network_beacon.h" #include "lemon/system_metrics_platform.h" +#include "lemon/request_log_service.h" namespace lemon { @@ -125,6 +126,9 @@ class Server { void handle_system_info(const httplib::Request& req, httplib::Response& res); void handle_system_stats(const httplib::Request& req, httplib::Response& res); void handle_log_level(const httplib::Request& req, httplib::Response& res); + void handle_request_log_recent(const httplib::Request& req, httplib::Response& res); + void handle_request_log_search(const httplib::Request& req, httplib::Response& res); + void handle_request_log_stats(const httplib::Request& req, httplib::Response& res); void handle_shutdown(const httplib::Request& req, httplib::Response& res); void handle_simulate_vram_pressure(const httplib::Request& req, httplib::Response& res); @@ -259,6 +263,7 @@ class Server { std::unique_ptr backend_manager_; std::unique_ptr cloud_registry_; std::unique_ptr websocket_server_; + std::unique_ptr request_log_service_; std::mutex downloads_mutex_; std::map> download_jobs_; diff --git a/src/cpp/server/request_log_handlers.cpp b/src/cpp/server/request_log_handlers.cpp new file mode 100644 index 000000000..4038324bc --- /dev/null +++ b/src/cpp/server/request_log_handlers.cpp @@ -0,0 +1,115 @@ +#include "lemon/request_log_handlers.h" + +#include "lemon/request_log_service.h" +#include "lemon/utils/aixlog.hpp" + +#include + +namespace lemon { + +namespace { + +void set_service_unavailable(httplib::Response& res, const std::string& message) { + res.status = 503; + nlohmann::json error = {{"error", message}}; + res.set_content(error.dump(), "application/json"); +} + +} // namespace + +void handle_request_log_recent(RequestLogService* service, + const httplib::Request& req, + httplib::Response& res) { + if (req.method == "HEAD") { + res.status = 200; + return; + } + + if (!service || !service->is_enabled()) { + set_service_unavailable(res, "Request logging is not enabled"); + return; + } + if (!service->is_database_available()) { + set_service_unavailable(res, "Request log database is unavailable"); + return; + } + + int limit = 100; + if (req.has_param("limit")) { + try { + limit = std::stoi(req.get_param_value("limit")); + if (limit < 1) { + limit = 1; + } + if (limit > 1000) { + limit = 1000; + } + } catch (...) { + res.status = 400; + res.set_content(R"({"error":"Invalid limit parameter"})", "application/json"); + return; + } + } + + try { + const auto payload = service->get_recent(limit); + res.set_content(payload.dump(), "application/json"); + } catch (const std::exception& e) { + LOG(ERROR, "RequestLog") << "handle_request_log_recent failed: " << e.what() << std::endl; + set_service_unavailable(res, e.what()); + } +} + +void handle_request_log_search(RequestLogService* service, + const httplib::Request& req, + httplib::Response& res) { + if (req.method == "HEAD") { + res.status = 200; + return; + } + + if (!service || !service->is_enabled()) { + set_service_unavailable(res, "Request logging is not enabled"); + return; + } + if (!service->is_database_available()) { + set_service_unavailable(res, "Request log database is unavailable"); + return; + } + + try { + const auto payload = service->search(req); + res.set_content(payload.dump(), "application/json"); + } catch (const std::exception& e) { + LOG(ERROR, "RequestLog") << "handle_request_log_search failed: " << e.what() << std::endl; + set_service_unavailable(res, e.what()); + } +} + +void handle_request_log_stats(RequestLogService* service, + const httplib::Request& req, + httplib::Response& res) { + if (req.method == "HEAD") { + res.status = 200; + return; + } + + if (!service || !service->is_enabled()) { + set_service_unavailable(res, "Request logging is not enabled"); + return; + } + if (!service->is_database_available()) { + set_service_unavailable(res, "Request log database is unavailable"); + return; + } + + try { + const auto payload = service->get_stats(req); + res.set_content(payload.dump(), "application/json"); + } catch (const std::exception& e) { + LOG(ERROR, "RequestLog") << "handle_request_log_stats failed: " << e.what() << std::endl; + set_service_unavailable(res, e.what()); + } +} + +} // namespace lemon diff --git a/src/cpp/server/request_log_parser.cpp b/src/cpp/server/request_log_parser.cpp new file mode 100644 index 000000000..4e94f6cbb --- /dev/null +++ b/src/cpp/server/request_log_parser.cpp @@ -0,0 +1,340 @@ +#include "lemon/request_log_parser.h" + +#include +#include +#include + +namespace lemon { +namespace { + +bool iequals(const std::string& a, const std::string& b) { + if (a.size() != b.size()) { + return false; + } + for (size_t i = 0; i < a.size(); ++i) { + if (std::tolower(static_cast(a[i])) != + std::tolower(static_cast(b[i]))) { + return false; + } + } + return true; +} + +bool is_sensitive_key(const std::string& key) { + static const char* const keys[] = { + "authorization", "api_key", "token", "password", "cookie", "secret", + "bearer", "access_token", "refresh_token", + }; + for (const char* candidate : keys) { + if (iequals(key, candidate)) { + return true; + } + } + return false; +} + +std::string strip_api_prefix(const std::string& path) { + static const char* const prefixes[] = { + "/api/v0/", "/api/v1/", "/v0/", "/v1/", + }; + for (const char* prefix : prefixes) { + const size_t len = std::char_traits::length(prefix); + if (path.rfind(prefix, 0) == 0) { + return path.substr(len); + } + } + return path; +} + +bool path_ends_with_static_asset(const std::string& path) { + static const char* const suffixes[] = { + ".js", ".css", ".svg", ".png", ".ico", ".woff", ".woff2", ".map", + }; + for (const char* suffix : suffixes) { + const size_t len = std::char_traits::length(suffix); + if (path.size() >= len && + path.compare(path.size() - len, len, suffix) == 0) { + return true; + } + } + return false; +} + +int count_message_chars(const nlohmann::json& messages) { + if (!messages.is_array()) { + return 0; + } + int total = 0; + for (const auto& message : messages) { + if (message.is_object() && message.contains("content")) { + const auto& content = message["content"]; + if (content.is_string()) { + total += static_cast(content.get().size()); + } else if (content.is_array()) { + for (const auto& part : content) { + if (part.is_object()) { + if (part.contains("text") && part["text"].is_string()) { + total += static_cast(part["text"].get().size()); + } else if (part.contains("input_text") && + part["input_text"].is_string()) { + total += static_cast(part["input_text"].get().size()); + } + } + } + } + } + } + return total; +} + +std::string json_value_to_string(const nlohmann::json& value) { + if (value.is_string()) { + return value.get(); + } + if (value.is_number_integer()) { + return std::to_string(value.get()); + } + if (value.is_number_float()) { + std::ostringstream oss; + oss << value.get(); + return oss.str(); + } + if (value.is_boolean()) { + return value.get() ? "true" : "false"; + } + return value.dump(); +} + +constexpr size_t kMaxRedactedBodyBytes = 32768; + +} // namespace + +nlohmann::json redact_json(const nlohmann::json& value) { + if (value.is_object()) { + nlohmann::json out = nlohmann::json::object(); + for (auto it = value.begin(); it != value.end(); ++it) { + if (is_sensitive_key(it.key())) { + out[it.key()] = "[REDACTED]"; + } else { + out[it.key()] = redact_json(it.value()); + } + } + return out; + } + if (value.is_array()) { + nlohmann::json out = nlohmann::json::array(); + for (const auto& item : value) { + out.push_back(redact_json(item)); + } + return out; + } + return value; +} + +std::string classify_endpoint_type(const std::string& path, const std::string& method) { + (void)method; + if (path.rfind("/internal/", 0) == 0) { + return "lemonade"; + } + + static const char* const ollama_paths[] = { + "/api/chat", "/api/generate", "/api/tags", "/api/show", "/api/delete", + "/api/pull", "/api/embed", "/api/embeddings", "/api/ps", "/api/version", + }; + for (const char* ollama_path : ollama_paths) { + if (path == ollama_path) { + return "ollama"; + } + } + + if (path == "/v1/messages" || path == "/api/v1/messages") { + return "openai"; + } + + const std::string relative = strip_api_prefix(path); + static const char* const openai_paths[] = { + "chat/completions", "completions", "embeddings", "reranking", "responses", + "audio/transcriptions", "audio/speech", "images/generations", "images/edits", + "images/variations", "images/upscale", + }; + for (const char* openai_path : openai_paths) { + if (relative == openai_path) { + return "openai"; + } + } + + static const char* const lemonade_paths[] = { + "load", "unload", "pull", "delete", "params", "install", "uninstall", + }; + for (const char* lemonade_path : lemonade_paths) { + if (relative == lemonade_path) { + return "lemonade"; + } + } + + if (path == "/mcp") { + return "lemonade"; + } + + return "other"; +} + +std::string extract_forwarded_for(const std::string& x_forwarded_for, + const std::string& x_real_ip, + const std::string& forwarded) { + if (!x_forwarded_for.empty()) { + return x_forwarded_for; + } + if (!x_real_ip.empty()) { + return x_real_ip; + } + return forwarded; +} + +ParsedRequestBody parse_request_body(const std::string& body, + const std::string& path, + bool log_prompts) { + ParsedRequestBody parsed; + if (body.empty()) { + return parsed; + } + + nlohmann::json request_json; + try { + request_json = nlohmann::json::parse(body); + } catch (...) { + return parsed; + } + + if (!request_json.is_object()) { + return parsed; + } + + if (request_json.contains("model") && request_json["model"].is_string()) { + parsed.model = request_json["model"].get(); + } else if (request_json.contains("model_name") && + request_json["model_name"].is_string()) { + parsed.model = request_json["model_name"].get(); + } + + if (request_json.contains("keep_alive")) { + parsed.keep_alive = json_value_to_string(request_json["keep_alive"]); + } + + if (request_json.contains("stream") && request_json["stream"].is_boolean()) { + parsed.stream = request_json["stream"].get(); + } + + if (request_json.contains("prompt") && request_json["prompt"].is_string()) { + parsed.prompt_chars = + static_cast(request_json["prompt"].get().size()); + } + + if (request_json.contains("messages")) { + parsed.messages_chars = count_message_chars(request_json["messages"]); + } + + nlohmann::json redacted = redact_json(request_json); + if (!log_prompts) { + if (redacted.contains("prompt") && redacted["prompt"].is_string()) { + redacted["prompt"] = nlohmann::json{{"char_count", parsed.prompt_chars}}; + } + if (redacted.contains("messages") && redacted["messages"].is_array()) { + redacted["messages"] = nlohmann::json{{"char_count", parsed.messages_chars}}; + } + } + + const std::string relative = strip_api_prefix(path); + if ((relative == "load" || path == "/internal/pin") && + request_json.contains("pinned") && request_json["pinned"].is_boolean()) { + redacted["_meta"] = nlohmann::json{{"pinned", request_json["pinned"].get()}}; + } + + const std::string dumped = redacted.dump(); + if (dumped.size() <= kMaxRedactedBodyBytes) { + parsed.redacted_body = std::move(redacted); + parsed.has_redacted_body = true; + } else { + parsed.redacted_body = nlohmann::json{ + {"truncated", true}, + {"original_bytes", dumped.size()}, + }; + parsed.has_redacted_body = true; + } + + return parsed; +} + +std::string extract_response_error(const std::string& response_body, int status_code) { + if (status_code >= 200 && status_code < 300) { + return {}; + } + if (response_body.empty()) { + return {}; + } + try { + auto response_json = nlohmann::json::parse(response_body); + if (response_json.is_object()) { + if (response_json.contains("error")) { + const auto& error = response_json["error"]; + if (error.is_string()) { + return error.get(); + } + if (error.is_object() && error.contains("message") && + error["message"].is_string()) { + return error["message"].get(); + } + return error.dump(); + } + if (response_json.contains("message") && + response_json["message"].is_string()) { + return response_json["message"].get(); + } + } + } catch (...) { + } + if (response_body.size() > 512) { + return response_body.substr(0, 512); + } + return response_body; +} + +bool should_skip_request_log_path(const std::string& path, const std::string& method) { + if (path == "/api/v0/health" || path == "/api/v1/health" || + path == "/v0/health" || path == "/v1/health" || + path == "/live" || path == "/metrics") { + return true; + } + + if (path == "/api/v0/downloads" || path == "/api/v1/downloads" || + path == "/v0/downloads" || path == "/v1/downloads" || + path == "/api/v0/system-stats" || path == "/api/v1/system-stats" || + path == "/v0/system-stats" || path == "/v1/system-stats" || + path == "/api/v0/stats" || path == "/api/v1/stats" || + path == "/v0/stats" || path == "/v1/stats") { + return true; + } + + if (path.find("request-log/") != std::string::npos) { + return true; + } + + if (method == "GET" && (path == "/" || path == "/app" || path.rfind("/app/", 0) == 0 || + path.rfind("/static/", 0) == 0 || path_ends_with_static_asset(path))) { + return true; + } + + if (method == "GET" && ( + path == "/api/v0/models" || path == "/api/v1/models" || + path == "/v0/models" || path == "/v1/models" || + path == "/api/v0/system-info" || path == "/api/v1/system-info" || + path == "/v0/system-info" || path == "/v1/system-info" || + path == "/api/v0/system-checks" || path == "/api/v1/system-checks" || + path == "/v0/system-checks" || path == "/v1/system-checks")) { + return true; + } + + return false; +} + +} // namespace lemon diff --git a/src/cpp/server/request_log_service.cpp b/src/cpp/server/request_log_service.cpp new file mode 100644 index 000000000..266e94467 --- /dev/null +++ b/src/cpp/server/request_log_service.cpp @@ -0,0 +1,795 @@ +#include "lemon/request_log_service.h" + +#include "lemon/request_log_parser.h" +#include "lemon/utils/aixlog.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#ifdef LEMONADE_HAVE_REQUEST_LOG +#include +#endif + +namespace lemon { +namespace { + +thread_local std::chrono::steady_clock::time_point g_request_log_start; +thread_local bool g_request_log_start_valid = false; + +bool parse_bool_env(const char* value, bool default_value) { + if (!value || !*value) { + return default_value; + } + std::string normalized(value); + for (char& ch : normalized) { + ch = static_cast(std::tolower(static_cast(ch))); + } + if (normalized == "1" || normalized == "true" || normalized == "yes" || + normalized == "on") { + return true; + } + if (normalized == "0" || normalized == "false" || normalized == "no" || + normalized == "off") { + return false; + } + return default_value; +} + +int parse_int_env(const char* value, int default_value) { + if (!value || !*value) { + return default_value; + } + try { + return std::stoi(value); + } catch (...) { + return default_value; + } +} + +std::string query_param(const httplib::Request& req, const char* key) { + if (req.has_param(key)) { + return req.get_param_value(key); + } + return {}; +} + +int clamp_limit(const std::string& raw, int default_value, int max_value) { + if (raw.empty()) { + return default_value; + } + try { + const int value = std::stoi(raw); + if (value < 1) { + return 1; + } + return std::min(value, max_value); + } catch (...) { + return default_value; + } +} + +int clamp_offset(const std::string& raw) { + if (raw.empty()) { + return 0; + } + try { + const int value = std::stoi(raw); + return value < 0 ? 0 : value; + } catch (...) { + return 0; + } +} + +std::string parse_since_timestamp(const std::string& since) { + if (since.empty()) { + return {}; + } + + if (since.size() >= 2) { + const char unit = since.back(); + try { + const int amount = std::stoi(since.substr(0, since.size() - 1)); + if (amount <= 0) { + return {}; + } + std::chrono::system_clock::time_point cutoff; + if (unit == 'h' || unit == 'H') { + cutoff = std::chrono::system_clock::now() - std::chrono::hours(amount); + } else if (unit == 'd' || unit == 'D') { + cutoff = std::chrono::system_clock::now() - std::chrono::hours(24 * amount); + } else { + return since; + } + const auto time = std::chrono::system_clock::to_time_t(cutoff); + std::tm tm_buf{}; +#ifdef _WIN32 + gmtime_s(&tm_buf, &time); +#else + gmtime_r(&time, &tm_buf); +#endif + char buffer[64]; + std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &tm_buf); + return buffer; + } catch (...) { + return since; + } + } + + return since; +} + +#ifdef LEMONADE_HAVE_REQUEST_LOG +PGconn* as_pg_conn(void* conn) { + return static_cast(conn); +} + +const char* nullable_cstr(const std::string& value) { + return value.empty() ? nullptr : value.c_str(); +} + +const char* stream_param(const std::optional& stream) { + if (!stream.has_value()) { + return nullptr; + } + return stream.value() ? "true" : "false"; +} + +const char* kSchemaSql = R"SQL( +CREATE TABLE IF NOT EXISTS request_logs ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + client_ip TEXT, + forwarded_for TEXT, + method TEXT NOT NULL, + path TEXT NOT NULL, + query_string TEXT, + status_code INTEGER, + duration_ms INTEGER, + user_agent TEXT, + endpoint_type TEXT, + model TEXT, + keep_alive TEXT, + stream BOOLEAN, + request_body_bytes INTEGER, + response_body_bytes INTEGER, + prompt_chars INTEGER, + messages_chars INTEGER, + redacted_body JSONB, + error TEXT +); +CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON request_logs (created_at); +CREATE INDEX IF NOT EXISTS idx_request_logs_model ON request_logs (model); +CREATE INDEX IF NOT EXISTS idx_request_logs_client_ip ON request_logs (client_ip); +CREATE INDEX IF NOT EXISTS idx_request_logs_path ON request_logs (path); +CREATE INDEX IF NOT EXISTS idx_request_logs_keep_alive ON request_logs (keep_alive); +)SQL"; +#endif + +} // namespace + +void request_log_mark_start() { + g_request_log_start = std::chrono::steady_clock::now(); + g_request_log_start_valid = true; +} + +int64_t request_log_elapsed_ms() { + if (!g_request_log_start_valid) { + return 0; + } + return std::chrono::duration_cast( + std::chrono::steady_clock::now() - g_request_log_start) + .count(); +} + +std::unique_ptr RequestLogService::from_env() { + const bool enabled = + parse_bool_env(std::getenv("LEMONADE_REQUEST_LOG_ENABLED"), false); + const char* database_url_env = std::getenv("LEMONADE_REQUEST_LOG_DATABASE_URL"); + const std::string database_url = database_url_env ? database_url_env : ""; + const int retention_days = + parse_int_env(std::getenv("LEMONADE_REQUEST_LOG_RETENTION_DAYS"), 30); + const bool log_prompts = parse_bool_env(std::getenv("LEMONADE_LOG_PROMPTS"), false); + +#ifndef LEMONADE_HAVE_REQUEST_LOG + if (enabled) { + LOG(WARNING, "RequestLog") + << "LEMONADE_REQUEST_LOG_ENABLED is set but lemond was built without libpq " + "support. Request logging is disabled." << std::endl; + } + return nullptr; +#else + if (!enabled) { + return nullptr; + } + if (database_url.empty()) { + LOG(WARNING, "RequestLog") + << "LEMONADE_REQUEST_LOG_ENABLED is true but " + "LEMONADE_REQUEST_LOG_DATABASE_URL is empty. Request logging is disabled." + << std::endl; + return nullptr; + } + + auto service = std::unique_ptr(new RequestLogService( + true, database_url, retention_days, log_prompts, false)); + if (!service->ensure_connection() || !service->init_schema()) { + LOG(WARNING, "RequestLog") + << "Failed to connect to PostgreSQL request log database. " + "Lemonade will continue serving requests without persistence." + << std::endl; + service->database_available_.store(false); + } else { + service->database_available_.store(true); + LOG(INFO, "RequestLog") << "PostgreSQL request logging enabled." << std::endl; + } + return service; +#endif +} + +RequestLogService::RequestLogService(bool enabled, + std::string database_url, + int retention_days, + bool log_prompts, + bool database_available) + : enabled_(enabled), + database_url_(std::move(database_url)), + retention_days_(retention_days), + log_prompts_(log_prompts), + database_available_(database_available) {} + +RequestLogService::~RequestLogService() { + stop(); +#ifdef LEMONADE_HAVE_REQUEST_LOG + close_connection(); +#endif +} + +void RequestLogService::start() { + if (!enabled_ || running_.exchange(true)) { + return; + } + writer_thread_ = std::thread(&RequestLogService::writer_loop, this); + purge_thread_ = std::thread(&RequestLogService::purge_loop, this); +} + +void RequestLogService::stop() { + if (!running_.exchange(false)) { + return; + } + queue_cv_.notify_all(); + if (writer_thread_.joinable()) { + writer_thread_.join(); + } + if (purge_thread_.joinable()) { + purge_thread_.join(); + } +} + +void RequestLogService::mark_request_start() { + request_log_mark_start(); +} + +void RequestLogService::log_response(const httplib::Request& req, + const httplib::Response& res) { + if (!enabled_ || should_skip_request_log_path(req.path, req.method)) { + return; + } + + RequestLogEntry entry; + entry.client_ip = req.remote_addr; + entry.forwarded_for = extract_forwarded_for( + req.has_header("X-Forwarded-For") ? req.get_header_value("X-Forwarded-For") : "", + req.has_header("X-Real-IP") ? req.get_header_value("X-Real-IP") : "", + req.has_header("Forwarded") ? req.get_header_value("Forwarded") : ""); + entry.method = req.method; + entry.path = req.path; + entry.query_string = req.target; + const auto query_pos = entry.query_string.find('?'); + if (query_pos != std::string::npos) { + entry.query_string = entry.query_string.substr(query_pos + 1); + } else { + entry.query_string.clear(); + } + entry.status_code = res.status; + entry.duration_ms = static_cast(request_log_elapsed_ms()); + entry.user_agent = req.has_header("User-Agent") ? req.get_header_value("User-Agent") : ""; + entry.endpoint_type = classify_endpoint_type(req.path, req.method); + entry.request_body_bytes = static_cast(req.body.size()); + entry.response_body_bytes = static_cast(res.body.size()); + entry.error = extract_response_error(res.body, res.status); + entry.request_body = req.body; + + { + std::lock_guard lock(queue_mutex_); + if (queue_.size() >= kMaxQueueSize) { + const auto now_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + const int64_t last = last_drop_warning_ms_.load(); + if (now_ms - last > 5000) { + last_drop_warning_ms_.store(now_ms); + LOG(WARNING, "RequestLog") + << "Request log queue is full; dropping log entries." << std::endl; + } + return; + } + queue_.push_back(std::move(entry)); + } + queue_cv_.notify_one(); +} + +#ifdef LEMONADE_HAVE_REQUEST_LOG + +bool RequestLogService::ensure_connection() { + std::lock_guard lock(db_mutex_); + if (pg_conn_ && PQstatus(as_pg_conn(pg_conn_)) == CONNECTION_OK) { + return true; + } + close_connection(); + pg_conn_ = PQconnectdb(database_url_.c_str()); + if (!pg_conn_ || PQstatus(as_pg_conn(pg_conn_)) != CONNECTION_OK) { + if (pg_conn_) { + LOG(WARNING, "RequestLog") + << "PostgreSQL connection failed: " << PQerrorMessage(as_pg_conn(pg_conn_)) + << std::endl; + } + close_connection(); + database_available_.store(false); + return false; + } + database_available_.store(true); + return true; +} + +void RequestLogService::close_connection() { + if (pg_conn_) { + PQfinish(as_pg_conn(pg_conn_)); + pg_conn_ = nullptr; + } +} + +bool RequestLogService::init_schema() { + std::lock_guard lock(db_mutex_); + if (!pg_conn_ || PQstatus(as_pg_conn(pg_conn_)) != CONNECTION_OK) { + return false; + } + PGresult* result = PQexec(as_pg_conn(pg_conn_), kSchemaSql); + const bool ok = result && PQresultStatus(result) == PGRES_COMMAND_OK; + if (!ok && result) { + LOG(WARNING, "RequestLog") + << "Failed to initialize request log schema: " + << PQerrorMessage(as_pg_conn(pg_conn_)) << std::endl; + } + if (result) { + PQclear(result); + } + return ok; +} + +bool RequestLogService::insert_entries(const std::vector& entries) { + if (entries.empty()) { + return true; + } + if (!ensure_connection()) { + return false; + } + + std::lock_guard lock(db_mutex_); + if (!pg_conn_ || PQstatus(as_pg_conn(pg_conn_)) != CONNECTION_OK) { + return false; + } + + PGresult* begin = PQexec(as_pg_conn(pg_conn_), "BEGIN"); + if (!begin || PQresultStatus(begin) != PGRES_COMMAND_OK) { + if (begin) { + PQclear(begin); + } + database_available_.store(false); + return false; + } + PQclear(begin); + + const char* insert_sql = + "INSERT INTO request_logs (client_ip, forwarded_for, method, path, query_string, " + "status_code, duration_ms, user_agent, endpoint_type, model, keep_alive, stream, " + "request_body_bytes, response_body_bytes, prompt_chars, messages_chars, " + "redacted_body, error) " + "VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)"; + + bool ok = true; + for (const auto& raw_entry : entries) { + RequestLogEntry entry = raw_entry; + ParsedRequestBody parsed = + parse_request_body(entry.request_body, entry.path, log_prompts_); + entry.model = parsed.model; + entry.keep_alive = parsed.keep_alive; + entry.stream = parsed.stream; + entry.prompt_chars = parsed.prompt_chars; + entry.messages_chars = parsed.messages_chars; + if (parsed.has_redacted_body) { + entry.redacted_body_json = parsed.redacted_body.dump(); + entry.has_redacted_body = true; + } + + const std::string status_code = std::to_string(entry.status_code); + const std::string duration_ms = std::to_string(entry.duration_ms); + const std::string request_body_bytes = std::to_string(entry.request_body_bytes); + const std::string response_body_bytes = std::to_string(entry.response_body_bytes); + const std::string prompt_chars = std::to_string(entry.prompt_chars); + const std::string messages_chars = std::to_string(entry.messages_chars); + + const char* params[18] = { + nullable_cstr(entry.client_ip), + nullable_cstr(entry.forwarded_for), + entry.method.c_str(), + entry.path.c_str(), + nullable_cstr(entry.query_string), + status_code.c_str(), + duration_ms.c_str(), + nullable_cstr(entry.user_agent), + nullable_cstr(entry.endpoint_type), + nullable_cstr(entry.model), + nullable_cstr(entry.keep_alive), + stream_param(entry.stream), + request_body_bytes.c_str(), + response_body_bytes.c_str(), + prompt_chars.c_str(), + messages_chars.c_str(), + entry.has_redacted_body ? entry.redacted_body_json.c_str() : nullptr, + nullable_cstr(entry.error), + }; + + PGresult* result = PQexecParams(as_pg_conn(pg_conn_), insert_sql, 18, nullptr, params, + nullptr, nullptr, 0); + if (!result || PQresultStatus(result) != PGRES_COMMAND_OK) { + LOG(WARNING, "RequestLog") + << "Failed to insert request log row: " + << (pg_conn_ ? PQerrorMessage(as_pg_conn(pg_conn_)) : "no connection") + << std::endl; + ok = false; + if (result) { + PQclear(result); + } + break; + } + PQclear(result); + } + + PGresult* end = PQexec(as_pg_conn(pg_conn_), ok ? "COMMIT" : "ROLLBACK"); + if (end) { + PQclear(end); + } + if (!ok) { + database_available_.store(false); + close_connection(); + } + return ok; +} + +void RequestLogService::run_purge() { + if (retention_days_ == -1 || !ensure_connection()) { + return; + } + + std::lock_guard lock(db_mutex_); + if (!pg_conn_ || PQstatus(as_pg_conn(pg_conn_)) != CONNECTION_OK) { + return; + } + + const char* sql = nullptr; + if (retention_days_ == 0) { + sql = "DELETE FROM request_logs"; + } else { + sql = "DELETE FROM request_logs WHERE created_at < NOW() - make_interval(days => $1)"; + } + + PGresult* result = nullptr; + if (retention_days_ == 0) { + result = PQexec(as_pg_conn(pg_conn_), sql); + } else { + const std::string days = std::to_string(retention_days_); + const char* params[1] = {days.c_str()}; + result = PQexecParams(as_pg_conn(pg_conn_), sql, 1, nullptr, params, nullptr, nullptr, 0); + } + + if (!result || PQresultStatus(result) != PGRES_COMMAND_OK) { + LOG(WARNING, "RequestLog") + << "Failed to purge request logs: " + << PQerrorMessage(as_pg_conn(pg_conn_)) << std::endl; + database_available_.store(false); + } + if (result) { + PQclear(result); + } +} + +nlohmann::json RequestLogService::row_to_json(int row) const { + auto value = [this, row](int column) -> std::string { + const char* raw = PQgetvalue(as_pg_conn(pg_conn_), row, column); + return raw ? raw : ""; + }; + + nlohmann::json entry = { + {"id", std::stoll(value(0))}, + {"created_at", value(1)}, + {"client_ip", value(2)}, + {"forwarded_for", value(3)}, + {"method", value(4)}, + {"path", value(5)}, + {"query_string", value(6)}, + {"status_code", value(7).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(7)))}, + {"duration_ms", value(8).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(8)))}, + {"user_agent", value(9)}, + {"endpoint_type", value(10)}, + {"model", value(11)}, + {"keep_alive", value(12).empty() ? nlohmann::json(nullptr) : nlohmann::json(value(12))}, + {"stream", value(13).empty() ? nlohmann::json(nullptr) : nlohmann::json(value(13) == "t")}, + {"request_body_bytes", value(14).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(14)))}, + {"response_body_bytes", value(15).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(15)))}, + {"prompt_chars", value(16).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(16)))}, + {"messages_chars", value(17).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(17)))}, + {"error", value(19).empty() ? nlohmann::json(nullptr) : nlohmann::json(value(19))}, + }; + + const char* redacted = PQgetvalue(as_pg_conn(pg_conn_), row, 18); + if (redacted && *redacted) { + try { + entry["redacted_body"] = nlohmann::json::parse(redacted); + } catch (...) { + entry["redacted_body"] = redacted; + } + } else { + entry["redacted_body"] = nullptr; + } + return entry; +} + +#else + +bool RequestLogService::ensure_connection() { return false; } +void RequestLogService::close_connection() {} +bool RequestLogService::init_schema() { return false; } +bool RequestLogService::insert_entries(const std::vector&) { return false; } +void RequestLogService::run_purge() {} +nlohmann::json RequestLogService::row_to_json(int) const { return nlohmann::json::object(); } + +#endif + +void RequestLogService::writer_loop() { + while (running_.load()) { + std::vector batch; + { + std::unique_lock lock(queue_mutex_); + queue_cv_.wait_for(lock, std::chrono::milliseconds(500), [this]() { + return !running_.load() || !queue_.empty(); + }); + if (!running_.load() && queue_.empty()) { + break; + } + while (!queue_.empty() && batch.size() < 50) { + batch.push_back(std::move(queue_.front())); + queue_.pop_front(); + } + } + + if (!batch.empty()) { + if (!insert_entries(batch)) { + LOG(WARNING, "RequestLog") + << "Request log insert failed; entries in this batch were dropped." + << std::endl; + } + } + } + + std::vector remaining; + { + std::lock_guard lock(queue_mutex_); + remaining.assign(std::make_move_iterator(queue_.begin()), + std::make_move_iterator(queue_.end())); + queue_.clear(); + } + if (!remaining.empty()) { + insert_entries(remaining); + } +} + +void RequestLogService::purge_loop() { + while (running_.load()) { + for (int i = 0; i < 3600 && running_.load(); ++i) { + std::this_thread::sleep_for(std::chrono::seconds(1)); + } + if (!running_.load()) { + break; + } + run_purge(); + } +} + +#ifdef LEMONADE_HAVE_REQUEST_LOG +nlohmann::json RequestLogService::get_recent(int limit) const { + if (!const_cast(this)->ensure_connection()) { + throw std::runtime_error("Request log database is unavailable"); + } + + std::lock_guard lock(db_mutex_); + const std::string sql = + "SELECT id, created_at, client_ip, forwarded_for, method, path, query_string, " + "status_code, duration_ms, user_agent, endpoint_type, model, keep_alive, stream, " + "request_body_bytes, response_body_bytes, prompt_chars, messages_chars, redacted_body, error " + "FROM request_logs ORDER BY created_at DESC LIMIT $1"; + const std::string limit_str = std::to_string(limit); + const char* params[1] = {limit_str.c_str()}; + + PGresult* result = PQexecParams(as_pg_conn(pg_conn_), sql.c_str(), 1, nullptr, params, + nullptr, nullptr, 0); + if (!result || PQresultStatus(result) != PGRES_TUPLES_OK) { + if (result) { + PQclear(result); + } + throw std::runtime_error("Failed to query recent request logs"); + } + + nlohmann::json entries = nlohmann::json::array(); + const int rows = PQntuples(result); + for (int i = 0; i < rows; ++i) { + entries.push_back(row_to_json(i)); + } + PQclear(result); + return {{"entries", entries}}; +} + +nlohmann::json RequestLogService::search(const httplib::Request& req) const { + if (!const_cast(this)->ensure_connection()) { + throw std::runtime_error("Request log database is unavailable"); + } + + const int limit = clamp_limit(query_param(req, "limit"), 100, 1000); + const int offset = clamp_offset(query_param(req, "offset")); + const std::string model = query_param(req, "model"); + const std::string client_ip = query_param(req, "client_ip"); + const std::string path = query_param(req, "path"); + const std::string keep_alive = query_param(req, "keep_alive"); + const std::string since = parse_since_timestamp(query_param(req, "since")); + + std::ostringstream sql; + sql << "SELECT id, created_at, client_ip, forwarded_for, method, path, query_string, " + "status_code, duration_ms, user_agent, endpoint_type, model, keep_alive, stream, " + "request_body_bytes, response_body_bytes, prompt_chars, messages_chars, redacted_body, error " + "FROM request_logs WHERE 1=1"; + + std::vector params; + auto add_filter = [&](const std::string& clause, const std::string& value) { + if (value.empty()) { + return; + } + params.push_back(value); + sql << clause << "$" << params.size(); + }; + + add_filter(" AND model = ", model); + add_filter(" AND client_ip = ", client_ip); + add_filter(" AND path LIKE ", path.empty() ? "" : "%" + path + "%"); + add_filter(" AND keep_alive = ", keep_alive); + add_filter(" AND created_at >= ", since); + + params.push_back(std::to_string(limit)); + sql << " ORDER BY created_at DESC LIMIT $" << params.size(); + params.push_back(std::to_string(offset)); + sql << " OFFSET $" << params.size(); + + std::vector param_ptrs; + param_ptrs.reserve(params.size()); + for (const auto& param : params) { + param_ptrs.push_back(param.c_str()); + } + + std::lock_guard lock(db_mutex_); + PGresult* result = PQexecParams(as_pg_conn(pg_conn_), sql.str().c_str(), + static_cast(param_ptrs.size()), nullptr, + param_ptrs.data(), nullptr, nullptr, 0); + if (!result || PQresultStatus(result) != PGRES_TUPLES_OK) { + if (result) { + PQclear(result); + } + throw std::runtime_error("Failed to search request logs"); + } + + nlohmann::json entries = nlohmann::json::array(); + const int rows = PQntuples(result); + for (int i = 0; i < rows; ++i) { + entries.push_back(row_to_json(i)); + } + PQclear(result); + return {{"entries", entries}, {"limit", limit}, {"offset", offset}}; +} + +nlohmann::json RequestLogService::get_stats(const httplib::Request& req) const { + if (!const_cast(this)->ensure_connection()) { + throw std::runtime_error("Request log database is unavailable"); + } + + const std::string since = + parse_since_timestamp(query_param(req, "since").empty() ? "24h" + : query_param(req, "since")); + + std::lock_guard lock(db_mutex_); + const char* summary_sql = + "SELECT COUNT(*)::bigint, COALESCE(AVG(duration_ms), 0), " + "COUNT(DISTINCT client_ip)::bigint, COUNT(*) FILTER (WHERE keep_alive IS NOT NULL)::bigint " + "FROM request_logs WHERE created_at >= $1"; + const char* summary_params[1] = {since.c_str()}; + PGresult* summary = PQexecParams(as_pg_conn(pg_conn_), summary_sql, 1, nullptr, summary_params, + nullptr, nullptr, 0); + if (!summary || PQresultStatus(summary) != PGRES_TUPLES_OK) { + if (summary) { + PQclear(summary); + } + throw std::runtime_error("Failed to query request log stats"); + } + + nlohmann::json response = { + {"since", since}, + {"total_requests", std::stoll(PQgetvalue(summary, 0, 0))}, + {"avg_duration_ms", std::stod(PQgetvalue(summary, 0, 1))}, + {"unique_client_ips", std::stoll(PQgetvalue(summary, 0, 2))}, + {"keep_alive_requests", std::stoll(PQgetvalue(summary, 0, 3))}, + {"by_endpoint_type", nlohmann::json::object()}, + {"by_model", nlohmann::json::object()}, + }; + PQclear(summary); + + const char* by_type_sql = + "SELECT endpoint_type, COUNT(*)::bigint FROM request_logs " + "WHERE created_at >= $1 GROUP BY endpoint_type ORDER BY COUNT(*) DESC"; + PGresult* by_type = PQexecParams(as_pg_conn(pg_conn_), by_type_sql, 1, nullptr, summary_params, + nullptr, nullptr, 0); + if (by_type && PQresultStatus(by_type) == PGRES_TUPLES_OK) { + for (int i = 0; i < PQntuples(by_type); ++i) { + response["by_endpoint_type"][PQgetvalue(by_type, i, 0)] = + std::stoll(PQgetvalue(by_type, i, 1)); + } + } + if (by_type) { + PQclear(by_type); + } + + const char* by_model_sql = + "SELECT COALESCE(model, ''), COUNT(*)::bigint FROM request_logs " + "WHERE created_at >= $1 GROUP BY model ORDER BY COUNT(*) DESC LIMIT 20"; + PGresult* by_model = PQexecParams(as_pg_conn(pg_conn_), by_model_sql, 1, nullptr, + summary_params, nullptr, nullptr, 0); + if (by_model && PQresultStatus(by_model) == PGRES_TUPLES_OK) { + for (int i = 0; i < PQntuples(by_model); ++i) { + const char* model = PQgetvalue(by_model, i, 0); + response["by_model"][model && *model ? model : "(none)"] = + std::stoll(PQgetvalue(by_model, i, 1)); + } + } + if (by_model) { + PQclear(by_model); + } + + return response; +} + +#else + +nlohmann::json RequestLogService::get_recent(int) const { + throw std::runtime_error("Request logging is not available in this build"); +} + +nlohmann::json RequestLogService::search(const httplib::Request&) const { + throw std::runtime_error("Request logging is not available in this build"); +} + +nlohmann::json RequestLogService::get_stats(const httplib::Request&) const { + throw std::runtime_error("Request logging is not available in this build"); +} + +#endif + +} // namespace lemon diff --git a/src/cpp/server/server.cpp b/src/cpp/server/server.cpp index b95176957..6046e0db2 100644 --- a/src/cpp/server/server.cpp +++ b/src/cpp/server/server.cpp @@ -14,6 +14,7 @@ #include "lemon/streaming_proxy.h" #include "lemon/logging_config.h" #include "lemon/prometheus_metrics.h" +#include "lemon/request_log_handlers.h" #include "lemon/runtime_config.h" #include "lemon/system_info.h" #include "lemon/version.h" @@ -252,6 +253,8 @@ Server::Server(std::shared_ptr config, const std::string& cache_d admin_api_key_ = api_key_; } + request_log_service_ = RequestLogService::from_env(); + setup_http_servers(); // Initialize WebSocket server for realtime API and log streaming @@ -421,6 +424,9 @@ httplib::Server::HandlerResponse Server::authenticate_request(const httplib::Req void Server::setup_routes(httplib::Server &web_server) { // Add pre-routing handler to log ALL incoming requests (except health checks) web_server.set_pre_routing_handler([this](const httplib::Request& req, httplib::Response& res) { + if (request_log_service_) { + request_log_service_->mark_request_start(); + } this->log_request(req); return authenticate_request(req, res); }); @@ -628,6 +634,18 @@ void Server::setup_routes(httplib::Server &web_server) { handle_log_level(req, res); }); + register_get("request-log/recent", [this](const httplib::Request& req, httplib::Response& res) { + handle_request_log_recent(req, res); + }); + + register_get("request-log/search", [this](const httplib::Request& req, httplib::Response& res) { + handle_request_log_search(req, res); + }); + + register_get("request-log/stats", [this](const httplib::Request& req, httplib::Response& res) { + handle_request_log_stats(req, res); + }); + // NOTE: /api/v1/halt endpoint removed - use SIGTERM signal instead (like Python server) // The stop command now sends termination signal directly to the process @@ -1170,6 +1188,10 @@ void Server::setup_http_logger(httplib::Server &web_server) { if (!is_quiet_get) { LOG(DEBUG, "Server") << req.method << " " << req.path << " - " << res.status << std::endl; } + + if (request_log_service_) { + request_log_service_->log_response(req, res); + } }); } @@ -1225,6 +1247,10 @@ void Server::run() { running_ = true; + if (request_log_service_) { + request_log_service_->start(); + } + // Start WebSocket server for realtime API and log streaming if (websocket_server_) { if (websocket_server_->start()) { @@ -1394,6 +1420,10 @@ bool Server::is_running() const { } void Server::stop() { + if (request_log_service_) { + request_log_service_->stop(); + } + if (running_) { LOG(INFO, "Server") << "Stopping HTTP server..." << std::endl; udp_beacon_.stopBroadcasting(); @@ -4065,6 +4095,18 @@ void Server::resolve_and_register_local_model( LOG(INFO, "Server") << "Model registered successfully" << std::endl; } +void Server::handle_request_log_recent(const httplib::Request& req, httplib::Response& res) { + lemon::handle_request_log_recent(request_log_service_.get(), req, res); +} + +void Server::handle_request_log_search(const httplib::Request& req, httplib::Response& res) { + lemon::handle_request_log_search(request_log_service_.get(), req, res); +} + +void Server::handle_request_log_stats(const httplib::Request& req, httplib::Response& res) { + lemon::handle_request_log_stats(request_log_service_.get(), req, res); +} + void Server::handle_stats(const httplib::Request& req, httplib::Response& res) { // For HEAD requests, just return 200 OK without processing if (req.method == "HEAD") { diff --git a/test/cpp/test_request_log_parser.cpp b/test/cpp/test_request_log_parser.cpp new file mode 100644 index 000000000..4fcf7170c --- /dev/null +++ b/test/cpp/test_request_log_parser.cpp @@ -0,0 +1,101 @@ +// Unit tests for request log parser helpers. + +#include "lemon/request_log_parser.h" + +#include +#include +#include + +using lemon::ParsedRequestBody; +using lemon::classify_endpoint_type; +using lemon::parse_request_body; +using lemon::redact_json; + +struct TestResult { + int passed = 0; + int failed = 0; + + void ok(const std::string& name) { + printf("[PASS] %s\n", name.c_str()); + ++passed; + } + + void fail(const std::string& name) { + printf("[FAIL] %s\n", name.c_str()); + ++failed; + } +}; + +static void test_endpoint_classification(TestResult& result) { + if (classify_endpoint_type("/api/chat", "POST") == "ollama") { + result.ok("ollama chat path"); + } else { + result.fail("ollama chat path"); + } + + if (classify_endpoint_type("/v1/chat/completions", "POST") == "openai") { + result.ok("openai chat path"); + } else { + result.fail("openai chat path"); + } + + if (classify_endpoint_type("/api/v1/load", "POST") == "lemonade") { + result.ok("lemonade load path"); + } else { + result.fail("lemonade load path"); + } +} + +static void test_redaction(TestResult& result) { + const auto input = nlohmann::json{ + {"model", "demo"}, + {"api_key", "secret-value"}, + {"messages", nlohmann::json::array({nlohmann::json{{"role", "user"}, {"content", "hello"}}})}, + }; + const auto redacted = redact_json(input); + if (redacted["api_key"] == "[REDACTED]") { + result.ok("api_key redacted"); + } else { + result.fail("api_key redacted"); + } + if (redacted["model"] == "demo") { + result.ok("model preserved"); + } else { + result.fail("model preserved"); + } +} + +static void test_char_counts_without_prompt_logging(TestResult& result) { + const std::string body = R"({ + "model": "llama3.2", + "keep_alive": 0, + "stream": true, + "prompt": "hello prompt", + "messages": [{"role":"user","content":"hello messages"}] + })"; + const ParsedRequestBody parsed = parse_request_body(body, "/api/generate", false); + if (parsed.model == "llama3.2" && parsed.keep_alive == "0" && + parsed.stream.has_value() && parsed.stream.value() && + parsed.prompt_chars == 12 && parsed.messages_chars == 14) { + result.ok("field extraction and char counts"); + } else { + result.fail("field extraction and char counts"); + } + if (parsed.has_redacted_body && + parsed.redacted_body["prompt"]["char_count"] == 12 && + parsed.redacted_body["messages"]["char_count"] == 14) { + result.ok("prompt content replaced with char counts"); + } else { + result.fail("prompt content replaced with char counts"); + } +} + +int main() { + TestResult result; + test_endpoint_classification(result); + test_redaction(result); + test_char_counts_without_prompt_logging(result); + + printf("\nResults: %d passed, %d failed\n", result.passed, result.failed); + return result.failed == 0 ? 0 : 1; +} diff --git a/test/server_endpoints.py b/test/server_endpoints.py index d3b286c16..1d08c357b 100644 --- a/test/server_endpoints.py +++ b/test/server_endpoints.py @@ -130,6 +130,9 @@ def test_000_endpoints_registered(self): "images/generations", "install", "uninstall", + "request-log/recent", + "request-log/search", + "request-log/stats", ] session = requests.Session() diff --git a/test/server_request_log.py b/test/server_request_log.py new file mode 100644 index 000000000..da3ccf6f8 --- /dev/null +++ b/test/server_request_log.py @@ -0,0 +1,125 @@ +""" +Request log endpoint tests. + +Integration tests that require PostgreSQL are skipped unless +LEMONADE_REQUEST_LOG_DATABASE_URL is set in the environment of the running server. +""" + +import os +import time +import unittest + +import requests + +from utils.server_base import ServerTestBase, _auth_headers +from utils.test_models import PORT, TIMEOUT_DEFAULT + + +class RequestLogTests(ServerTestBase): + """Tests for /api/v1/request-log/* review endpoints.""" + + def test_000_request_log_endpoints_registered(self): + """Verify request-log endpoints are registered on v0 and v1.""" + for endpoint in ("request-log/recent", "request-log/search", "request-log/stats"): + for version in ("v0", "v1"): + url = f"http://localhost:{PORT}/api/{version}/{endpoint}" + response = requests.head(url, timeout=TIMEOUT_DEFAULT) + self.assertNotEqual( + response.status_code, + 404, + f"Endpoint {endpoint} is not registered on {version}", + ) + + def test_001_request_log_auth_when_api_key_configured(self): + """When LEMONADE_API_KEY is set, review endpoints require auth.""" + api_key = os.environ.get("LEMONADE_API_KEY") + if not api_key: + self.skipTest("LEMONADE_API_KEY is not set on the test runner") + + url = f"{self.base_url}/request-log/recent" + unauth = requests.get(url, timeout=TIMEOUT_DEFAULT) + if unauth.status_code == 503: + self.skipTest("Request logging is not enabled on the running server") + + self.assertEqual(unauth.status_code, 401) + + authed = requests.get(url, headers=_auth_headers(), timeout=TIMEOUT_DEFAULT) + self.assertIn(authed.status_code, (200, 503)) + if authed.status_code == 200: + payload = authed.json() + self.assertIn("entries", payload) + self.assertIsInstance(payload["entries"], list) + + def test_002_request_log_search_keep_alive_integration(self): + """When DB logging is active, keep_alive requests appear in search results.""" + if not os.environ.get("LEMONADE_REQUEST_LOG_DATABASE_URL"): + self.skipTest("LEMONADE_REQUEST_LOG_DATABASE_URL is not configured") + + marker_model = "request-log-test-model" + chat_url = f"http://localhost:{PORT}/api/chat" + payload = { + "model": marker_model, + "messages": [], + "keep_alive": 0, + } + response = requests.post( + chat_url, + json=payload, + headers=_auth_headers(), + timeout=TIMEOUT_DEFAULT, + ) + self.assertIn(response.status_code, (200, 400, 404, 422, 500)) + + search_url = f"{self.base_url}/request-log/search" + params = {"keep_alive": "0", "limit": 20} + deadline = time.time() + 15 + found = False + while time.time() < deadline: + search = requests.get( + search_url, + params=params, + headers=_auth_headers(), + timeout=TIMEOUT_DEFAULT, + ) + if search.status_code == 503: + self.skipTest("Request logging is not enabled on the running server") + self.assertEqual(search.status_code, 200) + entries = search.json().get("entries", []) + for entry in entries: + if entry.get("path") == "/api/chat" and entry.get("keep_alive") == "0": + found = True + redacted = entry.get("redacted_body") + if redacted is not None: + dumped = str(redacted) + self.assertNotIn("Bearer", dumped) + self.assertNotIn("secret-value", dumped) + break + if found: + break + time.sleep(1) + + self.assertTrue(found, "Expected /api/chat keep_alive=0 request in search results") + + def test_003_request_log_stats(self): + """Stats endpoint returns aggregate JSON when logging is enabled.""" + url = f"{self.base_url}/request-log/stats" + response = requests.get( + url, + params={"since": "1h"}, + headers=_auth_headers(), + timeout=TIMEOUT_DEFAULT, + ) + if response.status_code == 503: + self.skipTest("Request logging is not enabled on the running server") + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertIn("total_requests", payload) + self.assertIn("by_endpoint_type", payload) + self.assertIn("by_model", payload) + + +if __name__ == "__main__": + from utils.server_base import run_server_tests + + run_server_tests(RequestLogTests, "REQUEST LOG TESTS") From 1cf18fc6b8c716465df626d30e1aa263d0d1bb0f Mon Sep 17 00:00:00 2001 From: Chuck Pearce Date: Wed, 17 Jun 2026 09:10:23 -0400 Subject: [PATCH 02/16] refactor(logging): update PostgreSQL request log configuration This commit modifies the PostgreSQL request logging configuration to use a dynamic port assignment instead of a hardcoded port. Changes include: - Updated `secrets.conf` to reflect the new `` placeholder for the database URL. - Revised documentation in `request-log.md` to clarify the use of a random host port assigned by Docker. - Adjusted the `docker-compose.request-log.yml` to expose the PostgreSQL service on the default port without a specific host port mapping. - Enhanced the `start-request-log-db.sh` script to determine and display the assigned host port dynamically. These updates improve flexibility and usability for users setting up the request logging feature. --- data/secrets.conf | 2 +- docs/guide/configuration/request-log.md | 12 ++++++++---- examples/docker-compose.request-log.yml | 2 +- examples/start-request-log-db.sh | 14 +++++++++++--- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/data/secrets.conf b/data/secrets.conf index 20cd3b8cb..426f0add9 100644 --- a/data/secrets.conf +++ b/data/secrets.conf @@ -2,6 +2,6 @@ # drop-ins and keeps secrets separate from the base config file. #LEMONADE_API_KEY= #LEMONADE_REQUEST_LOG_ENABLED=true -#LEMONADE_REQUEST_LOG_DATABASE_URL=postgresql://lemonade:change-me@127.0.0.1:5433/lemonade_logs +#LEMONADE_REQUEST_LOG_DATABASE_URL=postgresql://lemonade:change-me@127.0.0.1:/lemonade_logs #LEMONADE_REQUEST_LOG_RETENTION_DAYS=30 #LEMONADE_LOG_PROMPTS=false diff --git a/docs/guide/configuration/request-log.md b/docs/guide/configuration/request-log.md index 7a7d272e6..3b32dec50 100644 --- a/docs/guide/configuration/request-log.md +++ b/docs/guide/configuration/request-log.md @@ -32,13 +32,13 @@ From the repository root: ./examples/start-request-log-db.sh ``` -This starts PostgreSQL on port `5433` and prints the connection URL. +This starts PostgreSQL with a random host port (Docker assigns an available port) and prints the connection URL. Suggested env drop-in for systemd (`/etc/lemonade/conf.d/request-log.conf`): ```ini LEMONADE_REQUEST_LOG_ENABLED=true -LEMONADE_REQUEST_LOG_DATABASE_URL=postgresql://lemonade:change-me@127.0.0.1:5433/lemonade_logs +LEMONADE_REQUEST_LOG_DATABASE_URL=postgresql://lemonade:change-me@127.0.0.1:/lemonade_logs LEMONADE_REQUEST_LOG_RETENTION_DAYS=30 LEMONADE_LOG_PROMPTS=false ``` @@ -118,8 +118,10 @@ curl -s -X POST http://127.0.0.1:13305/api/chat \ Connect to the database: +Use the connection URL printed by `./examples/start-request-log-db.sh`, for example: + ```bash -psql postgresql://lemonade:change-me@127.0.0.1:5433/lemonade_logs +psql postgresql://lemonade:change-me@127.0.0.1:/lemonade_logs ``` Clients sending `keep_alive`: @@ -164,11 +166,13 @@ cmake -DLEMONADE_REQUEST_LOG=OFF --preset default sudo tee /etc/lemonade/conf.d/request-log.conf <<'EOF' LEMONADE_REQUEST_LOG_ENABLED=true -LEMONADE_REQUEST_LOG_DATABASE_URL=postgresql://lemonade:change-me@127.0.0.1:5433/lemonade_logs +LEMONADE_REQUEST_LOG_DATABASE_URL=postgresql://lemonade:change-me@127.0.0.1:/lemonade_logs LEMONADE_REQUEST_LOG_RETENTION_DAYS=30 LEMONADE_LOG_PROMPTS=false EOF +Use the `` value printed by `./examples/start-request-log-db.sh`. + sudo systemctl stop lemond.service sudo cp build/lemond /usr/bin/lemond sudo systemctl daemon-reload diff --git a/examples/docker-compose.request-log.yml b/examples/docker-compose.request-log.yml index 76a734a64..0000db994 100644 --- a/examples/docker-compose.request-log.yml +++ b/examples/docker-compose.request-log.yml @@ -8,7 +8,7 @@ services: volumes: - lemonade_request_logs:/var/lib/postgresql/data ports: - - "5433:5432" + - "5432" volumes: lemonade_request_logs: diff --git a/examples/start-request-log-db.sh b/examples/start-request-log-db.sh index aec42f222..885ab356d 100755 --- a/examples/start-request-log-db.sh +++ b/examples/start-request-log-db.sh @@ -6,11 +6,9 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" COMPOSE_FILE="${REPO_ROOT}/examples/docker-compose.request-log.yml" SERVICE_NAME="lemonade-request-log-db" DB_HOST="127.0.0.1" -DB_PORT="5433" DB_USER="lemonade" DB_PASSWORD="change-me" DB_NAME="lemonade_logs" -DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}" if ! command -v docker >/dev/null 2>&1; then echo "Error: docker is required but not installed." >&2 @@ -29,7 +27,7 @@ fi echo "Starting PostgreSQL request log database..." "${COMPOSE[@]}" -f "${COMPOSE_FILE}" up -d -echo "Waiting for PostgreSQL to accept connections on ${DB_HOST}:${DB_PORT}..." +echo "Waiting for PostgreSQL to accept connections..." ready=0 for _ in $(seq 1 60); do if "${COMPOSE[@]}" -f "${COMPOSE_FILE}" exec -T "${SERVICE_NAME}" \ @@ -45,10 +43,20 @@ if [[ "${ready}" -ne 1 ]]; then exit 1 fi +port_line="$("${COMPOSE[@]}" -f "${COMPOSE_FILE}" port "${SERVICE_NAME}" 5432)" +DB_PORT="${port_line##*:}" +if [[ -z "${DB_PORT}" || "${DB_PORT}" == "${port_line}" ]]; then + echo "Error: Could not determine published host port for ${SERVICE_NAME}." >&2 + exit 1 +fi + +DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}" + cat < Date: Wed, 17 Jun 2026 09:24:49 -0400 Subject: [PATCH 03/16] feat(logging): implement request log panel and center panel tabs This commit introduces a new request log panel and enhances the center panel with tabs for switching between server logs and request logs. Key changes include: - Added `CenterPanelTabs` component to manage tab navigation. - Integrated `RequestLogPanel` for displaying request logs. - Updated state management to handle the active tab and visibility of logs. - Modified `ModelManager` and `TitleBar` components to include functionality for opening the request logs. - Enhanced layout settings to persist the selected center panel tab. These updates improve the user interface for viewing logs, providing better organization and accessibility for users monitoring server activity. --- scripts/deploy-ubuntu-local.sh | 229 +++++++++++ src/app/src/renderer/App.tsx | 36 +- src/app/src/renderer/CenterPanelTabs.tsx | 31 ++ src/app/src/renderer/ModelManager.tsx | 22 +- src/app/src/renderer/RequestLogPanel.tsx | 429 ++++++++++++++++++++ src/app/src/renderer/TitleBar.tsx | 5 + src/app/src/renderer/components/Icons.tsx | 12 + src/app/src/renderer/utils/appSettings.ts | 5 + src/app/src/renderer/utils/requestLogApi.ts | 130 ++++++ src/app/styles/styles.css | 268 ++++++++++++ 10 files changed, 1160 insertions(+), 7 deletions(-) create mode 100755 scripts/deploy-ubuntu-local.sh create mode 100644 src/app/src/renderer/CenterPanelTabs.tsx create mode 100644 src/app/src/renderer/RequestLogPanel.tsx create mode 100644 src/app/src/renderer/utils/requestLogApi.ts diff --git a/scripts/deploy-ubuntu-local.sh b/scripts/deploy-ubuntu-local.sh new file mode 100755 index 000000000..194229f97 --- /dev/null +++ b/scripts/deploy-ubuntu-local.sh @@ -0,0 +1,229 @@ +#!/bin/bash +# Build lemonade-server from source (native Debian packaging, /usr layout) and +# install it on this Ubuntu machine, replacing a PPA or prior .deb install. +# +# Usage (from repo root on Ubuntu): +# ./scripts/deploy-ubuntu-local.sh [OPTIONS] +# +# Options: +# --skip-deps Skip apt build-dep +# --skip-build Reuse existing ../lemonade-server_*.deb +# --no-restart Install only; do not stop/start lemond.service +# --hold apt-mark hold lemonade-server after install +# -h, --help Show this help +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +SKIP_DEPS=false +SKIP_BUILD=false +NO_RESTART=false +APT_HOLD=false + +print_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } +print_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +usage() { + cat <<'EOF' +Build lemonade-server from source (native Debian packaging, /usr layout) and +install it on this Ubuntu machine, replacing a PPA or prior .deb install. + +Usage (from repo root on Ubuntu): + ./scripts/deploy-ubuntu-local.sh [OPTIONS] + +Options: + --skip-deps Skip apt build-dep + --skip-build Reuse existing ../lemonade-server_*.deb + --no-restart Install only; do not stop/start lemond.service + --hold apt-mark hold lemonade-server after install + -h, --help Show this help +EOF + exit 0 +} + +maybe_sudo() { + if [ "$(id -u)" -eq 0 ]; then + "$@" + else + sudo "$@" + fi +} + +cleanup() { + if [ -n "${REPO_ROOT:-}" ] && [ -d "${REPO_ROOT}/debian" ]; then + print_info "Removing generated debian/ directory..." + rm -rf "${REPO_ROOT}/debian" + fi +} + +while [ $# -gt 0 ]; do + case "$1" in + --skip-deps) SKIP_DEPS=true ;; + --skip-build) SKIP_BUILD=true ;; + --no-restart) NO_RESTART=true ;; + --hold) APT_HOLD=true ;; + -h|--help) usage ;; + *) + print_error "Unknown option: $1" + echo "Run with --help for usage." + exit 1 + ;; + esac + shift +done + +if [[ "${OSTYPE:-}" != linux* ]]; then + print_error "This script must run on Linux (Ubuntu)." + exit 1 +fi + +if ! command -v apt-get >/dev/null 2>&1; then + print_error "apt-get not found. This script is for Debian/Ubuntu only." + exit 1 +fi + +if ! command -v git >/dev/null 2>&1; then + print_error "git is required to determine the package version." + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "${REPO_ROOT}" ] || [ ! -f "${REPO_ROOT}/contrib/debian/control" ]; then + print_error "Run this script from a Lemonade git checkout (contrib/debian/control not found)." + exit 1 +fi + +cd "${REPO_ROOT}" +trap cleanup EXIT + +if command -v snap >/dev/null 2>&1 && snap list lemonade-server >/dev/null 2>&1; then + print_warning "Snap package 'lemonade-server' is installed and may conflict with the systemd .deb install." + print_warning "Consider: sudo snap remove lemonade-server" +fi + +if [ ! -d "contrib/debian" ]; then + print_error "Missing contrib/debian packaging tree." + exit 1 +fi + +print_info "Preparing Debian packaging metadata..." +rm -rf debian +cp -a contrib/debian debian + +GIT_VERSION="$(git describe --tags --always)" +DEB_VERSION="${GIT_VERSION#v}" +if command -v lsb_release >/dev/null 2>&1; then + UBUNTU_RELEASE="$(lsb_release -rs)" + DEB_CODENAME="$(lsb_release -cs)" + DEB_VERSION="${DEB_VERSION}~local${UBUNTU_RELEASE}" +else + DEB_CODENAME="local" + DEB_VERSION="${DEB_VERSION}~local" +fi +DEB_DATE="$(date -R)" + +sed -e "s|@@DEB_VERSION@@|${DEB_VERSION}|g" \ + -e "s|@@DEB_CODENAME@@|${DEB_CODENAME}|g" \ + -e "s|@@DEB_DATE@@|${DEB_DATE}|g" \ + debian/changelog.in > debian/changelog + +print_success "Package version: ${DEB_VERSION}" + +if [ "${SKIP_DEPS}" = false ]; then + print_info "Installing build dependencies (apt build-dep)..." + maybe_sudo apt-get update + maybe_sudo apt-get build-dep . -y + print_success "Build dependencies installed" +else + print_info "Skipping build dependencies (--skip-deps)" +fi + +if [ "${SKIP_BUILD}" = false ]; then + print_info "Building binary .deb (dpkg-buildpackage)..." + dpkg-buildpackage -us -uc -b + print_success "Package build finished" +else + print_info "Skipping package build (--skip-build)" +fi + +DEB_FILE="$(ls -t ../lemonade-server_*.deb 2>/dev/null | head -1 || true)" +if [ -z "${DEB_FILE}" ] || [ ! -f "${DEB_FILE}" ]; then + PARENT_DIR="$(cd "${REPO_ROOT}/.." && pwd)" + print_error "No lemonade-server .deb found in: ${PARENT_DIR}" + print_error "Run without --skip-build, or build manually with dpkg-buildpackage." + exit 1 +fi + +print_info "Using package: ${DEB_FILE}" + +if [ "${NO_RESTART}" = false ]; then + print_info "Stopping lemond.service (if running)..." + maybe_sudo systemctl stop lemond.service 2>/dev/null || true +fi + +print_info "Installing package (replaces PPA or prior install)..." +maybe_sudo apt-get install -y "${DEB_FILE}" +print_success "Package installed" + +if [ "${NO_RESTART}" = false ]; then + print_info "Enabling and starting lemond.service..." + maybe_sudo systemctl daemon-reload + maybe_sudo systemctl enable lemond.service + maybe_sudo systemctl start lemond.service + print_success "lemond.service started" +else + print_info "Skipping service restart (--no-restart)" +fi + +if [ "${APT_HOLD}" = true ]; then + print_info "Holding lemonade-server to prevent apt upgrade from overwriting local build..." + maybe_sudo apt-mark hold lemonade-server + print_success "lemonade-server marked as held" +fi + +if [ "${NO_RESTART}" = false ]; then + print_info "Waiting for server health check..." + HEALTH_OK=false + for _ in $(seq 1 30); do + if curl -sf "http://127.0.0.1:13305/health" >/dev/null 2>&1; then + HEALTH_OK=true + break + fi + if curl -sf "http://127.0.0.1:13305/v1/health" >/dev/null 2>&1; then + HEALTH_OK=true + break + fi + sleep 1 + done + + if [ "${HEALTH_OK}" = true ]; then + print_success "Health check passed (http://127.0.0.1:13305)" + else + print_warning "Health check did not succeed within 30s." + print_warning "Inspect logs: journalctl -u lemond.service -n 50 --no-pager" + fi +fi + +echo "" +echo "==========================================" +print_success "Deploy completed" +echo "==========================================" +echo "" +print_info "Installed package:" +dpkg -l lemonade-server 2>/dev/null | tail -1 || true +echo "" +print_info "Binary: $(command -v lemond 2>/dev/null || echo 'lemond not in PATH')" +print_info "Config/data: /var/lib/lemonade/.cache/lemonade/ (preserved across upgrades)" +echo "" +if [ "${APT_HOLD}" = false ]; then + print_warning "PPA still enabled? A future 'apt upgrade' may replace this build with the PPA version." + print_warning "To pin the local build: sudo apt-mark hold lemonade-server" + print_warning "Or re-run with: $0 --hold" +fi diff --git a/src/app/src/renderer/App.tsx b/src/app/src/renderer/App.tsx index 1df1f7088..61794285c 100644 --- a/src/app/src/renderer/App.tsx +++ b/src/app/src/renderer/App.tsx @@ -5,6 +5,8 @@ import TitleBar from './TitleBar'; import ChatWindow from './ChatWindow'; import ModelManager, { LeftPanelView } from './ModelManager'; import LogsWindow from './LogsWindow'; +import CenterPanelTabs, { CenterPanelTab } from './CenterPanelTabs'; +import RequestLogPanel from './RequestLogPanel'; import ResizableDivider from './ResizableDivider'; import DownloadManager from './DownloadManager'; import StatusBar from './StatusBar'; @@ -58,6 +60,7 @@ const AppContent: React.FC = () => { const [leftPanelView, setLeftPanelView] = useState('models'); const [externalContentUrl, setExternalContentUrl] = useState(null); const [isLogsVisible, setIsLogsVisible] = useState(DEFAULT_LAYOUT_SETTINGS.isLogsVisible); + const [centerPanelTab, setCenterPanelTab] = useState(DEFAULT_LAYOUT_SETTINGS.centerPanelTab); const [isDownloadManagerVisible, setIsDownloadManagerVisible] = useState(false); const [modelManagerWidth, setModelManagerWidth] = useState(DEFAULT_LAYOUT_SETTINGS.modelManagerWidth); const [chatWidth, setChatWidth] = useState(DEFAULT_LAYOUT_SETTINGS.chatWidth); @@ -92,6 +95,10 @@ const AppContent: React.FC = () => { setLeftPanelView(savedView); } setIsLogsVisible(settings.layout.isLogsVisible ?? DEFAULT_LAYOUT_SETTINGS.isLogsVisible); + const savedCenterTab = settings.layout.centerPanelTab; + if (savedCenterTab === 'server-logs' || savedCenterTab === 'request-logs') { + setCenterPanelTab(savedCenterTab); + } setModelManagerWidth(settings.layout.modelManagerWidth ?? DEFAULT_LAYOUT_SETTINGS.modelManagerWidth); setChatWidth(settings.layout.chatWidth ?? DEFAULT_LAYOUT_SETTINGS.chatWidth); setLogsHeight(settings.layout.logsHeight ?? DEFAULT_LAYOUT_SETTINGS.logsHeight); @@ -105,6 +112,10 @@ const AppContent: React.FC = () => { if (urlParams.get('view') === 'logs') { setIsLogsVisible(true); } + if (urlParams.get('view') === 'request-logs') { + setIsLogsVisible(true); + setCenterPanelTab('request-logs'); + } setLayoutLoaded(true); } }; @@ -126,6 +137,7 @@ const AppContent: React.FC = () => { isModelManagerVisible, leftPanelView, isLogsVisible, + centerPanelTab, modelManagerWidth, chatWidth, logsHeight, @@ -135,7 +147,12 @@ const AppContent: React.FC = () => { } catch (error) { console.error('Failed to save layout settings:', error); } - }, [layoutLoaded, theme, isChatVisible, isModelManagerVisible, leftPanelView, isLogsVisible, modelManagerWidth, chatWidth, logsHeight]); + }, [layoutLoaded, theme, isChatVisible, isModelManagerVisible, leftPanelView, isLogsVisible, centerPanelTab, modelManagerWidth, chatWidth, logsHeight]); + + const handleOpenRequestLogs = useCallback(() => { + setIsLogsVisible(true); + setCenterPanelTab('request-logs'); + }, []); // Debounced save effect useEffect(() => { @@ -219,6 +236,10 @@ const AppContent: React.FC = () => { if (data.view === 'logs') { setIsLogsVisible(true); } + if (data.view === 'request-logs') { + setIsLogsVisible(true); + setCenterPanelTab('request-logs'); + } }); if (typeof unsub === 'function') unsubscribe = unsub; window?.api?.signalReady?.(); @@ -504,6 +525,7 @@ const AppContent: React.FC = () => { onToggleModelManager={() => setIsModelManagerVisible(!isModelManagerVisible)} isLogsVisible={isLogsVisible} onToggleLogs={() => setIsLogsVisible(!isLogsVisible)} + onOpenRequestLogs={handleOpenRequestLogs} isDownloadManagerVisible={isDownloadManagerVisible} onToggleDownloadManager={handleToggleDownloadManager} /> @@ -518,16 +540,20 @@ const AppContent: React.FC = () => { width={isModelManagerVisible ? modelManagerWidth : LAYOUT_CONSTANTS.experienceRailWidth} currentView={leftPanelView} onViewChange={setLeftPanelView} + onOpenRequestLogs={handleOpenRequestLogs} + isRequestLogsActive={isLogsVisible && centerPanelTab === 'request-logs'} /> {isModelManagerVisible && (isLogsVisible || isChatVisible) && ( )} {isLogsVisible && (
- + + {centerPanelTab === 'server-logs' ? ( + + ) : ( + + )}
)} {isChatVisible && ( diff --git a/src/app/src/renderer/CenterPanelTabs.tsx b/src/app/src/renderer/CenterPanelTabs.tsx new file mode 100644 index 000000000..23507d82e --- /dev/null +++ b/src/app/src/renderer/CenterPanelTabs.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +export type CenterPanelTab = 'server-logs' | 'request-logs'; + +interface CenterPanelTabsProps { + activeTab: CenterPanelTab; + onTabChange: (tab: CenterPanelTab) => void; +} + +const CenterPanelTabs: React.FC = ({ activeTab, onTabChange }) => { + return ( +
+ + +
+ ); +}; + +export default CenterPanelTabs; diff --git a/src/app/src/renderer/ModelManager.tsx b/src/app/src/renderer/ModelManager.tsx index 70f55218e..8812d78a6 100644 --- a/src/app/src/renderer/ModelManager.tsx +++ b/src/app/src/renderer/ModelManager.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { Boxes, Brain, ChevronRight, Cpu, Eye, Flame, Layers, ListOrdered, Settings, SlidersHorizontal, Sparkles, SquareCode, Store, User, Wrench, XIcon } from './components/Icons'; +import { Boxes, Brain, ChevronRight, Cpu, Eye, Flame, Layers, ListOrdered, ScrollText, Settings, SlidersHorizontal, Sparkles, SquareCode, Store, User, Wrench, XIcon } from './components/Icons'; import { ModelInfo, USER_MODEL_PREFIX } from './utils/modelData'; import { CANONICAL_PREFIXES, getModelDisplayName } from './utils/modelDisplayName'; import { ToastContainer, useToast } from './Toast'; @@ -328,6 +328,8 @@ interface ModelManagerProps { width?: number; currentView: LeftPanelView; onViewChange: (view: LeftPanelView) => void; + onOpenRequestLogs?: () => void; + isRequestLogsActive?: boolean; } interface ModelJSON { @@ -346,7 +348,15 @@ interface ModelJSON { export type LeftPanelView = 'models' | 'backends' | 'marketplace' | 'settings'; -const ModelManager: React.FC = ({ isContentVisible, onContentVisibilityChange, width = 280, currentView, onViewChange }) => { +const ModelManager: React.FC = ({ + isContentVisible, + onContentVisibilityChange, + width = 280, + currentView, + onViewChange, + onOpenRequestLogs, + isRequestLogsActive = false, +}) => { // Get shared model data from context const { modelsData, suggestedModels, refresh: refreshModels } = useModels(); // Get system context for lazy loading system info @@ -1814,6 +1824,14 @@ const [searchQuery, setSearchQuery] = useState(''); +
+
+ + + {stats && !error && ( +
+ + {stats.total_requests.toLocaleString()} requests + + + {stats.unique_client_ips.toLocaleString()} clients + + + {stats.keep_alive_requests.toLocaleString()} keep_alive + + + avg {Math.round(stats.avg_duration_ms)} ms + + {Object.entries(stats.by_endpoint_type).map(([type, count]) => ( + + {type}: {count} + + ))} +
+ )} + +
+ setFilters((prev) => ({ ...prev, model: e.target.value }))} + /> + setFilters((prev) => ({ ...prev, clientIp: e.target.value }))} + /> + setFilters((prev) => ({ ...prev, path: e.target.value }))} + /> + + + + +
+ + {error && ( +
+

{error}

+ {error.includes('not enabled') && ( +

+ See{' '} + + request logging setup + + . +

+ )} +
+ )} + + {!error && loading && ( +
Loading request logs…
+ )} + + {!error && !loading && entries.length === 0 && ( +
No matching requests
+ )} + + {!error && entries.length > 0 && ( +
+ + + + + + + + + + + + + + + + {entries.map((entry) => ( + setSelectedId(entry.id)} + > + + + + + + + + + + + ))} + +
TimeClientMethodPathModelKeep-aliveStreamStatusDuration
{formatTime(entry.created_at)} + {entry.client_ip ?? '—'} + {entry.method} + {entry.path} + {entry.model ?? '—'}{entry.keep_alive ?? '—'} + {entry.stream === null || entry.stream === undefined + ? '—' + : entry.stream + ? 'yes' + : 'no'} + + {entry.status_code ?? '—'} + {formatDuration(entry.duration_ms)}
+
+ )} + + {!error && hasMore && ( +
+ +
+ )} + + {selectedEntry && ( +
+
+ Request #{selectedEntry.id} + +
+
+
Endpoint type
+
{selectedEntry.endpoint_type ?? '—'}
+
Forwarded for
+
{selectedEntry.forwarded_for ?? '—'}
+
User agent
+
{selectedEntry.user_agent ?? '—'}
+
Query string
+
{selectedEntry.query_string ?? '—'}
+
Request bytes
+
{selectedEntry.request_body_bytes ?? '—'}
+
Response bytes
+
{selectedEntry.response_body_bytes ?? '—'}
+
Prompt chars
+
{selectedEntry.prompt_chars ?? '—'}
+
Messages chars
+
{selectedEntry.messages_chars ?? '—'}
+
Error
+
{selectedEntry.error ?? '—'}
+
+ {selectedEntry.redacted_body !== null && selectedEntry.redacted_body !== undefined && ( +
+              {JSON.stringify(selectedEntry.redacted_body, null, 2)}
+            
+ )} +
+ )} + + ); +}; + +export default RequestLogPanel; diff --git a/src/app/src/renderer/TitleBar.tsx b/src/app/src/renderer/TitleBar.tsx index 995e2f62e..3dfe58ccd 100644 --- a/src/app/src/renderer/TitleBar.tsx +++ b/src/app/src/renderer/TitleBar.tsx @@ -13,6 +13,7 @@ interface TitleBarProps { onToggleModelManager: () => void; isLogsVisible: boolean; onToggleLogs: () => void; + onOpenRequestLogs?: () => void; isDownloadManagerVisible: boolean; onToggleDownloadManager: () => void; } @@ -26,6 +27,7 @@ const TitleBar: React.FC = ({ onToggleModelManager, isLogsVisible, onToggleLogs, + onOpenRequestLogs, isDownloadManagerVisible, onToggleDownloadManager }) => { @@ -199,6 +201,9 @@ const TitleBar: React.FC = ({ {isLogsVisible ? '✓ ' : ''}Logs Ctrl+Shift+L +
{ onOpenRequestLogs?.(); setActiveMenu(null); }}> + Request Logs +
Theme diff --git a/src/app/src/renderer/components/Icons.tsx b/src/app/src/renderer/components/Icons.tsx index 9482f5134..a711e6ff6 100644 --- a/src/app/src/renderer/components/Icons.tsx +++ b/src/app/src/renderer/components/Icons.tsx @@ -271,3 +271,15 @@ export const PinIcon: React.FC<{ size?: number; fill?: string }> = ({ size = 14, ); + +export const ScrollText: React.FC = ({ size = 24, strokeWidth = 2 }) => ( + + + + + + + + + +); diff --git a/src/app/src/renderer/utils/appSettings.ts b/src/app/src/renderer/utils/appSettings.ts index b4a16d793..1f8d88283 100644 --- a/src/app/src/renderer/utils/appSettings.ts +++ b/src/app/src/renderer/utils/appSettings.ts @@ -24,6 +24,7 @@ export interface LayoutSettings { isModelManagerVisible: boolean; leftPanelView: 'models' | 'marketplace' | 'backends' | 'settings'; isLogsVisible: boolean; + centerPanelTab: 'server-logs' | 'request-logs'; modelManagerWidth: number; chatWidth: number; logsHeight: number; @@ -88,6 +89,7 @@ export const DEFAULT_LAYOUT_SETTINGS: LayoutSettings = { isModelManagerVisible: true, leftPanelView: 'models', isLogsVisible: false, + centerPanelTab: 'server-logs', modelManagerWidth: 280, chatWidth: 350, logsHeight: 200, @@ -263,6 +265,9 @@ export const mergeWithDefaultSettings = (incoming?: Partial): AppSe if (typeof rawLayout.isLogsVisible === 'boolean') { defaults.layout.isLogsVisible = rawLayout.isLogsVisible; } + if (rawLayout.centerPanelTab === 'server-logs' || rawLayout.centerPanelTab === 'request-logs') { + defaults.layout.centerPanelTab = rawLayout.centerPanelTab; + } // Merge numeric size settings if (typeof rawLayout.modelManagerWidth === 'number') { defaults.layout.modelManagerWidth = rawLayout.modelManagerWidth; diff --git a/src/app/src/renderer/utils/requestLogApi.ts b/src/app/src/renderer/utils/requestLogApi.ts new file mode 100644 index 000000000..021c81e3f --- /dev/null +++ b/src/app/src/renderer/utils/requestLogApi.ts @@ -0,0 +1,130 @@ +import { serverConfig } from './serverConfig'; + +export interface RequestLogEntry { + id: number; + created_at: string; + client_ip: string | null; + forwarded_for: string | null; + method: string; + path: string; + query_string: string | null; + status_code: number | null; + duration_ms: number | null; + user_agent: string | null; + endpoint_type: string | null; + model: string | null; + keep_alive: string | null; + stream: boolean | null; + request_body_bytes: number | null; + response_body_bytes: number | null; + prompt_chars: number | null; + messages_chars: number | null; + redacted_body: unknown; + error: string | null; +} + +export interface RequestLogSearchResponse { + entries: RequestLogEntry[]; + limit?: number; + offset?: number; +} + +export interface RequestLogStats { + since: string; + total_requests: number; + avg_duration_ms: number; + unique_client_ips: number; + keep_alive_requests: number; + by_endpoint_type: Record; + by_model: Record; +} + +export interface RequestLogSearchParams { + model?: string; + client_ip?: string; + path?: string; + since?: string; + keep_alive?: string; + limit?: number; + offset?: number; +} + +export class RequestLogApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'RequestLogApiError'; + this.status = status; + } +} + +function buildQuery(params: Record): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== '') { + search.set(key, String(value)); + } + } + const query = search.toString(); + return query ? `?${query}` : ''; +} + +async function parseJsonResponse(response: Response): Promise { + if (response.status === 401) { + throw new RequestLogApiError( + 'API key required — set it in Settings → Connection', + 401, + ); + } + if (response.status === 503) { + throw new RequestLogApiError( + 'Request logging is not enabled or the database is unavailable', + 503, + ); + } + if (!response.ok) { + let message = `Request failed (${response.status})`; + try { + const body = await response.json(); + if (body?.error && typeof body.error === 'string') { + message = body.error; + } + } catch { + // ignore parse errors + } + throw new RequestLogApiError(message, response.status); + } + return response.json() as Promise; +} + +export async function fetchRequestLogSearch( + params: RequestLogSearchParams, +): Promise { + const response = await serverConfig.fetch( + `/request-log/search${buildQuery({ + model: params.model, + client_ip: params.client_ip, + path: params.path, + since: params.since, + keep_alive: params.keep_alive, + limit: params.limit ?? 100, + offset: params.offset ?? 0, + })}`, + ); + return parseJsonResponse(response); +} + +export async function fetchRequestLogStats(since = '24h'): Promise { + const response = await serverConfig.fetch( + `/request-log/stats${buildQuery({ since })}`, + ); + return parseJsonResponse(response); +} + +export async function fetchRequestLogRecent(limit = 100): Promise { + const response = await serverConfig.fetch( + `/request-log/recent${buildQuery({ limit })}`, + ); + return parseJsonResponse(response); +} diff --git a/src/app/styles/styles.css b/src/app/styles/styles.css index f4c58ef46..d9c54cf87 100644 --- a/src/app/styles/styles.css +++ b/src/app/styles/styles.css @@ -7621,3 +7621,271 @@ input::-webkit-calendar-picker-indicator { color: var(--text-secondary); margin-top: 4px; } + +/* Center panel tabs (Server Logs / Request Logs) */ +.center-panel-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border-tertiary); + background: var(--header-bg-color); + flex-shrink: 0; +} + +.center-panel-tab { + appearance: none; + border: none; + background: transparent; + color: var(--text-quaternary); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.4px; + text-transform: uppercase; + padding: 10px 16px; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; +} + +.center-panel-tab:hover { + color: var(--text-secondary); + background: var(--bg-alpha-2); +} + +.center-panel-tab.active { + color: var(--text-primary); + border-bottom-color: var(--accent-primary, #6ba3d6); +} + +/* Request log panel */ +.request-log-panel { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + background: var(--bg-primary); + overflow: hidden; +} + +.request-log-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--border-tertiary); + flex-shrink: 0; +} + +.request-log-title { + margin: 0; + font-size: 0.75rem; + color: var(--header-text); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.request-log-toolbar-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.request-log-auto-refresh { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.72rem; + color: var(--text-quaternary); + cursor: pointer; +} + +.request-log-stats { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--border-tertiary); + flex-shrink: 0; +} + +.request-log-stat-chip { + font-size: 0.72rem; + padding: 4px 10px; + border-radius: 999px; + background: var(--bg-secondary); + color: var(--text-secondary); + border: 1px solid var(--border-secondary); +} + +.request-log-stat-chip--muted { + color: var(--text-quaternary); +} + +.request-log-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--border-tertiary); + flex-shrink: 0; + align-items: center; +} + +.request-log-filter-input { + min-width: 120px; + flex: 1 1 120px; + max-width: 180px; + height: 28px; + font-size: 0.75rem; +} + +.request-log-filter-select { + min-width: 130px; + height: 28px; + font-size: 0.75rem; +} + +.request-log-table-wrap { + flex: 1; + min-height: 0; + overflow: auto; +} + +.request-log-table { + width: 100%; + min-width: 900px; + border-collapse: collapse; + font-size: 0.75rem; +} + +.request-log-table th { + position: sticky; + top: 0; + z-index: 1; + text-align: left; + padding: 8px 10px; + background: var(--header-bg-color); + color: var(--text-quaternary); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + font-size: 0.65rem; + border-bottom: 1px solid var(--border-tertiary); + white-space: nowrap; +} + +.request-log-table td { + padding: 7px 10px; + border-bottom: 1px solid var(--border-tertiary); + color: var(--text-secondary); + vertical-align: top; +} + +.request-log-row { + cursor: pointer; +} + +.request-log-row:hover { + background: var(--bg-alpha-2); +} + +.request-log-row.selected { + background: var(--bg-secondary); +} + +.request-log-row--keep-alive-zero td:nth-child(6) { + color: #f59e0b; + font-weight: 600; +} + +.request-log-path-cell { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.request-log-status--ok { + color: #22c55e; +} + +.request-log-status--warn { + color: #f59e0b; +} + +.request-log-status--error { + color: #ef4444; +} + +.request-log-pagination { + padding: 10px 14px; + border-top: 1px solid var(--border-tertiary); + flex-shrink: 0; +} + +.request-log-detail { + border-top: 1px solid var(--border-tertiary); + padding: 12px 14px; + max-height: 220px; + overflow: auto; + flex-shrink: 0; + background: var(--bg-secondary); +} + +.request-log-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + font-size: 0.8rem; + color: var(--text-primary); +} + +.request-log-detail-grid { + display: grid; + grid-template-columns: 120px 1fr; + gap: 4px 12px; + margin: 0 0 10px; + font-size: 0.72rem; +} + +.request-log-detail-grid dt { + color: var(--text-quaternary); + margin: 0; +} + +.request-log-detail-grid dd { + margin: 0; + color: var(--text-secondary); + word-break: break-word; +} + +.request-log-json { + margin: 0; + padding: 10px; + border-radius: 4px; + background: var(--bg-primary); + border: 1px solid var(--border-secondary); + font-size: 0.68rem; + line-height: 1.4; + overflow: auto; + max-height: 120px; + color: var(--text-secondary); +} + +.request-log-empty, +.request-log-unavailable { + padding: 24px 14px; + color: var(--text-quaternary); + font-size: 0.8rem; + text-align: center; +} + +.request-log-unavailable-hint { + margin-top: 8px; + font-size: 0.75rem; +} + +.request-log-unavailable a { + color: var(--accent-primary, #6ba3d6); +} From 405c5df0a9ef1c5250fc0bf92d646c2cff46937d Mon Sep 17 00:00:00 2001 From: Chuck Pearce Date: Wed, 17 Jun 2026 09:28:15 -0400 Subject: [PATCH 04/16] feat(logging): enhance request log error handling and UI feedback This commit improves the user experience for the request log feature by adding detailed error messages and hints in the UI when logging is unavailable or the database connection fails. Key changes include: - Updated `RequestLogPanel` to display specific guidance based on the error type, including setup instructions for enabling request logging and ensuring the database is running. - Enhanced error handling in `requestLogApi.ts` to provide more informative messages when API requests fail. - Added new CSS styles for better presentation of error hints in the request log panel. These updates aim to assist users in troubleshooting issues related to request logging, making the setup process clearer and more user-friendly. --- src/app/src/renderer/RequestLogPanel.tsx | 21 ++++++++++++++ src/app/src/renderer/utils/requestLogApi.ts | 32 +++++++++++++-------- src/app/styles/styles.css | 13 +++++++++ src/cpp/server/request_log_handlers.cpp | 12 -------- src/cpp/server/request_log_service.cpp | 11 ++++--- 5 files changed, 61 insertions(+), 28 deletions(-) diff --git a/src/app/src/renderer/RequestLogPanel.tsx b/src/app/src/renderer/RequestLogPanel.tsx index 10e4a0abd..f429f919d 100644 --- a/src/app/src/renderer/RequestLogPanel.tsx +++ b/src/app/src/renderer/RequestLogPanel.tsx @@ -298,6 +298,27 @@ const RequestLogPanel: React.FC = () => {

{error}

{error.includes('not enabled') && ( +
+

Start lemond with:

+
{`export LEMONADE_REQUEST_LOG_ENABLED=true
+export LEMONADE_REQUEST_LOG_DATABASE_URL=postgresql://lemonade:change-me@127.0.0.1:/lemonade_logs
+./build/lemond`}
+

+ Use the URL printed by{' '} + ./examples/start-request-log-db.sh. Rebuild with{' '} + libpq-dev installed so request logging is compiled in. +

+
+ )} + {error.includes('database is unavailable') && ( +
+

Check that PostgreSQL is running and the URL matches the published port:

+
{`docker compose -f examples/docker-compose.request-log.yml ps
+# then restart lemond with the correct LEMONADE_REQUEST_LOG_DATABASE_URL`}
+
+ )} + {error.includes('not enabled') === false && + error.includes('database is unavailable') === false && (

See{' '} ): string } async function parseJsonResponse(response: Response): Promise { + const readErrorMessage = async (fallback: string): Promise => { + try { + const body = await response.json(); + if (body?.error && typeof body.error === 'string') { + return body.error; + } + } catch { + // ignore parse errors + } + return fallback; + }; + if (response.status === 401) { throw new RequestLogApiError( - 'API key required — set it in Settings → Connection', + await readErrorMessage('API key required — set it in Settings → Connection'), 401, ); } if (response.status === 503) { throw new RequestLogApiError( - 'Request logging is not enabled or the database is unavailable', + await readErrorMessage( + 'Request logging is not enabled or the database is unavailable', + ), 503, ); } if (!response.ok) { - let message = `Request failed (${response.status})`; - try { - const body = await response.json(); - if (body?.error && typeof body.error === 'string') { - message = body.error; - } - } catch { - // ignore parse errors - } - throw new RequestLogApiError(message, response.status); + throw new RequestLogApiError( + await readErrorMessage(`Request failed (${response.status})`), + response.status, + ); } return response.json() as Promise; } diff --git a/src/app/styles/styles.css b/src/app/styles/styles.css index d9c54cf87..fd4134a47 100644 --- a/src/app/styles/styles.css +++ b/src/app/styles/styles.css @@ -7889,3 +7889,16 @@ input::-webkit-calendar-picker-indicator { .request-log-unavailable a { color: var(--accent-primary, #6ba3d6); } + +.request-log-setup-snippet { + margin: 8px 0; + padding: 10px; + border-radius: 4px; + background: var(--bg-primary); + border: 1px solid var(--border-secondary); + font-size: 0.68rem; + line-height: 1.45; + text-align: left; + overflow-x: auto; + color: var(--text-secondary); +} diff --git a/src/cpp/server/request_log_handlers.cpp b/src/cpp/server/request_log_handlers.cpp index 4038324bc..5eb911d7f 100644 --- a/src/cpp/server/request_log_handlers.cpp +++ b/src/cpp/server/request_log_handlers.cpp @@ -29,10 +29,6 @@ void handle_request_log_recent(RequestLogService* service, set_service_unavailable(res, "Request logging is not enabled"); return; } - if (!service->is_database_available()) { - set_service_unavailable(res, "Request log database is unavailable"); - return; - } int limit = 100; if (req.has_param("limit")) { @@ -72,10 +68,6 @@ void handle_request_log_search(RequestLogService* service, set_service_unavailable(res, "Request logging is not enabled"); return; } - if (!service->is_database_available()) { - set_service_unavailable(res, "Request log database is unavailable"); - return; - } try { const auto payload = service->search(req); @@ -98,10 +90,6 @@ void handle_request_log_stats(RequestLogService* service, set_service_unavailable(res, "Request logging is not enabled"); return; } - if (!service->is_database_available()) { - set_service_unavailable(res, "Request log database is unavailable"); - return; - } try { const auto payload = service->get_stats(req); diff --git a/src/cpp/server/request_log_service.cpp b/src/cpp/server/request_log_service.cpp index 266e94467..9172a5182 100644 --- a/src/cpp/server/request_log_service.cpp +++ b/src/cpp/server/request_log_service.cpp @@ -374,7 +374,7 @@ bool RequestLogService::insert_entries(const std::vector& entri if (entries.empty()) { return true; } - if (!ensure_connection()) { + if (!ensure_connection() || !init_schema()) { return false; } @@ -611,7 +611,8 @@ void RequestLogService::purge_loop() { #ifdef LEMONADE_HAVE_REQUEST_LOG nlohmann::json RequestLogService::get_recent(int limit) const { - if (!const_cast(this)->ensure_connection()) { + if (!const_cast(this)->ensure_connection() || + !const_cast(this)->init_schema()) { throw std::runtime_error("Request log database is unavailable"); } @@ -643,7 +644,8 @@ nlohmann::json RequestLogService::get_recent(int limit) const { } nlohmann::json RequestLogService::search(const httplib::Request& req) const { - if (!const_cast(this)->ensure_connection()) { + if (!const_cast(this)->ensure_connection() || + !const_cast(this)->init_schema()) { throw std::runtime_error("Request log database is unavailable"); } @@ -708,7 +710,8 @@ nlohmann::json RequestLogService::search(const httplib::Request& req) const { } nlohmann::json RequestLogService::get_stats(const httplib::Request& req) const { - if (!const_cast(this)->ensure_connection()) { + if (!const_cast(this)->ensure_connection() || + !const_cast(this)->init_schema()) { throw std::runtime_error("Request log database is unavailable"); } From 94fcebfa7150759a9e18d85396d131ae599b709a Mon Sep 17 00:00:00 2001 From: Chuck Pearce Date: Wed, 17 Jun 2026 09:34:20 -0400 Subject: [PATCH 05/16] refactor(logging): remove deprecated row_to_json method and introduce new row_to_json_from_result function This commit removes the obsolete `row_to_json` method from the `RequestLogService` class and introduces a new `row_to_json_from_result` function. The new function is designed to convert PostgreSQL query results into JSON format, enhancing the handling of request log entries. This change streamlines the codebase and improves the clarity of the logging functionality. --- src/cpp/include/lemon/request_log_service.h | 1 - src/cpp/server/request_log_service.cpp | 205 ++++++++++---------- 2 files changed, 100 insertions(+), 106 deletions(-) diff --git a/src/cpp/include/lemon/request_log_service.h b/src/cpp/include/lemon/request_log_service.h index 473bc2ce0..41b8c2804 100644 --- a/src/cpp/include/lemon/request_log_service.h +++ b/src/cpp/include/lemon/request_log_service.h @@ -76,7 +76,6 @@ class RequestLogService { bool init_schema(); bool insert_entries(const std::vector& entries); void run_purge(); - nlohmann::json row_to_json(int row) const; #ifdef LEMONADE_HAVE_REQUEST_LOG mutable std::mutex db_mutex_; diff --git a/src/cpp/server/request_log_service.cpp b/src/cpp/server/request_log_service.cpp index 9172a5182..050970e40 100644 --- a/src/cpp/server/request_log_service.cpp +++ b/src/cpp/server/request_log_service.cpp @@ -168,6 +168,48 @@ CREATE INDEX IF NOT EXISTS idx_request_logs_client_ip ON request_logs (client_ip CREATE INDEX IF NOT EXISTS idx_request_logs_path ON request_logs (path); CREATE INDEX IF NOT EXISTS idx_request_logs_keep_alive ON request_logs (keep_alive); )SQL"; + +nlohmann::json row_to_json_from_result(PGresult* result, int row) { + auto value = [result, row](int column) -> std::string { + const char* raw = PQgetvalue(result, row, column); + return raw ? raw : ""; + }; + + nlohmann::json entry = { + {"id", std::stoll(value(0))}, + {"created_at", value(1)}, + {"client_ip", value(2)}, + {"forwarded_for", value(3)}, + {"method", value(4)}, + {"path", value(5)}, + {"query_string", value(6)}, + {"status_code", value(7).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(7)))}, + {"duration_ms", value(8).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(8)))}, + {"user_agent", value(9)}, + {"endpoint_type", value(10)}, + {"model", value(11)}, + {"keep_alive", value(12).empty() ? nlohmann::json(nullptr) : nlohmann::json(value(12))}, + {"stream", value(13).empty() ? nlohmann::json(nullptr) : nlohmann::json(value(13) == "t")}, + {"request_body_bytes", value(14).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(14)))}, + {"response_body_bytes", value(15).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(15)))}, + {"prompt_chars", value(16).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(16)))}, + {"messages_chars", value(17).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(17)))}, + {"error", value(19).empty() ? nlohmann::json(nullptr) : nlohmann::json(value(19))}, + }; + + const char* redacted = PQgetvalue(result, row, 18); + if (redacted && *redacted) { + try { + entry["redacted_body"] = nlohmann::json::parse(redacted); + } catch (...) { + entry["redacted_body"] = redacted; + } + } else { + entry["redacted_body"] = nullptr; + } + return entry; +} + #endif } // namespace @@ -507,109 +549,6 @@ void RequestLogService::run_purge() { } } -nlohmann::json RequestLogService::row_to_json(int row) const { - auto value = [this, row](int column) -> std::string { - const char* raw = PQgetvalue(as_pg_conn(pg_conn_), row, column); - return raw ? raw : ""; - }; - - nlohmann::json entry = { - {"id", std::stoll(value(0))}, - {"created_at", value(1)}, - {"client_ip", value(2)}, - {"forwarded_for", value(3)}, - {"method", value(4)}, - {"path", value(5)}, - {"query_string", value(6)}, - {"status_code", value(7).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(7)))}, - {"duration_ms", value(8).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(8)))}, - {"user_agent", value(9)}, - {"endpoint_type", value(10)}, - {"model", value(11)}, - {"keep_alive", value(12).empty() ? nlohmann::json(nullptr) : nlohmann::json(value(12))}, - {"stream", value(13).empty() ? nlohmann::json(nullptr) : nlohmann::json(value(13) == "t")}, - {"request_body_bytes", value(14).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(14)))}, - {"response_body_bytes", value(15).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(15)))}, - {"prompt_chars", value(16).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(16)))}, - {"messages_chars", value(17).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(17)))}, - {"error", value(19).empty() ? nlohmann::json(nullptr) : nlohmann::json(value(19))}, - }; - - const char* redacted = PQgetvalue(as_pg_conn(pg_conn_), row, 18); - if (redacted && *redacted) { - try { - entry["redacted_body"] = nlohmann::json::parse(redacted); - } catch (...) { - entry["redacted_body"] = redacted; - } - } else { - entry["redacted_body"] = nullptr; - } - return entry; -} - -#else - -bool RequestLogService::ensure_connection() { return false; } -void RequestLogService::close_connection() {} -bool RequestLogService::init_schema() { return false; } -bool RequestLogService::insert_entries(const std::vector&) { return false; } -void RequestLogService::run_purge() {} -nlohmann::json RequestLogService::row_to_json(int) const { return nlohmann::json::object(); } - -#endif - -void RequestLogService::writer_loop() { - while (running_.load()) { - std::vector batch; - { - std::unique_lock lock(queue_mutex_); - queue_cv_.wait_for(lock, std::chrono::milliseconds(500), [this]() { - return !running_.load() || !queue_.empty(); - }); - if (!running_.load() && queue_.empty()) { - break; - } - while (!queue_.empty() && batch.size() < 50) { - batch.push_back(std::move(queue_.front())); - queue_.pop_front(); - } - } - - if (!batch.empty()) { - if (!insert_entries(batch)) { - LOG(WARNING, "RequestLog") - << "Request log insert failed; entries in this batch were dropped." - << std::endl; - } - } - } - - std::vector remaining; - { - std::lock_guard lock(queue_mutex_); - remaining.assign(std::make_move_iterator(queue_.begin()), - std::make_move_iterator(queue_.end())); - queue_.clear(); - } - if (!remaining.empty()) { - insert_entries(remaining); - } -} - -void RequestLogService::purge_loop() { - while (running_.load()) { - for (int i = 0; i < 3600 && running_.load(); ++i) { - std::this_thread::sleep_for(std::chrono::seconds(1)); - } - if (!running_.load()) { - break; - } - run_purge(); - } -} - -#ifdef LEMONADE_HAVE_REQUEST_LOG nlohmann::json RequestLogService::get_recent(int limit) const { if (!const_cast(this)->ensure_connection() || !const_cast(this)->init_schema()) { @@ -637,7 +576,7 @@ nlohmann::json RequestLogService::get_recent(int limit) const { nlohmann::json entries = nlohmann::json::array(); const int rows = PQntuples(result); for (int i = 0; i < rows; ++i) { - entries.push_back(row_to_json(i)); + entries.push_back(row_to_json_from_result(result, i)); } PQclear(result); return {{"entries", entries}}; @@ -703,7 +642,7 @@ nlohmann::json RequestLogService::search(const httplib::Request& req) const { nlohmann::json entries = nlohmann::json::array(); const int rows = PQntuples(result); for (int i = 0; i < rows; ++i) { - entries.push_back(row_to_json(i)); + entries.push_back(row_to_json_from_result(result, i)); } PQclear(result); return {{"entries", entries}, {"limit", limit}, {"offset", offset}}; @@ -781,6 +720,12 @@ nlohmann::json RequestLogService::get_stats(const httplib::Request& req) const { #else +bool RequestLogService::ensure_connection() { return false; } +void RequestLogService::close_connection() {} +bool RequestLogService::init_schema() { return false; } +bool RequestLogService::insert_entries(const std::vector&) { return false; } +void RequestLogService::run_purge() {} + nlohmann::json RequestLogService::get_recent(int) const { throw std::runtime_error("Request logging is not available in this build"); } @@ -795,4 +740,54 @@ nlohmann::json RequestLogService::get_stats(const httplib::Request&) const { #endif +void RequestLogService::writer_loop() { + while (running_.load()) { + std::vector batch; + { + std::unique_lock lock(queue_mutex_); + queue_cv_.wait_for(lock, std::chrono::milliseconds(500), [this]() { + return !running_.load() || !queue_.empty(); + }); + if (!running_.load() && queue_.empty()) { + break; + } + while (!queue_.empty() && batch.size() < 50) { + batch.push_back(std::move(queue_.front())); + queue_.pop_front(); + } + } + + if (!batch.empty()) { + if (!insert_entries(batch)) { + LOG(WARNING, "RequestLog") + << "Request log insert failed; entries in this batch were dropped." + << std::endl; + } + } + } + + std::vector remaining; + { + std::lock_guard lock(queue_mutex_); + remaining.assign(std::make_move_iterator(queue_.begin()), + std::make_move_iterator(queue_.end())); + queue_.clear(); + } + if (!remaining.empty()) { + insert_entries(remaining); + } +} + +void RequestLogService::purge_loop() { + while (running_.load()) { + for (int i = 0; i < 3600 && running_.load(); ++i) { + std::this_thread::sleep_for(std::chrono::seconds(1)); + } + if (!running_.load()) { + break; + } + run_purge(); + } +} + } // namespace lemon From f2d602c4d176607092519ff2868344afe21dd286 Mon Sep 17 00:00:00 2001 From: Chuck Pearce Date: Wed, 17 Jun 2026 09:48:55 -0400 Subject: [PATCH 06/16] feat(logging): add UTF-8 sanitization for database storage This commit introduces a new function, `sanitize_utf8_for_db`, to ensure that strings stored in the database are valid UTF-8. The function replaces invalid bytes with a replacement character. Additionally, the `extract_response_error` function has been updated to utilize this sanitization method for error messages, enhancing the robustness of error handling. The `RequestLogService` class has also been modified to reset the schema initialization flag upon closing connections, ensuring proper state management. Unit tests have been added to verify the functionality of the new sanitization feature. --- src/cpp/include/lemon/request_log_parser.h | 2 + src/cpp/include/lemon/request_log_service.h | 2 + src/cpp/server/request_log_parser.cpp | 104 ++++++++++++++++++-- src/cpp/server/request_log_service.cpp | 21 +++- test/cpp/test_request_log_parser.cpp | 20 ++++ 5 files changed, 142 insertions(+), 7 deletions(-) diff --git a/src/cpp/include/lemon/request_log_parser.h b/src/cpp/include/lemon/request_log_parser.h index a1c98032c..8b72f3080 100644 --- a/src/cpp/include/lemon/request_log_parser.h +++ b/src/cpp/include/lemon/request_log_parser.h @@ -30,6 +30,8 @@ nlohmann::json redact_json(const nlohmann::json& value); std::string extract_response_error(const std::string& response_body, int status_code); +std::string sanitize_utf8_for_db(std::string value); + bool should_skip_request_log_path(const std::string& path, const std::string& method); } // namespace lemon diff --git a/src/cpp/include/lemon/request_log_service.h b/src/cpp/include/lemon/request_log_service.h index 41b8c2804..7bc72e5d8 100644 --- a/src/cpp/include/lemon/request_log_service.h +++ b/src/cpp/include/lemon/request_log_service.h @@ -77,6 +77,8 @@ class RequestLogService { bool insert_entries(const std::vector& entries); void run_purge(); + bool schema_initialized_ = false; + #ifdef LEMONADE_HAVE_REQUEST_LOG mutable std::mutex db_mutex_; void* pg_conn_ = nullptr; // PGconn*, opaque to avoid header dependency diff --git a/src/cpp/server/request_log_parser.cpp b/src/cpp/server/request_log_parser.cpp index 4e94f6cbb..1ff51829e 100644 --- a/src/cpp/server/request_log_parser.cpp +++ b/src/cpp/server/request_log_parser.cpp @@ -107,6 +107,47 @@ std::string json_value_to_string(const nlohmann::json& value) { constexpr size_t kMaxRedactedBodyBytes = 32768; +bool is_valid_utf8(const std::string& value) { + size_t i = 0; + while (i < value.size()) { + const unsigned char byte = static_cast(value[i]); + if (byte <= 0x7F) { + ++i; + continue; + } + size_t extra = 0; + if ((byte & 0xE0) == 0xC0) { + extra = 1; + } else if ((byte & 0xF0) == 0xE0) { + extra = 2; + } else if ((byte & 0xF8) == 0xF0) { + extra = 3; + } else { + return false; + } + if (i + extra >= value.size()) { + return false; + } + for (size_t j = 1; j <= extra; ++j) { + const unsigned char continuation = + static_cast(value[i + j]); + if ((continuation & 0xC0) != 0x80) { + return false; + } + } + i += extra + 1; + } + return true; +} + +bool looks_like_binary_payload(const std::string& value) { + if (value.size() >= 2 && static_cast(value[0]) == 0x1F && + static_cast(value[1]) == 0x8B) { + return true; + } + return !is_valid_utf8(value); +} + } // namespace nlohmann::json redact_json(const nlohmann::json& value) { @@ -265,6 +306,54 @@ ParsedRequestBody parse_request_body(const std::string& body, return parsed; } +std::string sanitize_utf8_for_db(std::string value) { + if (value.empty() || is_valid_utf8(value)) { + return value; + } + + std::string sanitized; + sanitized.reserve(value.size()); + size_t i = 0; + while (i < value.size()) { + const unsigned char byte = static_cast(value[i]); + size_t seq_len = 1; + bool valid = true; + if (byte <= 0x7F) { + seq_len = 1; + } else if ((byte & 0xE0) == 0xC0) { + seq_len = 2; + } else if ((byte & 0xF0) == 0xE0) { + seq_len = 3; + } else if ((byte & 0xF8) == 0xF0) { + seq_len = 4; + } else { + valid = false; + } + + if (valid && i + seq_len <= value.size()) { + for (size_t j = 1; j < seq_len; ++j) { + const unsigned char continuation = + static_cast(value[i + j]); + if ((continuation & 0xC0) != 0x80) { + valid = false; + break; + } + } + } else { + valid = false; + } + + if (valid) { + sanitized.append(value, i, seq_len); + i += seq_len; + } else { + sanitized.append("\xEF\xBF\xBD", 3); + ++i; + } + } + return sanitized; +} + std::string extract_response_error(const std::string& response_body, int status_code) { if (status_code >= 200 && status_code < 300) { return {}; @@ -278,25 +367,28 @@ std::string extract_response_error(const std::string& response_body, int status_ if (response_json.contains("error")) { const auto& error = response_json["error"]; if (error.is_string()) { - return error.get(); + return sanitize_utf8_for_db(error.get()); } if (error.is_object() && error.contains("message") && error["message"].is_string()) { - return error["message"].get(); + return sanitize_utf8_for_db(error["message"].get()); } - return error.dump(); + return sanitize_utf8_for_db(error.dump()); } if (response_json.contains("message") && response_json["message"].is_string()) { - return response_json["message"].get(); + return sanitize_utf8_for_db(response_json["message"].get()); } } } catch (...) { } + if (looks_like_binary_payload(response_body)) { + return "[non-UTF-8 response body, " + std::to_string(response_body.size()) + " bytes]"; + } if (response_body.size() > 512) { - return response_body.substr(0, 512); + return sanitize_utf8_for_db(response_body.substr(0, 512)); } - return response_body; + return sanitize_utf8_for_db(response_body); } bool should_skip_request_log_path(const std::string& path, const std::string& method) { diff --git a/src/cpp/server/request_log_service.cpp b/src/cpp/server/request_log_service.cpp index 050970e40..65393eed0 100644 --- a/src/cpp/server/request_log_service.cpp +++ b/src/cpp/server/request_log_service.cpp @@ -392,9 +392,13 @@ void RequestLogService::close_connection() { PQfinish(as_pg_conn(pg_conn_)); pg_conn_ = nullptr; } + schema_initialized_ = false; } bool RequestLogService::init_schema() { + if (schema_initialized_) { + return true; + } std::lock_guard lock(db_mutex_); if (!pg_conn_ || PQstatus(as_pg_conn(pg_conn_)) != CONNECTION_OK) { return false; @@ -409,6 +413,9 @@ bool RequestLogService::init_schema() { if (result) { PQclear(result); } + if (ok) { + schema_initialized_ = true; + } return ok; } @@ -453,10 +460,22 @@ bool RequestLogService::insert_entries(const std::vector& entri entry.prompt_chars = parsed.prompt_chars; entry.messages_chars = parsed.messages_chars; if (parsed.has_redacted_body) { - entry.redacted_body_json = parsed.redacted_body.dump(); + entry.redacted_body_json = + sanitize_utf8_for_db(parsed.redacted_body.dump()); entry.has_redacted_body = true; } + entry.client_ip = sanitize_utf8_for_db(std::move(entry.client_ip)); + entry.forwarded_for = sanitize_utf8_for_db(std::move(entry.forwarded_for)); + entry.method = sanitize_utf8_for_db(std::move(entry.method)); + entry.path = sanitize_utf8_for_db(std::move(entry.path)); + entry.query_string = sanitize_utf8_for_db(std::move(entry.query_string)); + entry.user_agent = sanitize_utf8_for_db(std::move(entry.user_agent)); + entry.endpoint_type = sanitize_utf8_for_db(std::move(entry.endpoint_type)); + entry.model = sanitize_utf8_for_db(std::move(entry.model)); + entry.keep_alive = sanitize_utf8_for_db(std::move(entry.keep_alive)); + entry.error = sanitize_utf8_for_db(std::move(entry.error)); + const std::string status_code = std::to_string(entry.status_code); const std::string duration_ms = std::to_string(entry.duration_ms); const std::string request_body_bytes = std::to_string(entry.request_body_bytes); diff --git a/test/cpp/test_request_log_parser.cpp b/test/cpp/test_request_log_parser.cpp index 4fcf7170c..f9e1de452 100644 --- a/test/cpp/test_request_log_parser.cpp +++ b/test/cpp/test_request_log_parser.cpp @@ -8,8 +8,10 @@ using lemon::ParsedRequestBody; using lemon::classify_endpoint_type; +using lemon::extract_response_error; using lemon::parse_request_body; using lemon::redact_json; +using lemon::sanitize_utf8_for_db; struct TestResult { int passed = 0; @@ -90,11 +92,29 @@ static void test_char_counts_without_prompt_logging(TestResult& result) { } } +static void test_binary_response_error(TestResult& result) { + const std::string gzip_like = std::string{'\x1f', '\x8b', '\x08', '\x00'}; + const std::string extracted = extract_response_error(gzip_like, 404); + if (extracted.find("non-UTF-8") != std::string::npos) { + result.ok("binary response error sanitized"); + } else { + result.fail("binary response error sanitized"); + } + + const std::string sanitized = sanitize_utf8_for_db(std::string{'\x8b', 'x'}); + if (sanitized.find('\x8b') == std::string::npos && !sanitized.empty()) { + result.ok("invalid utf8 bytes replaced"); + } else { + result.fail("invalid utf8 bytes replaced"); + } +} + int main() { TestResult result; test_endpoint_classification(result); test_redaction(result); test_char_counts_without_prompt_logging(result); + test_binary_response_error(result); printf("\nResults: %d passed, %d failed\n", result.passed, result.failed); return result.failed == 0 ? 0 : 1; From 2cd6429416f5034c6468d743a0bc66231910b0a6 Mon Sep 17 00:00:00 2001 From: Chuck Pearce Date: Wed, 17 Jun 2026 11:35:45 -0400 Subject: [PATCH 07/16] feat(logging): enhance request logging with clear endpoint and token tracking This commit introduces a new endpoint for clearing request logs, allowing users to delete all HTTP request log entries from the database. Additionally, the request log schema has been updated to include `prompt_tokens` and `completion_tokens`, enabling better tracking of token usage in requests. The UI has been enhanced to support the new clear log functionality, including a confirmation dialog and feedback on the operation's success. Unit tests have been added to ensure the correct behavior of the new clear endpoint and token tracking features. --- AGENTS.md | 2 +- examples/find-ollama-api-clients.sh | 72 ++++++++++ sql/request_logs_init.sql | 7 + src/app/src/renderer/RequestLogPanel.tsx | 138 +++++++++++++++++-- src/app/src/renderer/utils/requestLogApi.ts | 14 ++ src/app/styles/styles.css | 45 +++++- src/cpp/include/lemon/request_log_handlers.h | 4 + src/cpp/include/lemon/request_log_parser.h | 12 ++ src/cpp/include/lemon/request_log_service.h | 6 + src/cpp/include/lemon/server.h | 1 + src/cpp/server/ollama_api.cpp | 15 ++ src/cpp/server/request_log_handlers.cpp | 22 +++ src/cpp/server/request_log_parser.cpp | 124 +++++++++++++++++ src/cpp/server/request_log_service.cpp | 101 ++++++++++++-- src/cpp/server/server.cpp | 20 ++- test/cpp/test_request_log_parser.cpp | 31 +++++ test/server_endpoints.py | 1 + test/server_request_log.py | 25 ++++ 18 files changed, 618 insertions(+), 22 deletions(-) create mode 100755 examples/find-ollama-api-clients.sh diff --git a/AGENTS.md b/AGENTS.md index dc72888b5..41955b8e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ All core endpoints are registered under **4 path prefixes**: - `/v0/` — Legacy short - `/v1/` — OpenAI SDK / LiteLLM compatibility -**Core endpoints:** `chat/completions`, `completions`, `embeddings`, `reranking`, `models`, `models/{id}`, `health`, `pull`, `load`, `unload`, `delete`, `params`, `install`, `uninstall`, `audio/transcriptions`, `audio/speech`, `images/generations`, `images/edits`, `images/variations`, `responses`, `stats`, `system-info`, `system-stats`, `log-level`, `logs/stream`, `request-log/recent`, `request-log/search`, `request-log/stats` +**Core endpoints:** `chat/completions`, `completions`, `embeddings`, `reranking`, `models`, `models/{id}`, `health`, `pull`, `load`, `unload`, `delete`, `params`, `install`, `uninstall`, `audio/transcriptions`, `audio/speech`, `images/generations`, `images/edits`, `images/variations`, `responses`, `stats`, `system-info`, `system-stats`, `log-level`, `logs/stream`, `request-log/recent`, `request-log/search`, `request-log/stats`, `request-log/clear` **Ollama-compatible endpoints** (under `/api/` without version prefix): `chat`, `generate`, `tags`, `show`, `delete`, `pull`, `embed`, `embeddings`, `ps`, `version` diff --git a/examples/find-ollama-api-clients.sh b/examples/find-ollama-api-clients.sh new file mode 100755 index 000000000..8690d69e2 --- /dev/null +++ b/examples/find-ollama-api-clients.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Find local processes sending Ollama-compatible API traffic to Lemonade. +# +# Usage: +# ./examples/find-ollama-api-clients.sh +# LEMONADE_PORT=11434 ./examples/find-ollama-api-clients.sh +# LEMONADE_BASE_URL=http://127.0.0.1:11434 ./examples/find-ollama-api-clients.sh + +set -euo pipefail + +PORT="${LEMONADE_PORT:-11434}" +BASE_URL="${LEMONADE_BASE_URL:-http://127.0.0.1:${PORT}}" + +echo "=== Lemonade Ollama API client diagnostics ===" +echo "Port: ${PORT}" +echo "Base URL: ${BASE_URL}" +echo + +echo "--- TCP clients connected to :${PORT} ---" +if command -v ss >/dev/null 2>&1; then + ss -tnp 2>/dev/null | grep ":${PORT}" || echo "(no connections)" +elif command -v lsof >/dev/null 2>&1; then + lsof -nP -iTCP:"${PORT}" -sTCP:ESTABLISHED 2>/dev/null || echo "(no connections)" +else + echo "Install ss or lsof to list connected processes." +fi +echo + +echo "--- Python processes (common ollama-python clients) ---" +if pgrep -a python >/dev/null 2>&1 || pgrep -a python3 >/dev/null 2>&1; then + pgrep -a python 2>/dev/null || true + pgrep -a python3 2>/dev/null || true +else + echo "(no python processes)" +fi +echo + +echo "--- Recent POST /api/chat from request-log API (if enabled) ---" +if command -v curl >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then + headers=() + if [[ -n "${LEMONADE_API_KEY:-}" ]]; then + headers=(-H "Authorization: Bearer ${LEMONADE_API_KEY}") + elif [[ -n "${LEMONADE_ADMIN_API_KEY:-}" ]]; then + headers=(-H "Authorization: Bearer ${LEMONADE_ADMIN_API_KEY}") + fi + response="$(curl -fsS "${headers[@]}" \ + "${BASE_URL}/api/v1/request-log/search?path=%25chat%25&limit=15" 2>/dev/null || true)" + if [[ -n "${response}" ]]; then + echo "${response}" | jq -r '.entries[]? | "\(.created_at) ip=\(.client_ip) model=\(.model) ua=\(.user_agent)"' + else + echo "Request log API unavailable (logging disabled, auth required, or wrong port)." + echo "Query manually:" + echo " curl -s '${BASE_URL}/api/v1/request-log/search?path=%25chat%25&limit=15' | jq ." + fi +else + echo "Install curl and jq to query the request-log API." +fi +echo + +echo "--- Tips ---" +cat <<'EOF' +1. user_agent "ollama-python/..." means a local Python script using the PyPI + `ollama` package (pip install ollama), not Lemonade itself. +2. Default client host is http://127.0.0.1:11434 — same as Ollama's port. +3. Find the script: + pgrep -af python + sudo lsof -iTCP:11434 -sTCP:ESTABLISHED + tr '\0' ' ' < /proc//cmdline; ls -l /proc//cwd +4. Stop it: kill , or reconfigure it to use Lemonade model names from + `lemonade list`, or point OLLAMA_HOST at a real Ollama server. +5. To avoid accidental clients, run Lemonade on a non-Ollama port (e.g. 13305). +EOF diff --git a/sql/request_logs_init.sql b/sql/request_logs_init.sql index 2a4165360..41f19f5da 100644 --- a/sql/request_logs_init.sql +++ b/sql/request_logs_init.sql @@ -18,7 +18,10 @@ CREATE TABLE IF NOT EXISTS request_logs ( response_body_bytes INTEGER, prompt_chars INTEGER, messages_chars INTEGER, + prompt_tokens INTEGER, + completion_tokens INTEGER, redacted_body JSONB, + redacted_response JSONB, error TEXT ); @@ -27,3 +30,7 @@ CREATE INDEX IF NOT EXISTS idx_request_logs_model ON request_logs (model); CREATE INDEX IF NOT EXISTS idx_request_logs_client_ip ON request_logs (client_ip); CREATE INDEX IF NOT EXISTS idx_request_logs_path ON request_logs (path); CREATE INDEX IF NOT EXISTS idx_request_logs_keep_alive ON request_logs (keep_alive); + +ALTER TABLE request_logs ADD COLUMN IF NOT EXISTS prompt_tokens INTEGER; +ALTER TABLE request_logs ADD COLUMN IF NOT EXISTS completion_tokens INTEGER; +ALTER TABLE request_logs ADD COLUMN IF NOT EXISTS redacted_response JSONB; diff --git a/src/app/src/renderer/RequestLogPanel.tsx b/src/app/src/renderer/RequestLogPanel.tsx index f429f919d..4b2d919fd 100644 --- a/src/app/src/renderer/RequestLogPanel.tsx +++ b/src/app/src/renderer/RequestLogPanel.tsx @@ -1,11 +1,13 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { + clearRequestLogs, fetchRequestLogSearch, fetchRequestLogStats, RequestLogApiError, RequestLogEntry, RequestLogStats, } from './utils/requestLogApi'; +import { writeClipboard } from './utils/clipboardUtils'; type KeepAliveFilter = 'any' | 'zero' | 'has'; type SinceFilter = '1h' | '24h' | '7d'; @@ -66,6 +68,42 @@ function formatDuration(ms: number | null): string { return `${(ms / 1000).toFixed(2)} s`; } +function formatTokens(entry: RequestLogEntry): string { + if (entry.prompt_tokens === null && entry.completion_tokens === null) { + return '—'; + } + const input = entry.prompt_tokens ?? '—'; + const output = entry.completion_tokens ?? '—'; + return `${input} / ${output}`; +} + +function formatJsonBlock(value: unknown): string { + if (value === null || value === undefined) { + return '—'; + } + if (typeof value === 'string') { + return value; + } + return JSON.stringify(value, null, 2); +} + +function payloadUsesCharCountsOnly(value: unknown): boolean { + if (!value || typeof value !== 'object') { + return false; + } + const record = value as Record; + if (record.prompt && typeof record.prompt === 'object' && record.prompt !== null) { + return 'char_count' in (record.prompt as Record); + } + if (record.messages && typeof record.messages === 'object' && record.messages !== null) { + return 'char_count' in (record.messages as Record); + } + if (record.content && typeof record.content === 'object' && record.content !== null) { + return 'char_count' in (record.content as Record); + } + return false; +} + function applyKeepAliveClientFilter( entries: RequestLogEntry[], keepAlive: KeepAliveFilter, @@ -88,6 +126,8 @@ const RequestLogPanel: React.FC = () => { const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const [autoRefresh, setAutoRefresh] = useState(false); + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle'); + const [clearing, setClearing] = useState(false); const selectedEntry = useMemo( () => entries.find((entry) => entry.id === selectedId) ?? null, @@ -178,26 +218,69 @@ const RequestLogPanel: React.FC = () => { return () => window.clearInterval(intervalId); }, [autoRefresh, loadData]); + useEffect(() => { + setCopyState('idle'); + }, [selectedId]); + const handleApply = () => { setAppliedFilters({ ...filters }); }; - const handleClear = () => { + const handleClearFilters = () => { setFilters(DEFAULT_FILTERS); setAppliedFilters(DEFAULT_FILTERS); }; + const handleClearLog = async () => { + if (error) { + return; + } + const confirmed = window.confirm( + 'Delete all HTTP request log entries from the database? This cannot be undone.', + ); + if (!confirmed) { + return; + } + + setClearing(true); + try { + await clearRequestLogs(); + setSelectedId(null); + setOffset(0); + setHasMore(false); + await loadData(); + } catch (err) { + const message = + err instanceof RequestLogApiError + ? err.message + : err instanceof Error + ? err.message + : 'Failed to clear request logs'; + setError(message); + } finally { + setClearing(false); + } + }; + const handleCopyRow = async () => { if (!selectedEntry) { return; } try { - await navigator.clipboard.writeText(JSON.stringify(selectedEntry, null, 2)); + await writeClipboard(JSON.stringify(selectedEntry, null, 2)); + setCopyState('copied'); + window.setTimeout(() => setCopyState('idle'), 2000); } catch { - // clipboard unavailable + setCopyState('failed'); + window.setTimeout(() => setCopyState('idle'), 2000); } }; + const requestRedacted = selectedEntry?.redacted_body; + const responseRedacted = selectedEntry?.redacted_response; + const showRedactionHint = + payloadUsesCharCountsOnly(requestRedacted) || payloadUsesCharCountsOnly(responseRedacted); + return (

@@ -289,7 +380,7 @@ const RequestLogPanel: React.FC = () => { -
@@ -352,6 +443,7 @@ export LEMONADE_REQUEST_LOG_DATABASE_URL=postgresql://lemonade:change-me@127.0.0 Method Path Model + Tokens in/out Keep-alive Stream Status @@ -376,6 +468,7 @@ export LEMONADE_REQUEST_LOG_DATABASE_URL=postgresql://lemonade:change-me@127.0.0 {entry.path} {entry.model ?? '—'} + {formatTokens(entry)} {entry.keep_alive ?? '—'} {entry.stream === null || entry.stream === undefined @@ -413,7 +506,7 @@ export LEMONADE_REQUEST_LOG_DATABASE_URL=postgresql://lemonade:change-me@127.0.0
Request #{selectedEntry.id}
@@ -433,14 +526,41 @@ export LEMONADE_REQUEST_LOG_DATABASE_URL=postgresql://lemonade:change-me@127.0.0
{selectedEntry.prompt_chars ?? '—'}
Messages chars
{selectedEntry.messages_chars ?? '—'}
+
Input tokens
+
{selectedEntry.prompt_tokens ?? '—'}
+
Output tokens
+
{selectedEntry.completion_tokens ?? '—'}
Error
{selectedEntry.error ?? '—'}
- {selectedEntry.redacted_body !== null && selectedEntry.redacted_body !== undefined && ( -
-              {JSON.stringify(selectedEntry.redacted_body, null, 2)}
-            
+ + {showRedactionHint && ( +

+ Prompt and response text are summarized by character count unless{' '} + LEMONADE_LOG_PROMPTS=true is set on the server. Restart{' '} + lemond after changing it. +

)} + +
+

Request payload (redacted)

+ {requestRedacted !== null && requestRedacted !== undefined ? ( +
{formatJsonBlock(requestRedacted)}
+ ) : ( +

No request body recorded

+ )} +
+ +
+

API response (redacted)

+ {responseRedacted !== null && responseRedacted !== undefined ? ( +
{formatJsonBlock(responseRedacted)}
+ ) : ( +

+ No response body recorded (common for streaming requests or empty errors) +

+ )} +
)} diff --git a/src/app/src/renderer/utils/requestLogApi.ts b/src/app/src/renderer/utils/requestLogApi.ts index 1bdc4c7e3..12c9f1cd5 100644 --- a/src/app/src/renderer/utils/requestLogApi.ts +++ b/src/app/src/renderer/utils/requestLogApi.ts @@ -19,7 +19,10 @@ export interface RequestLogEntry { response_body_bytes: number | null; prompt_chars: number | null; messages_chars: number | null; + prompt_tokens: number | null; + completion_tokens: number | null; redacted_body: unknown; + redacted_response: unknown; error: string | null; } @@ -136,3 +139,14 @@ export async function fetchRequestLogRecent(limit = 100): Promise(response); } + +export interface RequestLogClearResponse { + deleted: number; +} + +export async function clearRequestLogs(): Promise { + const response = await serverConfig.fetch('/request-log/clear', { + method: 'POST', + }); + return parseJsonResponse(response); +} diff --git a/src/app/styles/styles.css b/src/app/styles/styles.css index fd4134a47..6c931f82a 100644 --- a/src/app/styles/styles.css +++ b/src/app/styles/styles.css @@ -7688,6 +7688,12 @@ input::-webkit-calendar-picker-indicator { display: flex; align-items: center; gap: 12px; + flex-wrap: wrap; +} + +.request-log-clear-btn:disabled { + opacity: 0.5; + cursor: not-allowed; } .request-log-auto-refresh { @@ -7793,7 +7799,7 @@ input::-webkit-calendar-picker-indicator { background: var(--bg-secondary); } -.request-log-row--keep-alive-zero td:nth-child(6) { +.request-log-row--keep-alive-zero td:nth-child(7) { color: #f59e0b; font-weight: 600; } @@ -7826,7 +7832,7 @@ input::-webkit-calendar-picker-indicator { .request-log-detail { border-top: 1px solid var(--border-tertiary); padding: 12px 14px; - max-height: 220px; + max-height: 420px; overflow: auto; flex-shrink: 0; background: var(--bg-secondary); @@ -7869,7 +7875,40 @@ input::-webkit-calendar-picker-indicator { font-size: 0.68rem; line-height: 1.4; overflow: auto; - max-height: 120px; + max-height: 200px; + white-space: pre-wrap; + word-break: break-word; + color: var(--text-secondary); +} + +.request-log-payload-section { + margin-top: 14px; +} + +.request-log-payload-title { + margin: 0 0 8px; + font-size: 0.72rem; + font-weight: 600; + color: var(--text-primary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.request-log-payload-empty { + margin: 0; + font-size: 0.72rem; + color: var(--text-quaternary); + font-style: italic; +} + +.request-log-redaction-hint { + margin: 12px 0 0; + padding: 10px 12px; + border-radius: 4px; + background: rgba(245, 158, 11, 0.08); + border: 1px solid rgba(245, 158, 11, 0.25); + font-size: 0.72rem; + line-height: 1.5; color: var(--text-secondary); } diff --git a/src/cpp/include/lemon/request_log_handlers.h b/src/cpp/include/lemon/request_log_handlers.h index de86e41db..6104e2348 100644 --- a/src/cpp/include/lemon/request_log_handlers.h +++ b/src/cpp/include/lemon/request_log_handlers.h @@ -18,4 +18,8 @@ void handle_request_log_stats(RequestLogService* service, const httplib::Request& req, httplib::Response& res); +void handle_request_log_clear(RequestLogService* service, + const httplib::Request& req, + httplib::Response& res); + } // namespace lemon diff --git a/src/cpp/include/lemon/request_log_parser.h b/src/cpp/include/lemon/request_log_parser.h index 8b72f3080..c04759cb1 100644 --- a/src/cpp/include/lemon/request_log_parser.h +++ b/src/cpp/include/lemon/request_log_parser.h @@ -16,6 +16,13 @@ struct ParsedRequestBody { bool has_redacted_body = false; }; +struct ParsedResponseBody { + std::optional prompt_tokens; + std::optional completion_tokens; + nlohmann::json redacted_response; + bool has_redacted_response = false; +}; + std::string classify_endpoint_type(const std::string& path, const std::string& method); std::string extract_forwarded_for(const std::string& x_forwarded_for, @@ -26,6 +33,11 @@ ParsedRequestBody parse_request_body(const std::string& body, const std::string& path, bool log_prompts); +ParsedResponseBody parse_response_body(const std::string& body, + const std::string& path, + int status_code, + bool log_prompts); + nlohmann::json redact_json(const nlohmann::json& value); std::string extract_response_error(const std::string& response_body, int status_code); diff --git a/src/cpp/include/lemon/request_log_service.h b/src/cpp/include/lemon/request_log_service.h index 7bc72e5d8..1414ab4e9 100644 --- a/src/cpp/include/lemon/request_log_service.h +++ b/src/cpp/include/lemon/request_log_service.h @@ -34,10 +34,15 @@ struct RequestLogEntry { int response_body_bytes = 0; int prompt_chars = 0; int messages_chars = 0; + std::optional prompt_tokens; + std::optional completion_tokens; std::string redacted_body_json; bool has_redacted_body = false; + std::string redacted_response_json; + bool has_redacted_response = false; std::string error; std::string request_body; + std::string response_body; }; class RequestLogService { @@ -67,6 +72,7 @@ class RequestLogService { nlohmann::json get_recent(int limit) const; nlohmann::json search(const httplib::Request& req) const; nlohmann::json get_stats(const httplib::Request& req) const; + nlohmann::json clear_all(); private: void writer_loop(); diff --git a/src/cpp/include/lemon/server.h b/src/cpp/include/lemon/server.h index 63580548d..6ad0fad76 100644 --- a/src/cpp/include/lemon/server.h +++ b/src/cpp/include/lemon/server.h @@ -129,6 +129,7 @@ class Server { void handle_request_log_recent(const httplib::Request& req, httplib::Response& res); void handle_request_log_search(const httplib::Request& req, httplib::Response& res); void handle_request_log_stats(const httplib::Request& req, httplib::Response& res); + void handle_request_log_clear(const httplib::Request& req, httplib::Response& res); void handle_shutdown(const httplib::Request& req, httplib::Response& res); void handle_simulate_vram_pressure(const httplib::Request& req, httplib::Response& res); diff --git a/src/cpp/server/ollama_api.cpp b/src/cpp/server/ollama_api.cpp index 7687caab4..c9336b51e 100644 --- a/src/cpp/server/ollama_api.cpp +++ b/src/cpp/server/ollama_api.cpp @@ -10,6 +10,17 @@ namespace lemon { +static std::string ollama_request_user_agent(const httplib::Request& req) { + return req.has_header("User-Agent") ? req.get_header_value("User-Agent") : "(none)"; +} + +static void log_ollama_model_not_found(const httplib::Request& req, const std::string& model) { + LOG(WARNING, "OllamaApi") + << "Model not found for " << req.method << " " << req.path << ": model=" << model + << " client=" << req.remote_addr << " user_agent=" << ollama_request_user_agent(req) + << std::endl; +} + // ============================================================================ // extract parameter size from model name // e.g. "Qwen3-0.6B-GGUF" → "0.6B", "Gemma-3-4b-it-GGUF" → "4B" @@ -704,6 +715,7 @@ void OllamaApi::handle_chat(const httplib::Request& req, httplib::Response& res) try { auto_load_model(model); } catch (const std::exception& e) { + log_ollama_model_not_found(req, model); res.status = 404; json error = {{"error", "model '" + model + "' not found, try pulling it first"}}; res.set_content(error.dump(), "application/json"); @@ -836,6 +848,7 @@ void OllamaApi::handle_generate(const httplib::Request& req, httplib::Response& try { auto_load_model(model); } catch (const std::exception& e) { + log_ollama_model_not_found(req, model); res.status = 404; json error = {{"error", "model '" + model + "' not found, try pulling it first"}}; res.set_content(error.dump(), "application/json"); @@ -1266,6 +1279,7 @@ void OllamaApi::handle_embed(const httplib::Request& req, httplib::Response& res try { auto_load_model(model); } catch (const std::exception& e) { + log_ollama_model_not_found(req, model); res.status = 404; json error = {{"error", "model '" + model + "' not found"}}; res.set_content(error.dump(), "application/json"); @@ -1333,6 +1347,7 @@ void OllamaApi::handle_embeddings(const httplib::Request& req, httplib::Response try { auto_load_model(model); } catch (const std::exception& e) { + log_ollama_model_not_found(req, model); res.status = 404; json error = {{"error", "model '" + model + "' not found"}}; res.set_content(error.dump(), "application/json"); diff --git a/src/cpp/server/request_log_handlers.cpp b/src/cpp/server/request_log_handlers.cpp index 5eb911d7f..46f2a0539 100644 --- a/src/cpp/server/request_log_handlers.cpp +++ b/src/cpp/server/request_log_handlers.cpp @@ -100,4 +100,26 @@ void handle_request_log_stats(RequestLogService* service, } } +void handle_request_log_clear(RequestLogService* service, + const httplib::Request& req, + httplib::Response& res) { + if (req.method == "HEAD") { + res.status = 200; + return; + } + + if (!service || !service->is_enabled()) { + set_service_unavailable(res, "Request logging is not enabled"); + return; + } + + try { + const auto payload = service->clear_all(); + res.set_content(payload.dump(), "application/json"); + } catch (const std::exception& e) { + LOG(ERROR, "RequestLog") << "handle_request_log_clear failed: " << e.what() << std::endl; + set_service_unavailable(res, e.what()); + } +} + } // namespace lemon diff --git a/src/cpp/server/request_log_parser.cpp b/src/cpp/server/request_log_parser.cpp index 1ff51829e..0cdcf240d 100644 --- a/src/cpp/server/request_log_parser.cpp +++ b/src/cpp/server/request_log_parser.cpp @@ -306,6 +306,130 @@ ParsedRequestBody parse_request_body(const std::string& body, return parsed; } +std::string sanitize_utf8_for_db(std::string value); + +std::optional json_int_field(const nlohmann::json& obj, const char* key) { + if (!obj.contains(key)) { + return std::nullopt; + } + const auto& value = obj[key]; + if (value.is_number_integer()) { + return value.get(); + } + if (value.is_number_unsigned()) { + return static_cast(value.get()); + } + return std::nullopt; +} + +std::string extract_response_text(const nlohmann::json& response_json) { + if (response_json.contains("choices") && response_json["choices"].is_array() && + !response_json["choices"].empty()) { + const auto& choice = response_json["choices"][0]; + if (choice.is_object()) { + if (choice.contains("message") && choice["message"].is_object() && + choice["message"].contains("content")) { + return json_value_to_string(choice["message"]["content"]); + } + if (choice.contains("text") && choice["text"].is_string()) { + return choice["text"].get(); + } + } + } + if (response_json.contains("message") && response_json["message"].is_object() && + response_json["message"].contains("content")) { + return json_value_to_string(response_json["message"]["content"]); + } + if (response_json.contains("response") && response_json["response"].is_string()) { + return response_json["response"].get(); + } + return {}; +} + +ParsedResponseBody parse_response_body(const std::string& body, + const std::string& path, + int status_code, + bool log_prompts) { + (void)path; + ParsedResponseBody parsed; + if (body.empty()) { + return parsed; + } + + nlohmann::json response_json; + try { + response_json = nlohmann::json::parse(body); + } catch (...) { + if (status_code >= 200 && status_code < 300) { + const std::string preview = + body.size() > 512 ? body.substr(0, 512) : body; + parsed.redacted_response = nlohmann::json{ + {"note", "non-JSON response"}, + {"preview", sanitize_utf8_for_db(preview)}, + }; + parsed.has_redacted_response = true; + } + return parsed; + } + + if (!response_json.is_object()) { + return parsed; + } + + if (response_json.contains("usage") && response_json["usage"].is_object()) { + const auto& usage = response_json["usage"]; + parsed.prompt_tokens = json_int_field(usage, "prompt_tokens"); + parsed.completion_tokens = json_int_field(usage, "completion_tokens"); + } + if (!parsed.prompt_tokens.has_value()) { + parsed.prompt_tokens = json_int_field(response_json, "prompt_eval_count"); + } + if (!parsed.completion_tokens.has_value()) { + parsed.completion_tokens = json_int_field(response_json, "eval_count"); + } + + nlohmann::json summary = nlohmann::json::object(); + if (parsed.prompt_tokens.has_value()) { + summary["prompt_tokens"] = parsed.prompt_tokens.value(); + } + if (parsed.completion_tokens.has_value()) { + summary["completion_tokens"] = parsed.completion_tokens.value(); + } + + const std::string content = extract_response_text(response_json); + if (!content.empty()) { + if (log_prompts) { + summary["content"] = content; + } else { + summary["content"] = nlohmann::json{{"char_count", static_cast(content.size())}}; + } + } + + if (log_prompts) { + summary["body"] = redact_json(response_json); + } else if (status_code >= 400) { + summary["body"] = redact_json(response_json); + } + + if (!summary.empty()) { + const std::string dumped = summary.dump(); + if (dumped.size() <= kMaxRedactedBodyBytes) { + parsed.redacted_response = std::move(summary); + parsed.has_redacted_response = true; + } else { + parsed.redacted_response = nlohmann::json{ + {"truncated", true}, + {"original_bytes", dumped.size()}, + {"prompt_tokens", parsed.prompt_tokens.value_or(0)}, + {"completion_tokens", parsed.completion_tokens.value_or(0)}, + }; + parsed.has_redacted_response = true; + } + } + + return parsed; +} + std::string sanitize_utf8_for_db(std::string value) { if (value.empty() || is_valid_utf8(value)) { return value; diff --git a/src/cpp/server/request_log_service.cpp b/src/cpp/server/request_log_service.cpp index 65393eed0..bf2b559cd 100644 --- a/src/cpp/server/request_log_service.cpp +++ b/src/cpp/server/request_log_service.cpp @@ -159,7 +159,10 @@ CREATE TABLE IF NOT EXISTS request_logs ( response_body_bytes INTEGER, prompt_chars INTEGER, messages_chars INTEGER, + prompt_tokens INTEGER, + completion_tokens INTEGER, redacted_body JSONB, + redacted_response JSONB, error TEXT ); CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON request_logs (created_at); @@ -167,6 +170,9 @@ CREATE INDEX IF NOT EXISTS idx_request_logs_model ON request_logs (model); CREATE INDEX IF NOT EXISTS idx_request_logs_client_ip ON request_logs (client_ip); CREATE INDEX IF NOT EXISTS idx_request_logs_path ON request_logs (path); CREATE INDEX IF NOT EXISTS idx_request_logs_keep_alive ON request_logs (keep_alive); +ALTER TABLE request_logs ADD COLUMN IF NOT EXISTS prompt_tokens INTEGER; +ALTER TABLE request_logs ADD COLUMN IF NOT EXISTS completion_tokens INTEGER; +ALTER TABLE request_logs ADD COLUMN IF NOT EXISTS redacted_response JSONB; )SQL"; nlohmann::json row_to_json_from_result(PGresult* result, int row) { @@ -194,10 +200,12 @@ nlohmann::json row_to_json_from_result(PGresult* result, int row) { {"response_body_bytes", value(15).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(15)))}, {"prompt_chars", value(16).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(16)))}, {"messages_chars", value(17).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(17)))}, - {"error", value(19).empty() ? nlohmann::json(nullptr) : nlohmann::json(value(19))}, + {"prompt_tokens", value(18).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(18)))}, + {"completion_tokens", value(19).empty() ? nlohmann::json(nullptr) : nlohmann::json(std::stoi(value(19)))}, + {"error", value(22).empty() ? nlohmann::json(nullptr) : nlohmann::json(value(22))}, }; - const char* redacted = PQgetvalue(result, row, 18); + const char* redacted = PQgetvalue(result, row, 20); if (redacted && *redacted) { try { entry["redacted_body"] = nlohmann::json::parse(redacted); @@ -207,6 +215,17 @@ nlohmann::json row_to_json_from_result(PGresult* result, int row) { } else { entry["redacted_body"] = nullptr; } + + const char* redacted_response = PQgetvalue(result, row, 21); + if (redacted_response && *redacted_response) { + try { + entry["redacted_response"] = nlohmann::json::parse(redacted_response); + } catch (...) { + entry["redacted_response"] = redacted_response; + } + } else { + entry["redacted_response"] = nullptr; + } return entry; } @@ -344,6 +363,7 @@ void RequestLogService::log_response(const httplib::Request& req, entry.response_body_bytes = static_cast(res.body.size()); entry.error = extract_response_error(res.body, res.status); entry.request_body = req.body; + entry.response_body = res.body; { std::lock_guard lock(queue_mutex_); @@ -446,24 +466,33 @@ bool RequestLogService::insert_entries(const std::vector& entri "INSERT INTO request_logs (client_ip, forwarded_for, method, path, query_string, " "status_code, duration_ms, user_agent, endpoint_type, model, keep_alive, stream, " "request_body_bytes, response_body_bytes, prompt_chars, messages_chars, " - "redacted_body, error) " - "VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)"; + "prompt_tokens, completion_tokens, redacted_body, redacted_response, error) " + "VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)"; bool ok = true; for (const auto& raw_entry : entries) { RequestLogEntry entry = raw_entry; ParsedRequestBody parsed = parse_request_body(entry.request_body, entry.path, log_prompts_); + ParsedResponseBody parsed_response = parse_response_body( + entry.response_body, entry.path, entry.status_code, log_prompts_); entry.model = parsed.model; entry.keep_alive = parsed.keep_alive; entry.stream = parsed.stream; entry.prompt_chars = parsed.prompt_chars; entry.messages_chars = parsed.messages_chars; + entry.prompt_tokens = parsed_response.prompt_tokens; + entry.completion_tokens = parsed_response.completion_tokens; if (parsed.has_redacted_body) { entry.redacted_body_json = sanitize_utf8_for_db(parsed.redacted_body.dump()); entry.has_redacted_body = true; } + if (parsed_response.has_redacted_response) { + entry.redacted_response_json = + sanitize_utf8_for_db(parsed_response.redacted_response.dump()); + entry.has_redacted_response = true; + } entry.client_ip = sanitize_utf8_for_db(std::move(entry.client_ip)); entry.forwarded_for = sanitize_utf8_for_db(std::move(entry.forwarded_for)); @@ -482,8 +511,16 @@ bool RequestLogService::insert_entries(const std::vector& entri const std::string response_body_bytes = std::to_string(entry.response_body_bytes); const std::string prompt_chars = std::to_string(entry.prompt_chars); const std::string messages_chars = std::to_string(entry.messages_chars); + std::string prompt_tokens; + std::string completion_tokens; + if (entry.prompt_tokens.has_value()) { + prompt_tokens = std::to_string(entry.prompt_tokens.value()); + } + if (entry.completion_tokens.has_value()) { + completion_tokens = std::to_string(entry.completion_tokens.value()); + } - const char* params[18] = { + const char* params[21] = { nullable_cstr(entry.client_ip), nullable_cstr(entry.forwarded_for), entry.method.c_str(), @@ -500,11 +537,14 @@ bool RequestLogService::insert_entries(const std::vector& entri response_body_bytes.c_str(), prompt_chars.c_str(), messages_chars.c_str(), + prompt_tokens.empty() ? nullptr : prompt_tokens.c_str(), + completion_tokens.empty() ? nullptr : completion_tokens.c_str(), entry.has_redacted_body ? entry.redacted_body_json.c_str() : nullptr, + entry.has_redacted_response ? entry.redacted_response_json.c_str() : nullptr, nullable_cstr(entry.error), }; - PGresult* result = PQexecParams(as_pg_conn(pg_conn_), insert_sql, 18, nullptr, params, + PGresult* result = PQexecParams(as_pg_conn(pg_conn_), insert_sql, 21, nullptr, params, nullptr, nullptr, 0); if (!result || PQresultStatus(result) != PGRES_COMMAND_OK) { LOG(WARNING, "RequestLog") @@ -578,7 +618,8 @@ nlohmann::json RequestLogService::get_recent(int limit) const { const std::string sql = "SELECT id, created_at, client_ip, forwarded_for, method, path, query_string, " "status_code, duration_ms, user_agent, endpoint_type, model, keep_alive, stream, " - "request_body_bytes, response_body_bytes, prompt_chars, messages_chars, redacted_body, error " + "request_body_bytes, response_body_bytes, prompt_chars, messages_chars, " + "prompt_tokens, completion_tokens, redacted_body, redacted_response, error " "FROM request_logs ORDER BY created_at DESC LIMIT $1"; const std::string limit_str = std::to_string(limit); const char* params[1] = {limit_str.c_str()}; @@ -618,7 +659,8 @@ nlohmann::json RequestLogService::search(const httplib::Request& req) const { std::ostringstream sql; sql << "SELECT id, created_at, client_ip, forwarded_for, method, path, query_string, " "status_code, duration_ms, user_agent, endpoint_type, model, keep_alive, stream, " - "request_body_bytes, response_body_bytes, prompt_chars, messages_chars, redacted_body, error " + "request_body_bytes, response_body_bytes, prompt_chars, messages_chars, " + "prompt_tokens, completion_tokens, redacted_body, redacted_response, error " "FROM request_logs WHERE 1=1"; std::vector params; @@ -737,6 +779,45 @@ nlohmann::json RequestLogService::get_stats(const httplib::Request& req) const { return response; } +nlohmann::json RequestLogService::clear_all() { + if (!ensure_connection()) { + throw std::runtime_error("Request log database is unavailable"); + } + + std::lock_guard lock(db_mutex_); + if (!pg_conn_ || PQstatus(as_pg_conn(pg_conn_)) != CONNECTION_OK) { + throw std::runtime_error("Request log database is unavailable"); + } + + int64_t deleted = 0; + PGresult* count_result = PQexec(as_pg_conn(pg_conn_), "SELECT COUNT(*)::bigint FROM request_logs"); + if (count_result && PQresultStatus(count_result) == PGRES_TUPLES_OK && PQntuples(count_result) > 0) { + const char* value = PQgetvalue(count_result, 0, 0); + if (value && *value) { + deleted = std::stoll(value); + } + } + if (count_result) { + PQclear(count_result); + } + + PGresult* delete_result = PQexec(as_pg_conn(pg_conn_), "DELETE FROM request_logs"); + const bool ok = delete_result && PQresultStatus(delete_result) == PGRES_COMMAND_OK; + if (!ok) { + if (delete_result) { + PQclear(delete_result); + } + database_available_.store(false); + throw std::runtime_error("Failed to clear request logs"); + } + if (delete_result) { + PQclear(delete_result); + } + + LOG(INFO, "RequestLog") << "Cleared " << deleted << " request log entries." << std::endl; + return {{"deleted", deleted}}; +} + #else bool RequestLogService::ensure_connection() { return false; } @@ -757,6 +838,10 @@ nlohmann::json RequestLogService::get_stats(const httplib::Request&) const { throw std::runtime_error("Request logging is not available in this build"); } +nlohmann::json RequestLogService::clear_all() { + throw std::runtime_error("Request logging is not available in this build"); +} + #endif void RequestLogService::writer_loop() { diff --git a/src/cpp/server/server.cpp b/src/cpp/server/server.cpp index 6046e0db2..bdd92cc94 100644 --- a/src/cpp/server/server.cpp +++ b/src/cpp/server/server.cpp @@ -646,6 +646,10 @@ void Server::setup_routes(httplib::Server &web_server) { handle_request_log_stats(req, res); }); + register_post("request-log/clear", [this](const httplib::Request& req, httplib::Response& res) { + handle_request_log_clear(req, res); + }); + // NOTE: /api/v1/halt endpoint removed - use SIGTERM signal instead (like Python server) // The stop command now sends termination signal directly to the process @@ -1073,7 +1077,17 @@ void Server::setup_cors(httplib::Server &web_server) { // Catch-all error handler - must be last! web_server.set_error_handler([](const httplib::Request& req, httplib::Response& res) { - LOG(ERROR, "Server") << "Error " << res.status << ": " << req.method << " " << req.path << std::endl; + // Handlers such as OllamaApi already log client context for expected 404s + // (unknown model, etc.). Avoid duplicate ERROR lines for those responses. + const bool handler_reported = + res.status == 404 && !res.body.empty() && + (res.body.find("not found") != std::string::npos || + res.body.find("try pulling it first") != std::string::npos); + + if (!handler_reported) { + LOG(ERROR, "Server") << "Error " << res.status << ": " << req.method << " " + << req.path << std::endl; + } if (res.status == 404) { // Only set generic "endpoint not found" if no content was already set @@ -4107,6 +4121,10 @@ void Server::handle_request_log_stats(const httplib::Request& req, httplib::Resp lemon::handle_request_log_stats(request_log_service_.get(), req, res); } +void Server::handle_request_log_clear(const httplib::Request& req, httplib::Response& res) { + lemon::handle_request_log_clear(request_log_service_.get(), req, res); +} + void Server::handle_stats(const httplib::Request& req, httplib::Response& res) { // For HEAD requests, just return 200 OK without processing if (req.method == "HEAD") { diff --git a/test/cpp/test_request_log_parser.cpp b/test/cpp/test_request_log_parser.cpp index f9e1de452..8d71e45ba 100644 --- a/test/cpp/test_request_log_parser.cpp +++ b/test/cpp/test_request_log_parser.cpp @@ -10,6 +10,7 @@ using lemon::ParsedRequestBody; using lemon::classify_endpoint_type; using lemon::extract_response_error; using lemon::parse_request_body; +using lemon::parse_response_body; using lemon::redact_json; using lemon::sanitize_utf8_for_db; @@ -109,12 +110,42 @@ static void test_binary_response_error(TestResult& result) { } } +static void test_response_tokens_and_content(TestResult& result) { + const std::string openai_response = R"({ + "choices": [{"message": {"role": "assistant", "content": "Hello there"}}], + "usage": {"prompt_tokens": 12, "completion_tokens": 3} + })"; + const auto parsed = lemon::parse_response_body(openai_response, "/v1/chat/completions", 200, true); + if (parsed.prompt_tokens.has_value() && parsed.prompt_tokens.value() == 12 && + parsed.completion_tokens.has_value() && parsed.completion_tokens.value() == 3 && + parsed.has_redacted_response) { + result.ok("openai response tokens extracted"); + } else { + result.fail("openai response tokens extracted"); + } + + const std::string ollama_response = R"({ + "message": {"role": "assistant", "content": "Hi"}, + "prompt_eval_count": 20, + "eval_count": 2 + })"; + const auto ollama_parsed = + lemon::parse_response_body(ollama_response, "/api/chat", 200, false); + if (ollama_parsed.prompt_tokens.has_value() && ollama_parsed.prompt_tokens.value() == 20 && + ollama_parsed.completion_tokens.has_value() && ollama_parsed.completion_tokens.value() == 2) { + result.ok("ollama response tokens extracted"); + } else { + result.fail("ollama response tokens extracted"); + } +} + int main() { TestResult result; test_endpoint_classification(result); test_redaction(result); test_char_counts_without_prompt_logging(result); test_binary_response_error(result); + test_response_tokens_and_content(result); printf("\nResults: %d passed, %d failed\n", result.passed, result.failed); return result.failed == 0 ? 0 : 1; diff --git a/test/server_endpoints.py b/test/server_endpoints.py index 1d08c357b..3beabc7aa 100644 --- a/test/server_endpoints.py +++ b/test/server_endpoints.py @@ -133,6 +133,7 @@ def test_000_endpoints_registered(self): "request-log/recent", "request-log/search", "request-log/stats", + "request-log/clear", ] session = requests.Session() diff --git a/test/server_request_log.py b/test/server_request_log.py index da3ccf6f8..5a86044d2 100644 --- a/test/server_request_log.py +++ b/test/server_request_log.py @@ -118,6 +118,31 @@ def test_003_request_log_stats(self): self.assertIn("by_endpoint_type", payload) self.assertIn("by_model", payload) + def test_004_request_log_clear(self): + """Clear endpoint removes all rows when DB logging is active.""" + if not os.environ.get("LEMONADE_REQUEST_LOG_DATABASE_URL"): + self.skipTest("LEMONADE_REQUEST_LOG_DATABASE_URL is not configured") + + clear_url = f"{self.base_url}/request-log/clear" + response = requests.post(clear_url, headers=_auth_headers(), timeout=TIMEOUT_DEFAULT) + if response.status_code == 503: + self.skipTest("Request logging is not enabled on the running server") + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertIn("deleted", payload) + self.assertIsInstance(payload["deleted"], int) + + search_url = f"{self.base_url}/request-log/search" + search = requests.get( + search_url, + params={"limit": 10}, + headers=_auth_headers(), + timeout=TIMEOUT_DEFAULT, + ) + self.assertEqual(search.status_code, 200) + self.assertEqual(search.json().get("entries", []), []) + if __name__ == "__main__": from utils.server_base import run_server_tests From 9166bd0b6446ce5369a3b081315637f0629a1ff3 Mon Sep 17 00:00:00 2001 From: Chuck Pearce Date: Wed, 17 Jun 2026 11:43:56 -0400 Subject: [PATCH 08/16] feat(logging): enhance UTF-8 sanitization and error handling in request logging This commit introduces a new function, `sanitize_json_utf8`, to recursively sanitize JSON objects for valid UTF-8 encoding, ensuring that all strings are properly formatted before logging. The existing `sanitize_utf8_for_db` function has been integrated into the request logging process, enhancing the robustness of error handling during serialization. Additionally, error handling has been improved in the `insert_entries` method of `RequestLogService`, with try-catch blocks added to manage serialization errors gracefully. Unit tests have been added to verify the functionality of the new sanitization features and ensure safe handling of invalid UTF-8 data. --- src/cpp/server/request_log_parser.cpp | 178 +++++++++++++++---------- src/cpp/server/request_log_service.cpp | 32 ++++- test/cpp/test_request_log_parser.cpp | 14 ++ 3 files changed, 150 insertions(+), 74 deletions(-) diff --git a/src/cpp/server/request_log_parser.cpp b/src/cpp/server/request_log_parser.cpp index 0cdcf240d..7cf40950f 100644 --- a/src/cpp/server/request_log_parser.cpp +++ b/src/cpp/server/request_log_parser.cpp @@ -150,6 +150,79 @@ bool looks_like_binary_payload(const std::string& value) { } // namespace +std::string sanitize_utf8_for_db(std::string value) { + if (value.empty() || is_valid_utf8(value)) { + return value; + } + + std::string sanitized; + sanitized.reserve(value.size()); + size_t i = 0; + while (i < value.size()) { + const unsigned char byte = static_cast(value[i]); + size_t seq_len = 1; + bool valid = true; + if (byte <= 0x7F) { + seq_len = 1; + } else if ((byte & 0xE0) == 0xC0) { + seq_len = 2; + } else if ((byte & 0xF0) == 0xE0) { + seq_len = 3; + } else if ((byte & 0xF8) == 0xF0) { + seq_len = 4; + } else { + valid = false; + } + + if (valid && i + seq_len <= value.size()) { + for (size_t j = 1; j < seq_len; ++j) { + const unsigned char continuation = + static_cast(value[i + j]); + if ((continuation & 0xC0) != 0x80) { + valid = false; + break; + } + } + } else { + valid = false; + } + + if (valid) { + sanitized.append(value, i, seq_len); + i += seq_len; + } else { + sanitized.append("\xEF\xBF\xBD", 3); + ++i; + } + } + return sanitized; +} + +namespace { + +nlohmann::json sanitize_json_utf8(const nlohmann::json& value) { + if (value.is_string()) { + return sanitize_utf8_for_db(value.get()); + } + if (value.is_object()) { + nlohmann::json out = nlohmann::json::object(); + for (auto it = value.begin(); it != value.end(); ++it) { + out[it.key()] = sanitize_json_utf8(it.value()); + } + return out; + } + if (value.is_array()) { + nlohmann::json out = nlohmann::json::array(); + for (const auto& item : value) { + out.push_back(sanitize_json_utf8(item)); + } + return out; + } + return value; +} + +} // namespace + nlohmann::json redact_json(const nlohmann::json& value) { if (value.is_object()) { nlohmann::json out = nlohmann::json::object(); @@ -169,6 +242,9 @@ nlohmann::json redact_json(const nlohmann::json& value) { } return out; } + if (value.is_string()) { + return sanitize_utf8_for_db(value.get()); + } return value; } @@ -275,7 +351,7 @@ ParsedRequestBody parse_request_body(const std::string& body, parsed.messages_chars = count_message_chars(request_json["messages"]); } - nlohmann::json redacted = redact_json(request_json); + nlohmann::json redacted = sanitize_json_utf8(redact_json(request_json)); if (!log_prompts) { if (redacted.contains("prompt") && redacted["prompt"].is_string()) { redacted["prompt"] = nlohmann::json{{"char_count", parsed.prompt_chars}}; @@ -291,14 +367,21 @@ ParsedRequestBody parse_request_body(const std::string& body, redacted["_meta"] = nlohmann::json{{"pinned", request_json["pinned"].get()}}; } - const std::string dumped = redacted.dump(); - if (dumped.size() <= kMaxRedactedBodyBytes) { - parsed.redacted_body = std::move(redacted); - parsed.has_redacted_body = true; - } else { + try { + const std::string dumped = redacted.dump(); + if (dumped.size() <= kMaxRedactedBodyBytes) { + parsed.redacted_body = std::move(redacted); + parsed.has_redacted_body = true; + } else { + parsed.redacted_body = nlohmann::json{ + {"truncated", true}, + {"original_bytes", dumped.size()}, + }; + parsed.has_redacted_body = true; + } + } catch (...) { parsed.redacted_body = nlohmann::json{ - {"truncated", true}, - {"original_bytes", dumped.size()}, + {"note", "request could not be serialized for logging"}, }; parsed.has_redacted_body = true; } @@ -306,8 +389,6 @@ ParsedRequestBody parse_request_body(const std::string& body, return parsed; } -std::string sanitize_utf8_for_db(std::string value); - std::optional json_int_field(const nlohmann::json& obj, const char* key) { if (!obj.contains(key)) { return std::nullopt; @@ -396,7 +477,7 @@ ParsedResponseBody parse_response_body(const std::string& body, summary["completion_tokens"] = parsed.completion_tokens.value(); } - const std::string content = extract_response_text(response_json); + const std::string content = sanitize_utf8_for_db(extract_response_text(response_json)); if (!content.empty()) { if (log_prompts) { summary["content"] = content; @@ -406,20 +487,29 @@ ParsedResponseBody parse_response_body(const std::string& body, } if (log_prompts) { - summary["body"] = redact_json(response_json); + summary["body"] = sanitize_json_utf8(redact_json(response_json)); } else if (status_code >= 400) { - summary["body"] = redact_json(response_json); + summary["body"] = sanitize_json_utf8(redact_json(response_json)); } if (!summary.empty()) { - const std::string dumped = summary.dump(); - if (dumped.size() <= kMaxRedactedBodyBytes) { - parsed.redacted_response = std::move(summary); - parsed.has_redacted_response = true; - } else { + try { + const std::string dumped = summary.dump(); + if (dumped.size() <= kMaxRedactedBodyBytes) { + parsed.redacted_response = std::move(summary); + parsed.has_redacted_response = true; + } else { + parsed.redacted_response = nlohmann::json{ + {"truncated", true}, + {"original_bytes", dumped.size()}, + {"prompt_tokens", parsed.prompt_tokens.value_or(0)}, + {"completion_tokens", parsed.completion_tokens.value_or(0)}, + }; + parsed.has_redacted_response = true; + } + } catch (...) { parsed.redacted_response = nlohmann::json{ - {"truncated", true}, - {"original_bytes", dumped.size()}, + {"note", "response could not be serialized for logging"}, {"prompt_tokens", parsed.prompt_tokens.value_or(0)}, {"completion_tokens", parsed.completion_tokens.value_or(0)}, }; @@ -430,54 +520,6 @@ ParsedResponseBody parse_response_body(const std::string& body, return parsed; } -std::string sanitize_utf8_for_db(std::string value) { - if (value.empty() || is_valid_utf8(value)) { - return value; - } - - std::string sanitized; - sanitized.reserve(value.size()); - size_t i = 0; - while (i < value.size()) { - const unsigned char byte = static_cast(value[i]); - size_t seq_len = 1; - bool valid = true; - if (byte <= 0x7F) { - seq_len = 1; - } else if ((byte & 0xE0) == 0xC0) { - seq_len = 2; - } else if ((byte & 0xF0) == 0xE0) { - seq_len = 3; - } else if ((byte & 0xF8) == 0xF0) { - seq_len = 4; - } else { - valid = false; - } - - if (valid && i + seq_len <= value.size()) { - for (size_t j = 1; j < seq_len; ++j) { - const unsigned char continuation = - static_cast(value[i + j]); - if ((continuation & 0xC0) != 0x80) { - valid = false; - break; - } - } - } else { - valid = false; - } - - if (valid) { - sanitized.append(value, i, seq_len); - i += seq_len; - } else { - sanitized.append("\xEF\xBF\xBD", 3); - ++i; - } - } - return sanitized; -} - std::string extract_response_error(const std::string& response_body, int status_code) { if (status_code >= 200 && status_code < 300) { return {}; diff --git a/src/cpp/server/request_log_service.cpp b/src/cpp/server/request_log_service.cpp index bf2b559cd..77baa22d8 100644 --- a/src/cpp/server/request_log_service.cpp +++ b/src/cpp/server/request_log_service.cpp @@ -471,6 +471,7 @@ bool RequestLogService::insert_entries(const std::vector& entri bool ok = true; for (const auto& raw_entry : entries) { + try { RequestLogEntry entry = raw_entry; ParsedRequestBody parsed = parse_request_body(entry.request_body, entry.path, log_prompts_); @@ -484,14 +485,25 @@ bool RequestLogService::insert_entries(const std::vector& entri entry.prompt_tokens = parsed_response.prompt_tokens; entry.completion_tokens = parsed_response.completion_tokens; if (parsed.has_redacted_body) { - entry.redacted_body_json = - sanitize_utf8_for_db(parsed.redacted_body.dump()); - entry.has_redacted_body = true; + try { + entry.redacted_body_json = + sanitize_utf8_for_db(parsed.redacted_body.dump()); + entry.has_redacted_body = true; + } catch (...) { + entry.redacted_body_json = R"({"note":"request could not be serialized for logging"})"; + entry.has_redacted_body = true; + } } if (parsed_response.has_redacted_response) { - entry.redacted_response_json = - sanitize_utf8_for_db(parsed_response.redacted_response.dump()); - entry.has_redacted_response = true; + try { + entry.redacted_response_json = + sanitize_utf8_for_db(parsed_response.redacted_response.dump()); + entry.has_redacted_response = true; + } catch (...) { + entry.redacted_response_json = + R"({"note":"response could not be serialized for logging"})"; + entry.has_redacted_response = true; + } } entry.client_ip = sanitize_utf8_for_db(std::move(entry.client_ip)); @@ -558,6 +570,14 @@ bool RequestLogService::insert_entries(const std::vector& entri break; } PQclear(result); + } catch (const std::exception& e) { + LOG(WARNING, "RequestLog") + << "Skipping request log row after serialization error: " << e.what() + << std::endl; + } catch (...) { + LOG(WARNING, "RequestLog") + << "Skipping request log row after serialization error." << std::endl; + } } PGresult* end = PQexec(as_pg_conn(pg_conn_), ok ? "COMMIT" : "ROLLBACK"); diff --git a/test/cpp/test_request_log_parser.cpp b/test/cpp/test_request_log_parser.cpp index 8d71e45ba..f296c5453 100644 --- a/test/cpp/test_request_log_parser.cpp +++ b/test/cpp/test_request_log_parser.cpp @@ -139,6 +139,19 @@ static void test_response_tokens_and_content(TestResult& result) { } } +static void test_invalid_utf8_json_dump(TestResult& result) { + nlohmann::json summary = nlohmann::json::object(); + std::string bad = "hi"; + bad.push_back(static_cast(0xF6)); + summary["content"] = sanitize_utf8_for_db(bad); + try { + summary.dump(); + result.ok("sanitized invalid utf8 dumps safely"); + } catch (...) { + result.fail("sanitized invalid utf8 dumps safely"); + } +} + int main() { TestResult result; test_endpoint_classification(result); @@ -146,6 +159,7 @@ int main() { test_char_counts_without_prompt_logging(result); test_binary_response_error(result); test_response_tokens_and_content(result); + test_invalid_utf8_json_dump(result); printf("\nResults: %d passed, %d failed\n", result.passed, result.failed); return result.failed == 0 ? 0 : 1; From fa478d8746ec719824f3d6d13c01c8a242fe38f3 Mon Sep 17 00:00:00 2001 From: Chuck Pearce Date: Wed, 17 Jun 2026 11:59:56 -0400 Subject: [PATCH 09/16] feat(logging): introduce safe JSON dumping and enhance request logging This commit adds a new function, `safe_json_dump`, to safely serialize JSON objects while handling potential errors gracefully. The function is integrated into various parts of the request logging process, replacing direct calls to `dump()` to ensure robust error handling. Additionally, the request log service has been updated to utilize this new function for logging redacted request and response bodies. Unit tests have been added to verify the functionality of the new dumping method and to ensure proper handling of invalid UTF-8 data and float token counts. --- src/cpp/include/lemon/request_log_parser.h | 2 + src/cpp/server/request_log_parser.cpp | 69 ++++++++++++++-------- src/cpp/server/request_log_service.cpp | 23 ++------ test/cpp/test_request_log_parser.cpp | 46 ++++++++++++++- 4 files changed, 96 insertions(+), 44 deletions(-) diff --git a/src/cpp/include/lemon/request_log_parser.h b/src/cpp/include/lemon/request_log_parser.h index c04759cb1..4efeacc1a 100644 --- a/src/cpp/include/lemon/request_log_parser.h +++ b/src/cpp/include/lemon/request_log_parser.h @@ -44,6 +44,8 @@ std::string extract_response_error(const std::string& response_body, int status_ std::string sanitize_utf8_for_db(std::string value); +std::string safe_json_dump(const nlohmann::json& value); + bool should_skip_request_log_path(const std::string& path, const std::string& method); } // namespace lemon diff --git a/src/cpp/server/request_log_parser.cpp b/src/cpp/server/request_log_parser.cpp index 7cf40950f..80735380d 100644 --- a/src/cpp/server/request_log_parser.cpp +++ b/src/cpp/server/request_log_parser.cpp @@ -102,10 +102,13 @@ std::string json_value_to_string(const nlohmann::json& value) { if (value.is_boolean()) { return value.get() ? "true" : "false"; } - return value.dump(); + return lemon::safe_json_dump(value); } +} // namespace + constexpr size_t kMaxRedactedBodyBytes = 32768; +constexpr size_t kMaxLoggedContentChars = 16384; bool is_valid_utf8(const std::string& value) { size_t i = 0; @@ -148,8 +151,6 @@ bool looks_like_binary_payload(const std::string& value) { return !is_valid_utf8(value); } -} // namespace - std::string sanitize_utf8_for_db(std::string value) { if (value.empty() || is_valid_utf8(value)) { return value; @@ -198,6 +199,14 @@ std::string sanitize_utf8_for_db(std::string value) { return sanitized; } +std::string safe_json_dump(const nlohmann::json& value) { + try { + return value.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace); + } catch (...) { + return "{}"; + } +} + namespace { nlohmann::json sanitize_json_utf8(const nlohmann::json& value) { @@ -368,7 +377,7 @@ ParsedRequestBody parse_request_body(const std::string& body, } try { - const std::string dumped = redacted.dump(); + const std::string dumped = safe_json_dump(redacted); if (dumped.size() <= kMaxRedactedBodyBytes) { parsed.redacted_body = std::move(redacted); parsed.has_redacted_body = true; @@ -400,6 +409,9 @@ std::optional json_int_field(const nlohmann::json& obj, const char* key) { if (value.is_number_unsigned()) { return static_cast(value.get()); } + if (value.is_number_float()) { + return static_cast(value.get()); + } return std::nullopt; } @@ -487,32 +499,41 @@ ParsedResponseBody parse_response_body(const std::string& body, } if (log_prompts) { - summary["body"] = sanitize_json_utf8(redact_json(response_json)); + if (content.empty()) { + summary["body"] = sanitize_json_utf8(redact_json(response_json)); + } } else if (status_code >= 400) { summary["body"] = sanitize_json_utf8(redact_json(response_json)); } if (!summary.empty()) { - try { - const std::string dumped = summary.dump(); - if (dumped.size() <= kMaxRedactedBodyBytes) { - parsed.redacted_response = std::move(summary); - parsed.has_redacted_response = true; - } else { - parsed.redacted_response = nlohmann::json{ - {"truncated", true}, - {"original_bytes", dumped.size()}, - {"prompt_tokens", parsed.prompt_tokens.value_or(0)}, - {"completion_tokens", parsed.completion_tokens.value_or(0)}, - }; - parsed.has_redacted_response = true; - } - } catch (...) { - parsed.redacted_response = nlohmann::json{ - {"note", "response could not be serialized for logging"}, - {"prompt_tokens", parsed.prompt_tokens.value_or(0)}, - {"completion_tokens", parsed.completion_tokens.value_or(0)}, + const std::string dumped = safe_json_dump(summary); + if (dumped.size() <= kMaxRedactedBodyBytes) { + parsed.redacted_response = std::move(summary); + parsed.has_redacted_response = true; + } else { + nlohmann::json truncated = nlohmann::json{ + {"truncated", true}, + {"original_bytes", dumped.size()}, }; + if (parsed.prompt_tokens.has_value()) { + truncated["prompt_tokens"] = parsed.prompt_tokens.value(); + } + if (parsed.completion_tokens.has_value()) { + truncated["completion_tokens"] = parsed.completion_tokens.value(); + } + if (!content.empty()) { + if (log_prompts) { + truncated["content"] = + content.size() > kMaxLoggedContentChars + ? content.substr(0, kMaxLoggedContentChars) + : content; + } else { + truncated["content"] = + nlohmann::json{{"char_count", static_cast(content.size())}}; + } + } + parsed.redacted_response = std::move(truncated); parsed.has_redacted_response = true; } } diff --git a/src/cpp/server/request_log_service.cpp b/src/cpp/server/request_log_service.cpp index 77baa22d8..75742787b 100644 --- a/src/cpp/server/request_log_service.cpp +++ b/src/cpp/server/request_log_service.cpp @@ -485,25 +485,14 @@ bool RequestLogService::insert_entries(const std::vector& entri entry.prompt_tokens = parsed_response.prompt_tokens; entry.completion_tokens = parsed_response.completion_tokens; if (parsed.has_redacted_body) { - try { - entry.redacted_body_json = - sanitize_utf8_for_db(parsed.redacted_body.dump()); - entry.has_redacted_body = true; - } catch (...) { - entry.redacted_body_json = R"({"note":"request could not be serialized for logging"})"; - entry.has_redacted_body = true; - } + entry.redacted_body_json = + sanitize_utf8_for_db(safe_json_dump(parsed.redacted_body)); + entry.has_redacted_body = true; } if (parsed_response.has_redacted_response) { - try { - entry.redacted_response_json = - sanitize_utf8_for_db(parsed_response.redacted_response.dump()); - entry.has_redacted_response = true; - } catch (...) { - entry.redacted_response_json = - R"({"note":"response could not be serialized for logging"})"; - entry.has_redacted_response = true; - } + entry.redacted_response_json = + sanitize_utf8_for_db(safe_json_dump(parsed_response.redacted_response)); + entry.has_redacted_response = true; } entry.client_ip = sanitize_utf8_for_db(std::move(entry.client_ip)); diff --git a/test/cpp/test_request_log_parser.cpp b/test/cpp/test_request_log_parser.cpp index f296c5453..d08c9aa1a 100644 --- a/test/cpp/test_request_log_parser.cpp +++ b/test/cpp/test_request_log_parser.cpp @@ -12,6 +12,7 @@ using lemon::extract_response_error; using lemon::parse_request_body; using lemon::parse_response_body; using lemon::redact_json; +using lemon::safe_json_dump; using lemon::sanitize_utf8_for_db; struct TestResult { @@ -144,14 +145,51 @@ static void test_invalid_utf8_json_dump(TestResult& result) { std::string bad = "hi"; bad.push_back(static_cast(0xF6)); summary["content"] = sanitize_utf8_for_db(bad); - try { - summary.dump(); + const std::string dumped = safe_json_dump(summary); + if (!dumped.empty() && dumped.find("content") != std::string::npos) { result.ok("sanitized invalid utf8 dumps safely"); - } catch (...) { + } else { result.fail("sanitized invalid utf8 dumps safely"); } } +static void test_invalid_utf8_response_content(TestResult& result) { + std::string content = "answer"; + content.push_back(static_cast(0xF6)); + const nlohmann::json response_json = { + {"message", nlohmann::json{{"role", "assistant"}, {"content", content}}}, + {"prompt_eval_count", 42}, + {"eval_count", 7}, + }; + const std::string response = + response_json.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace); + const auto parsed = parse_response_body(response, "/api/chat", 200, true); + if (parsed.prompt_tokens.has_value() && parsed.prompt_tokens.value() == 42 && + parsed.completion_tokens.has_value() && parsed.completion_tokens.value() == 7 && + parsed.has_redacted_response && + parsed.redacted_response.contains("content") && + !parsed.redacted_response.contains("note")) { + result.ok("invalid utf8 response content logged without serialization fallback"); + } else { + result.fail("invalid utf8 response content logged without serialization fallback"); + } +} + +static void test_float_token_counts(TestResult& result) { + const std::string response = R"({ + "message": {"role": "assistant", "content": "ok"}, + "prompt_eval_count": 100.0, + "eval_count": 5.0 + })"; + const auto parsed = parse_response_body(response, "/api/chat", 200, false); + if (parsed.prompt_tokens.has_value() && parsed.prompt_tokens.value() == 100 && + parsed.completion_tokens.has_value() && parsed.completion_tokens.value() == 5) { + result.ok("float token counts coerced to integers"); + } else { + result.fail("float token counts coerced to integers"); + } +} + int main() { TestResult result; test_endpoint_classification(result); @@ -160,6 +198,8 @@ int main() { test_binary_response_error(result); test_response_tokens_and_content(result); test_invalid_utf8_json_dump(result); + test_invalid_utf8_response_content(result); + test_float_token_counts(result); printf("\nResults: %d passed, %d failed\n", result.passed, result.failed); return result.failed == 0 ? 0 : 1; From 23292a835b54ba5bb8184ac6de49d1f831bc609c Mon Sep 17 00:00:00 2001 From: Chuck Pearce Date: Wed, 17 Jun 2026 12:15:31 -0400 Subject: [PATCH 10/16] feat(logging): enhance JSON handling for database storage This commit introduces the `safe_json_dump_for_db` function, which sanitizes JSON data for safe storage in the database by stripping null escapes and ensuring valid UTF-8 encoding. The `sanitize_utf8_for_db` function has been updated to utilize this new method, improving the handling of redacted request and response bodies in the `RequestLogService`. Additionally, unit tests have been added to verify the functionality of the new JSON dumping method and ensure proper handling of binary responses and null byte stripping. --- src/cpp/include/lemon/request_log_parser.h | 2 + src/cpp/server/request_log_parser.cpp | 66 +++++++++++++++++++--- src/cpp/server/request_log_service.cpp | 5 +- test/cpp/test_request_log_parser.cpp | 38 +++++++++++++ 4 files changed, 101 insertions(+), 10 deletions(-) diff --git a/src/cpp/include/lemon/request_log_parser.h b/src/cpp/include/lemon/request_log_parser.h index 4efeacc1a..eaac17112 100644 --- a/src/cpp/include/lemon/request_log_parser.h +++ b/src/cpp/include/lemon/request_log_parser.h @@ -46,6 +46,8 @@ std::string sanitize_utf8_for_db(std::string value); std::string safe_json_dump(const nlohmann::json& value); +std::string safe_json_dump_for_db(const nlohmann::json& value); + bool should_skip_request_log_path(const std::string& path, const std::string& method); } // namespace lemon diff --git a/src/cpp/server/request_log_parser.cpp b/src/cpp/server/request_log_parser.cpp index 80735380d..dd49c5d7d 100644 --- a/src/cpp/server/request_log_parser.cpp +++ b/src/cpp/server/request_log_parser.cpp @@ -151,7 +151,42 @@ bool looks_like_binary_payload(const std::string& value) { return !is_valid_utf8(value); } +bool is_postgres_text_byte(unsigned char byte) { + if (byte == 0) { + return false; + } + if (byte < 0x20 && byte != '\t' && byte != '\n' && byte != '\r') { + return false; + } + return true; +} + +std::string strip_postgres_text_bytes(std::string value) { + std::string stripped; + stripped.reserve(value.size()); + for (unsigned char byte : value) { + if (is_postgres_text_byte(byte)) { + stripped.push_back(static_cast(byte)); + } + } + return stripped; +} + +std::string strip_json_null_escapes(std::string json) { + std::string out; + out.reserve(json.size()); + for (size_t i = 0; i < json.size(); ++i) { + if (i + 6 <= json.size() && json.compare(i, 6, "\\u0000") == 0) { + i += 5; + continue; + } + out.push_back(json[i]); + } + return out; +} + std::string sanitize_utf8_for_db(std::string value) { + value = strip_postgres_text_bytes(std::move(value)); if (value.empty() || is_valid_utf8(value)) { return value; } @@ -196,7 +231,7 @@ std::string sanitize_utf8_for_db(std::string value) { ++i; } } - return sanitized; + return strip_postgres_text_bytes(std::move(sanitized)); } std::string safe_json_dump(const nlohmann::json& value) { @@ -207,6 +242,10 @@ std::string safe_json_dump(const nlohmann::json& value) { } } +std::string safe_json_dump_for_db(const nlohmann::json& value) { + return sanitize_utf8_for_db(strip_json_null_escapes(safe_json_dump(value))); +} + namespace { nlohmann::json sanitize_json_utf8(const nlohmann::json& value) { @@ -454,12 +493,25 @@ ParsedResponseBody parse_response_body(const std::string& body, response_json = nlohmann::json::parse(body); } catch (...) { if (status_code >= 200 && status_code < 300) { - const std::string preview = - body.size() > 512 ? body.substr(0, 512) : body; - parsed.redacted_response = nlohmann::json{ - {"note", "non-JSON response"}, - {"preview", sanitize_utf8_for_db(preview)}, - }; + if (looks_like_binary_payload(body)) { + nlohmann::json summary = nlohmann::json{ + {"note", "binary response"}, + {"bytes", static_cast(body.size())}, + }; + if (body.size() >= 2 && + static_cast(body[0]) == 0x1F && + static_cast(body[1]) == 0x8B) { + summary["encoding"] = "gzip"; + } + parsed.redacted_response = std::move(summary); + } else { + const std::string preview = + body.size() > 512 ? body.substr(0, 512) : body; + parsed.redacted_response = nlohmann::json{ + {"note", "non-JSON response"}, + {"preview", sanitize_utf8_for_db(preview)}, + }; + } parsed.has_redacted_response = true; } return parsed; diff --git a/src/cpp/server/request_log_service.cpp b/src/cpp/server/request_log_service.cpp index 75742787b..3b50c675b 100644 --- a/src/cpp/server/request_log_service.cpp +++ b/src/cpp/server/request_log_service.cpp @@ -485,13 +485,12 @@ bool RequestLogService::insert_entries(const std::vector& entri entry.prompt_tokens = parsed_response.prompt_tokens; entry.completion_tokens = parsed_response.completion_tokens; if (parsed.has_redacted_body) { - entry.redacted_body_json = - sanitize_utf8_for_db(safe_json_dump(parsed.redacted_body)); + entry.redacted_body_json = safe_json_dump_for_db(parsed.redacted_body); entry.has_redacted_body = true; } if (parsed_response.has_redacted_response) { entry.redacted_response_json = - sanitize_utf8_for_db(safe_json_dump(parsed_response.redacted_response)); + safe_json_dump_for_db(parsed_response.redacted_response); entry.has_redacted_response = true; } diff --git a/test/cpp/test_request_log_parser.cpp b/test/cpp/test_request_log_parser.cpp index d08c9aa1a..01fd840ec 100644 --- a/test/cpp/test_request_log_parser.cpp +++ b/test/cpp/test_request_log_parser.cpp @@ -13,6 +13,7 @@ using lemon::parse_request_body; using lemon::parse_response_body; using lemon::redact_json; using lemon::safe_json_dump; +using lemon::safe_json_dump_for_db; using lemon::sanitize_utf8_for_db; struct TestResult { @@ -190,6 +191,41 @@ static void test_float_token_counts(TestResult& result) { } } +static void test_binary_success_response(TestResult& result) { + const std::string gzip_like = + std::string{'\x1f', '\x8b', '\x08', '\x00', 'x', 'y', '\x00', 'z'}; + const auto parsed = parse_response_body(gzip_like, "/api/chat", 200, true); + if (parsed.has_redacted_response && + parsed.redacted_response.value("note", "") == "binary response" && + parsed.redacted_response.value("encoding", "") == "gzip" && + !parsed.redacted_response.contains("preview")) { + result.ok("binary 200 response omits garbage preview"); + } else { + result.fail("binary 200 response omits garbage preview"); + } +} + +static void test_null_byte_stripping(TestResult& result) { + std::string with_null = "hello"; + with_null.push_back('\0'); + with_null += "world"; + const std::string sanitized = sanitize_utf8_for_db(with_null); + if (sanitized.find('\0') == std::string::npos && sanitized == "helloworld") { + result.ok("null bytes stripped for postgres"); + } else { + result.fail("null bytes stripped for postgres"); + } + + const nlohmann::json payload{{"preview", with_null}}; + const std::string dumped = safe_json_dump_for_db(payload); + if (dumped.find("\\u0000") == std::string::npos && + dumped.find('\0') == std::string::npos) { + result.ok("json dump for db has no null escapes"); + } else { + result.fail("json dump for db has no null escapes"); + } +} + int main() { TestResult result; test_endpoint_classification(result); @@ -200,6 +236,8 @@ int main() { test_invalid_utf8_json_dump(result); test_invalid_utf8_response_content(result); test_float_token_counts(result); + test_binary_success_response(result); + test_null_byte_stripping(result); printf("\nResults: %d passed, %d failed\n", result.passed, result.failed); return result.failed == 0 ? 0 : 1; From 628d85128762cb33fce7cc8839478c3781d807b0 Mon Sep 17 00:00:00 2001 From: Chuck Pearce Date: Wed, 17 Jun 2026 12:24:46 -0400 Subject: [PATCH 11/16] feat(logging): enhance response body logging with decompression support This commit introduces functionality to handle compressed response bodies in the request logging process. It adds methods to check for various content encodings (gzip, deflate, br, zstd) and decompress the response body accordingly before logging. The `prepare_response_body_for_logging` function has been implemented to ensure that the logged response body is in its decompressed form, improving the clarity and usability of logged data. Additionally, the logging setup has been updated to capture uncompressed response bodies prior to compression, ensuring accurate logging of the original content. --- src/cpp/server/request_log_service.cpp | 81 +++++++++++++++++++++++++- src/cpp/server/server.cpp | 43 +++++++++++++- 2 files changed, 119 insertions(+), 5 deletions(-) diff --git a/src/cpp/server/request_log_service.cpp b/src/cpp/server/request_log_service.cpp index 3b50c675b..d2d3a8361 100644 --- a/src/cpp/server/request_log_service.cpp +++ b/src/cpp/server/request_log_service.cpp @@ -40,6 +40,82 @@ bool parse_bool_env(const char* value, bool default_value) { return default_value; } +bool encoding_contains(const std::string& encoding, const char* token) { + return encoding.find(token) != std::string::npos; +} + +bool is_gzip_payload(const std::string& body) { + return body.size() >= 2 && static_cast(body[0]) == 0x1F && + static_cast(body[1]) == 0x8B; +} + +std::string decompress_with(httplib::decompressor& decompressor, const std::string& body) { + if (!decompressor.is_valid() || body.empty()) { + return body; + } + std::string out; + const bool ok = decompressor.decompress( + body.data(), body.size(), [&](const char* data, size_t len) { + out.append(data, len); + return true; + }); + return ok && !out.empty() ? out : body; +} + +std::string prepare_response_body_for_logging(const httplib::Response& res) { + const std::string& body = res.body; + if (body.empty()) { + return body; + } + + std::string encoding; + if (res.has_header("Content-Encoding")) { + encoding = res.get_header_value("Content-Encoding"); + } + + if (!encoding.empty()) { +#ifdef CPPHTTPLIB_ZLIB_SUPPORT + if (encoding_contains(encoding, "gzip") || encoding_contains(encoding, "deflate")) { + httplib::gzip_decompressor decompressor; + const std::string decoded = decompress_with(decompressor, body); + if (decoded != body) { + return decoded; + } + } +#endif +#ifdef CPPHTTPLIB_BROTLI_SUPPORT + if (encoding_contains(encoding, "br")) { + httplib::brotli_decompressor decompressor; + const std::string decoded = decompress_with(decompressor, body); + if (decoded != body) { + return decoded; + } + } +#endif +#ifdef CPPHTTPLIB_ZSTD_SUPPORT + if (encoding_contains(encoding, "zstd")) { + httplib::zstd_decompressor decompressor; + const std::string decoded = decompress_with(decompressor, body); + if (decoded != body) { + return decoded; + } + } +#endif + } + + if (is_gzip_payload(body)) { +#ifdef CPPHTTPLIB_ZLIB_SUPPORT + httplib::gzip_decompressor decompressor; + const std::string decoded = decompress_with(decompressor, body); + if (decoded != body) { + return decoded; + } +#endif + } + + return body; +} + int parse_int_env(const char* value, int default_value) { if (!value || !*value) { return default_value; @@ -361,9 +437,10 @@ void RequestLogService::log_response(const httplib::Request& req, entry.endpoint_type = classify_endpoint_type(req.path, req.method); entry.request_body_bytes = static_cast(req.body.size()); entry.response_body_bytes = static_cast(res.body.size()); - entry.error = extract_response_error(res.body, res.status); + const std::string response_body = prepare_response_body_for_logging(res); + entry.error = extract_response_error(response_body, res.status); entry.request_body = req.body; - entry.response_body = res.body; + entry.response_body = response_body; { std::lock_guard lock(queue_mutex_); diff --git a/src/cpp/server/server.cpp b/src/cpp/server/server.cpp index bdd92cc94..db839ef49 100644 --- a/src/cpp/server/server.cpp +++ b/src/cpp/server/server.cpp @@ -15,6 +15,7 @@ #include "lemon/logging_config.h" #include "lemon/prometheus_metrics.h" #include "lemon/request_log_handlers.h" +#include "lemon/request_log_parser.h" #include "lemon/runtime_config.h" #include "lemon/system_info.h" #include "lemon/version.h" @@ -1161,8 +1162,35 @@ std::string Server::resolve_host_to_ip(int ai_family, const std::string& host) { } void Server::setup_http_logger(httplib::Server &web_server) { + auto should_emit_request_log = [](const httplib::Request& req) -> bool { + if (req.path == "/metrics") { + return false; + } + if (req.path == "/api/v0/health" || req.path == "/api/v1/health" || + req.path == "/v0/health" || req.path == "/v1/health" || req.path == "/live" || + is_quiet_polling_path(req.path)) { + return false; + } + return !should_skip_request_log_path(req.path, req.method); + }; + + auto emit_request_log = [this, should_emit_request_log](const httplib::Request& req, + const httplib::Response& res) { + if (!request_log_service_ || !should_emit_request_log(req)) { + return; + } + request_log_service_->log_response(req, res); + }; + + // Capture uncompressed response bodies before httplib applies gzip/brotli/zstd. + web_server.set_pre_compression_logger( + [emit_request_log](const httplib::Request& req, const httplib::Response& res) { + emit_request_log(req, res); + }); + // Add request logging for ALL requests (except health checks and stats endpoints) - web_server.set_logger([this](const httplib::Request& req, const httplib::Response& res) { + web_server.set_logger([this, should_emit_request_log, emit_request_log]( + const httplib::Request& req, const httplib::Response& res) { if (req.path == "/metrics") { if (res.status == 200) { bool expected = false; @@ -1203,8 +1231,17 @@ void Server::setup_http_logger(httplib::Server &web_server) { LOG(DEBUG, "Server") << req.method << " " << req.path << " - " << res.status << std::endl; } - if (request_log_service_) { - request_log_service_->log_response(req, res); + if (request_log_service_ && should_emit_request_log(req)) { + if (res.has_header("Content-Encoding")) { + const std::string& encoding = res.get_header_value("Content-Encoding"); + if (encoding.find("gzip") != std::string::npos || + encoding.find("deflate") != std::string::npos || + encoding.find("br") != std::string::npos || + encoding.find("zstd") != std::string::npos) { + return; + } + } + emit_request_log(req, res); } }); } From 4acb22c43ea2ba606ddb2224318091f68102fbe3 Mon Sep 17 00:00:00 2001 From: Chuck Pearce Date: Wed, 17 Jun 2026 12:26:13 -0400 Subject: [PATCH 12/16] refactor(logging): use httplib_detail namespace for decompressor types This commit updates the `request_log_service.cpp` file to utilize the `httplib_detail` namespace for decompressor types, enhancing code clarity and consistency. The changes include modifying the declarations of various decompressor instances to ensure they reference the correct namespace, which improves maintainability and aligns with recent refactoring efforts in the logging functionality. --- src/cpp/server/request_log_service.cpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/cpp/server/request_log_service.cpp b/src/cpp/server/request_log_service.cpp index d2d3a8361..c71599f38 100644 --- a/src/cpp/server/request_log_service.cpp +++ b/src/cpp/server/request_log_service.cpp @@ -18,6 +18,8 @@ namespace lemon { namespace { +namespace httplib_detail = httplib::detail; + thread_local std::chrono::steady_clock::time_point g_request_log_start; thread_local bool g_request_log_start_valid = false; @@ -49,7 +51,8 @@ bool is_gzip_payload(const std::string& body) { static_cast(body[1]) == 0x8B; } -std::string decompress_with(httplib::decompressor& decompressor, const std::string& body) { +std::string decompress_with(httplib_detail::decompressor& decompressor, + const std::string& body) { if (!decompressor.is_valid() || body.empty()) { return body; } @@ -76,7 +79,7 @@ std::string prepare_response_body_for_logging(const httplib::Response& res) { if (!encoding.empty()) { #ifdef CPPHTTPLIB_ZLIB_SUPPORT if (encoding_contains(encoding, "gzip") || encoding_contains(encoding, "deflate")) { - httplib::gzip_decompressor decompressor; + httplib_detail::gzip_decompressor decompressor; const std::string decoded = decompress_with(decompressor, body); if (decoded != body) { return decoded; @@ -85,7 +88,7 @@ std::string prepare_response_body_for_logging(const httplib::Response& res) { #endif #ifdef CPPHTTPLIB_BROTLI_SUPPORT if (encoding_contains(encoding, "br")) { - httplib::brotli_decompressor decompressor; + httplib_detail::brotli_decompressor decompressor; const std::string decoded = decompress_with(decompressor, body); if (decoded != body) { return decoded; @@ -94,7 +97,7 @@ std::string prepare_response_body_for_logging(const httplib::Response& res) { #endif #ifdef CPPHTTPLIB_ZSTD_SUPPORT if (encoding_contains(encoding, "zstd")) { - httplib::zstd_decompressor decompressor; + httplib_detail::zstd_decompressor decompressor; const std::string decoded = decompress_with(decompressor, body); if (decoded != body) { return decoded; @@ -105,7 +108,7 @@ std::string prepare_response_body_for_logging(const httplib::Response& res) { if (is_gzip_payload(body)) { #ifdef CPPHTTPLIB_ZLIB_SUPPORT - httplib::gzip_decompressor decompressor; + httplib_detail::gzip_decompressor decompressor; const std::string decoded = decompress_with(decompressor, body); if (decoded != body) { return decoded; From 93f5f3b7c135bb3d995afe59a16a65bca5fdd7e2 Mon Sep 17 00:00:00 2001 From: Chuck Pearce Date: Wed, 17 Jun 2026 12:53:02 -0400 Subject: [PATCH 13/16] refactor(logging): remove request logs functionality from UI components This commit removes the `handleOpenRequestLogs` function and its associated references from the `App`, `ModelManager`, `TitleBar`, and related components. Additionally, the label "Server Logs" has been updated to "System Logs" in the `CenterPanelTabs` and `LogsWindow` components for consistency. These changes streamline the logging interface and improve clarity in the application. --- src/app/src/renderer/App.tsx | 8 -------- src/app/src/renderer/CenterPanelTabs.tsx | 2 +- src/app/src/renderer/LogsWindow.tsx | 2 +- src/app/src/renderer/ModelManager.tsx | 14 +------------- src/app/src/renderer/TitleBar.tsx | 5 ----- src/app/styles/styles.css | 2 +- 6 files changed, 4 insertions(+), 29 deletions(-) diff --git a/src/app/src/renderer/App.tsx b/src/app/src/renderer/App.tsx index 61794285c..e69f5c9d1 100644 --- a/src/app/src/renderer/App.tsx +++ b/src/app/src/renderer/App.tsx @@ -149,11 +149,6 @@ const AppContent: React.FC = () => { } }, [layoutLoaded, theme, isChatVisible, isModelManagerVisible, leftPanelView, isLogsVisible, centerPanelTab, modelManagerWidth, chatWidth, logsHeight]); - const handleOpenRequestLogs = useCallback(() => { - setIsLogsVisible(true); - setCenterPanelTab('request-logs'); - }, []); - // Debounced save effect useEffect(() => { if (!layoutLoaded) return; @@ -525,7 +520,6 @@ const AppContent: React.FC = () => { onToggleModelManager={() => setIsModelManagerVisible(!isModelManagerVisible)} isLogsVisible={isLogsVisible} onToggleLogs={() => setIsLogsVisible(!isLogsVisible)} - onOpenRequestLogs={handleOpenRequestLogs} isDownloadManagerVisible={isDownloadManagerVisible} onToggleDownloadManager={handleToggleDownloadManager} /> @@ -540,8 +534,6 @@ const AppContent: React.FC = () => { width={isModelManagerVisible ? modelManagerWidth : LAYOUT_CONSTANTS.experienceRailWidth} currentView={leftPanelView} onViewChange={setLeftPanelView} - onOpenRequestLogs={handleOpenRequestLogs} - isRequestLogsActive={isLogsVisible && centerPanelTab === 'request-logs'} /> {isModelManagerVisible && (isLogsVisible || isChatVisible) && ( diff --git a/src/app/src/renderer/CenterPanelTabs.tsx b/src/app/src/renderer/CenterPanelTabs.tsx index 23507d82e..3d077c6f1 100644 --- a/src/app/src/renderer/CenterPanelTabs.tsx +++ b/src/app/src/renderer/CenterPanelTabs.tsx @@ -15,7 +15,7 @@ const CenterPanelTabs: React.FC = ({ activeTab, onTabChang className={`center-panel-tab ${activeTab === 'server-logs' ? 'active' : ''}`} onClick={() => onTabChange('server-logs')} > - Server Logs + System Logs -
-
{ onOpenRequestLogs?.(); setActiveMenu(null); }}> - Request Logs -
Theme diff --git a/src/app/styles/styles.css b/src/app/styles/styles.css index 6c931f82a..6da1230a7 100644 --- a/src/app/styles/styles.css +++ b/src/app/styles/styles.css @@ -7622,7 +7622,7 @@ input::-webkit-calendar-picker-indicator { margin-top: 4px; } -/* Center panel tabs (Server Logs / Request Logs) */ +/* Center panel tabs (System Logs / Request Logs) */ .center-panel-tabs { display: flex; gap: 0; From 951468ab0460fe68fa7bff28dcfab682524cba8b Mon Sep 17 00:00:00 2001 From: Chuck Pearce Date: Wed, 17 Jun 2026 15:03:19 -0400 Subject: [PATCH 14/16] feat(logging): enhance JSON normalization and response body logging This commit introduces new functions to improve the handling of JSON data in the request logging process. The `normalizeJsonForDisplay` function recursively expands JSON-encoded strings for better readability, while `tryParseJsonString` safely attempts to parse JSON strings. Additionally, the `prepare_response_body_for_logging` function has been updated to incorporate decompression for various content encodings, ensuring that logged response bodies are accurately represented. These enhancements improve the clarity and usability of logged data, particularly for complex JSON structures. --- src/app/src/renderer/RequestLogPanel.tsx | 49 ++++++++++++++- src/cpp/server/request_log_service.cpp | 80 +++++++++++++++++++++--- src/cpp/server/server.cpp | 18 +----- 3 files changed, 121 insertions(+), 26 deletions(-) diff --git a/src/app/src/renderer/RequestLogPanel.tsx b/src/app/src/renderer/RequestLogPanel.tsx index 4b2d919fd..11459bb6c 100644 --- a/src/app/src/renderer/RequestLogPanel.tsx +++ b/src/app/src/renderer/RequestLogPanel.tsx @@ -77,14 +77,57 @@ function formatTokens(entry: RequestLogEntry): string { return `${input} / ${output}`; } +function looksLikeJsonString(value: string): boolean { + const trimmed = value.trim(); + return trimmed.startsWith('{') || trimmed.startsWith('['); +} + +function tryParseJsonString(value: string): unknown { + if (!looksLikeJsonString(value)) { + return value; + } + try { + return JSON.parse(value); + } catch { + return value; + } +} + +/** Recursively expand JSON-encoded strings so nested payloads pretty-print. */ +function normalizeJsonForDisplay(value: unknown): unknown { + if (typeof value === 'string') { + let parsed: unknown = tryParseJsonString(value); + // Handle double-encoded JSON (JSONB stored as a quoted JSON string). + if (typeof parsed === 'string' && looksLikeJsonString(parsed)) { + parsed = tryParseJsonString(parsed); + } + if (parsed !== value) { + return normalizeJsonForDisplay(parsed); + } + return value; + } + if (Array.isArray(value)) { + return value.map((item) => normalizeJsonForDisplay(item)); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, normalizeJsonForDisplay(item)]), + ); + } + return value; +} + function formatJsonBlock(value: unknown): string { if (value === null || value === undefined) { return '—'; } - if (typeof value === 'string') { - return value; + + const normalized = normalizeJsonForDisplay(value); + if (typeof normalized === 'string') { + return normalized; } - return JSON.stringify(value, null, 2); + + return JSON.stringify(normalized, null, 2); } function payloadUsesCharCountsOnly(value: unknown): boolean { diff --git a/src/cpp/server/request_log_service.cpp b/src/cpp/server/request_log_service.cpp index c71599f38..8b12301e3 100644 --- a/src/cpp/server/request_log_service.cpp +++ b/src/cpp/server/request_log_service.cpp @@ -15,6 +15,10 @@ #include #endif +#ifdef CPPHTTPLIB_ZLIB_SUPPORT +#include +#endif + namespace lemon { namespace { @@ -43,7 +47,16 @@ bool parse_bool_env(const char* value, bool default_value) { } bool encoding_contains(const std::string& encoding, const char* token) { - return encoding.find(token) != std::string::npos; + if (encoding.empty() || !token || !*token) { + return false; + } + auto lower = [](std::string value) { + for (char& ch : value) { + ch = static_cast(std::tolower(static_cast(ch))); + } + return value; + }; + return lower(encoding).find(lower(token)) != std::string::npos; } bool is_gzip_payload(const std::string& body) { @@ -62,18 +75,49 @@ std::string decompress_with(httplib_detail::decompressor& decompressor, out.append(data, len); return true; }); - return ok && !out.empty() ? out : body; + return ok ? out : body; } -std::string prepare_response_body_for_logging(const httplib::Response& res) { - const std::string& body = res.body; +#ifdef CPPHTTPLIB_ZLIB_SUPPORT +std::string decompress_gzip_zlib(const std::string& body) { if (body.empty()) { return body; } - std::string encoding; - if (res.has_header("Content-Encoding")) { - encoding = res.get_header_value("Content-Encoding"); + z_stream strm{}; + strm.zalloc = Z_NULL; + strm.zfree = Z_NULL; + strm.opaque = Z_NULL; + if (inflateInit2(&strm, 32 + 15) != Z_OK) { + return body; + } + + std::string out; + strm.avail_in = static_cast(body.size()); + strm.next_in = reinterpret_cast(const_cast(body.data())); + + int ret = Z_OK; + do { + char buffer[16384]; + strm.avail_out = sizeof(buffer); + strm.next_out = reinterpret_cast(buffer); + ret = inflate(&strm, Z_NO_FLUSH); + if (ret == Z_STREAM_ERROR || ret == Z_NEED_DICT || ret == Z_DATA_ERROR || + ret == Z_MEM_ERROR) { + inflateEnd(&strm); + return body; + } + out.append(buffer, sizeof(buffer) - strm.avail_out); + } while (ret != Z_STREAM_END); + + inflateEnd(&strm); + return out.empty() ? body : out; +} +#endif + +std::string try_decompress_body(const std::string& body, const std::string& encoding) { + if (body.empty()) { + return body; } if (!encoding.empty()) { @@ -84,6 +128,10 @@ std::string prepare_response_body_for_logging(const httplib::Response& res) { if (decoded != body) { return decoded; } + const std::string zlib_decoded = decompress_gzip_zlib(body); + if (zlib_decoded != body) { + return zlib_decoded; + } } #endif #ifdef CPPHTTPLIB_BROTLI_SUPPORT @@ -113,12 +161,30 @@ std::string prepare_response_body_for_logging(const httplib::Response& res) { if (decoded != body) { return decoded; } + const std::string zlib_decoded = decompress_gzip_zlib(body); + if (zlib_decoded != body) { + return zlib_decoded; + } #endif } return body; } +std::string prepare_response_body_for_logging(const httplib::Response& res) { + const std::string& body = res.body; + if (body.empty()) { + return body; + } + + std::string encoding; + if (res.has_header("Content-Encoding")) { + encoding = res.get_header_value("Content-Encoding"); + } + + return try_decompress_body(body, encoding); +} + int parse_int_env(const char* value, int default_value) { if (!value || !*value) { return default_value; diff --git a/src/cpp/server/server.cpp b/src/cpp/server/server.cpp index db839ef49..e47493a30 100644 --- a/src/cpp/server/server.cpp +++ b/src/cpp/server/server.cpp @@ -1182,13 +1182,8 @@ void Server::setup_http_logger(httplib::Server &web_server) { request_log_service_->log_response(req, res); }; - // Capture uncompressed response bodies before httplib applies gzip/brotli/zstd. - web_server.set_pre_compression_logger( - [emit_request_log](const httplib::Request& req, const httplib::Response& res) { - emit_request_log(req, res); - }); - - // Add request logging for ALL requests (except health checks and stats endpoints) + // Log after the response is finalized (including optional Content-Encoding + // compression). prepare_response_body_for_logging decompresses before parsing. web_server.set_logger([this, should_emit_request_log, emit_request_log]( const httplib::Request& req, const httplib::Response& res) { if (req.path == "/metrics") { @@ -1232,15 +1227,6 @@ void Server::setup_http_logger(httplib::Server &web_server) { } if (request_log_service_ && should_emit_request_log(req)) { - if (res.has_header("Content-Encoding")) { - const std::string& encoding = res.get_header_value("Content-Encoding"); - if (encoding.find("gzip") != std::string::npos || - encoding.find("deflate") != std::string::npos || - encoding.find("br") != std::string::npos || - encoding.find("zstd") != std::string::npos) { - return; - } - } emit_request_log(req, res); } }); From 07d2c722f936d00f351c967f938108277b400bdb Mon Sep 17 00:00:00 2001 From: Chuck Pearce Date: Wed, 17 Jun 2026 15:40:20 -0400 Subject: [PATCH 15/16] feat(logging): refactor request log payload display and enhance styling This commit refactors the request log payload display by replacing the JSON formatting logic with a new `RequestLogPayloadView` component for improved readability. Additionally, new CSS styles are introduced to enhance the layout and presentation of request and response payloads, including metadata handling and text formatting. These changes aim to provide a clearer and more user-friendly interface for viewing logged data. --- src/app/src/renderer/RequestLogPanel.tsx | 76 +------ .../src/renderer/RequestLogPayloadView.tsx | 209 ++++++++++++++++++ .../src/renderer/utils/requestLogFormat.ts | 68 ++++++ src/app/styles/styles.css | 38 ++++ 4 files changed, 319 insertions(+), 72 deletions(-) create mode 100644 src/app/src/renderer/RequestLogPayloadView.tsx create mode 100644 src/app/src/renderer/utils/requestLogFormat.ts diff --git a/src/app/src/renderer/RequestLogPanel.tsx b/src/app/src/renderer/RequestLogPanel.tsx index 11459bb6c..6937017fa 100644 --- a/src/app/src/renderer/RequestLogPanel.tsx +++ b/src/app/src/renderer/RequestLogPanel.tsx @@ -8,6 +8,8 @@ import { RequestLogStats, } from './utils/requestLogApi'; import { writeClipboard } from './utils/clipboardUtils'; +import { payloadUsesCharCountsOnly } from './utils/requestLogFormat'; +import RequestLogPayloadView from './RequestLogPayloadView'; type KeepAliveFilter = 'any' | 'zero' | 'has'; type SinceFilter = '1h' | '24h' | '7d'; @@ -77,76 +79,6 @@ function formatTokens(entry: RequestLogEntry): string { return `${input} / ${output}`; } -function looksLikeJsonString(value: string): boolean { - const trimmed = value.trim(); - return trimmed.startsWith('{') || trimmed.startsWith('['); -} - -function tryParseJsonString(value: string): unknown { - if (!looksLikeJsonString(value)) { - return value; - } - try { - return JSON.parse(value); - } catch { - return value; - } -} - -/** Recursively expand JSON-encoded strings so nested payloads pretty-print. */ -function normalizeJsonForDisplay(value: unknown): unknown { - if (typeof value === 'string') { - let parsed: unknown = tryParseJsonString(value); - // Handle double-encoded JSON (JSONB stored as a quoted JSON string). - if (typeof parsed === 'string' && looksLikeJsonString(parsed)) { - parsed = tryParseJsonString(parsed); - } - if (parsed !== value) { - return normalizeJsonForDisplay(parsed); - } - return value; - } - if (Array.isArray(value)) { - return value.map((item) => normalizeJsonForDisplay(item)); - } - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value).map(([key, item]) => [key, normalizeJsonForDisplay(item)]), - ); - } - return value; -} - -function formatJsonBlock(value: unknown): string { - if (value === null || value === undefined) { - return '—'; - } - - const normalized = normalizeJsonForDisplay(value); - if (typeof normalized === 'string') { - return normalized; - } - - return JSON.stringify(normalized, null, 2); -} - -function payloadUsesCharCountsOnly(value: unknown): boolean { - if (!value || typeof value !== 'object') { - return false; - } - const record = value as Record; - if (record.prompt && typeof record.prompt === 'object' && record.prompt !== null) { - return 'char_count' in (record.prompt as Record); - } - if (record.messages && typeof record.messages === 'object' && record.messages !== null) { - return 'char_count' in (record.messages as Record); - } - if (record.content && typeof record.content === 'object' && record.content !== null) { - return 'char_count' in (record.content as Record); - } - return false; -} - function applyKeepAliveClientFilter( entries: RequestLogEntry[], keepAlive: KeepAliveFilter, @@ -588,7 +520,7 @@ export LEMONADE_REQUEST_LOG_DATABASE_URL=postgresql://lemonade:change-me@127.0.0

Request payload (redacted)

{requestRedacted !== null && requestRedacted !== undefined ? ( -
{formatJsonBlock(requestRedacted)}
+ ) : (

No request body recorded

)} @@ -597,7 +529,7 @@ export LEMONADE_REQUEST_LOG_DATABASE_URL=postgresql://lemonade:change-me@127.0.0

API response (redacted)

{responseRedacted !== null && responseRedacted !== undefined ? ( -
{formatJsonBlock(responseRedacted)}
+ ) : (

No response body recorded (common for streaming requests or empty errors) diff --git a/src/app/src/renderer/RequestLogPayloadView.tsx b/src/app/src/renderer/RequestLogPayloadView.tsx new file mode 100644 index 000000000..86d2136ab --- /dev/null +++ b/src/app/src/renderer/RequestLogPayloadView.tsx @@ -0,0 +1,209 @@ +import React from 'react'; +import { formatJsonBlock, normalizeJsonForDisplay } from './utils/requestLogFormat'; + +export interface TextSection { + key: string; + label: string; + text: string; +} + +const TEXT_FIELD_NAMES = new Set([ + 'prompt', + 'content', + 'text', + 'input', + 'system', + 'message', +]); + +const LONG_TEXT_MIN_LENGTH = 120; + +function isRedactionSummary(value: unknown): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const record = value as Record; + return 'char_count' in record && Object.keys(record).length <= 2; +} + +function isTextFieldName(key: string): boolean { + return TEXT_FIELD_NAMES.has(key.toLowerCase()); +} + +function shouldExtractAsText(key: string, value: string): boolean { + if (isTextFieldName(key)) { + return true; + } + return value.length > LONG_TEXT_MIN_LENGTH || value.includes('\n'); +} + +function placeholderForText(text: string): string { + return `[shown below — ${text.length} chars]`; +} + +function extractTextSections(value: unknown, path = ''): TextSection[] { + const sections: TextSection[] = []; + + if (typeof value === 'string') { + if (path && shouldExtractAsText(path.split('.').pop() ?? '', value)) { + sections.push({ + key: path, + label: path, + text: value, + }); + } + return sections; + } + + if (Array.isArray(value)) { + value.forEach((item, index) => { + const childPath = path ? `${path}[${index}]` : `[${index}]`; + if (item && typeof item === 'object' && !Array.isArray(item)) { + const record = item as Record; + const role = + typeof record.role === 'string' ? record.role : `item ${index}`; + if (record.content !== undefined) { + if (typeof record.content === 'string' && !isRedactionSummary(record.content)) { + if (shouldExtractAsText('content', record.content)) { + sections.push({ + key: `${childPath}.content`, + label: path ? `${path}[${index}] (${role})` : `messages[${index}] (${role})`, + text: record.content, + }); + } + } else if (Array.isArray(record.content)) { + sections.push(...extractTextSections(record.content, `${childPath}.content`)); + } + } + for (const [key, child] of Object.entries(record)) { + if (key === 'content') continue; + sections.push(...extractTextSections(child, `${childPath}.${key}`)); + } + } else { + sections.push(...extractTextSections(item, childPath)); + } + }); + return sections; + } + + if (value && typeof value === 'object') { + const record = value as Record; + + if (record.messages && Array.isArray(record.messages)) { + sections.push(...extractTextSections(record.messages, path ? `${path}.messages` : 'messages')); + } else if (record.messages && isRedactionSummary(record.messages)) { + // Redacted summary — do not expand. + } + + for (const [key, child] of Object.entries(record)) { + if (key === 'messages') continue; + + const childPath = path ? `${path}.${key}` : key; + + if (typeof child === 'string' && !isRedactionSummary(child)) { + if (shouldExtractAsText(key, child)) { + sections.push({ + key: childPath, + label: childPath, + text: child, + }); + } + } else if (key === 'body' && child && typeof child === 'object') { + // Keep small response bodies inline in metadata JSON. + sections.push(...extractTextSections(child, childPath)); + } else { + sections.push(...extractTextSections(child, childPath)); + } + } + } + + return sections; +} + +function metadataWithPlaceholders( + value: unknown, + sections: TextSection[], + path = '', +): unknown { + const sectionKeys = new Set(sections.map((s) => s.key)); + + if (typeof value === 'string') { + const key = path.split('.').pop() ?? path; + if (sectionKeys.has(path) && shouldExtractAsText(key, value)) { + return placeholderForText(value); + } + return value; + } + + if (Array.isArray(value)) { + return value.map((item, index) => { + const childPath = path ? `${path}[${index}]` : `[${index}]`; + return metadataWithPlaceholders(item, sections, childPath); + }); + } + + if (value && typeof value === 'object') { + const record = value as Record; + const result: Record = {}; + + for (const [key, child] of Object.entries(record)) { + const childPath = path ? `${path}.${key}` : key; + + if (key === 'messages' && Array.isArray(child)) { + result[key] = child.map((item, index) => { + const msgPath = `${childPath}[${index}]`; + if (item && typeof item === 'object' && !Array.isArray(item)) { + const msg = item as Record; + const role = typeof msg.role === 'string' ? msg.role : `item ${index}`; + const contentPath = `${msgPath}.content`; + if (typeof msg.content === 'string' && sectionKeys.has(contentPath)) { + return { + ...msg, + content: placeholderForText(msg.content), + }; + } + return metadataWithPlaceholders(item, sections, msgPath); + } + return metadataWithPlaceholders(item, sections, msgPath); + }); + continue; + } + + if (typeof child === 'string' && sectionKeys.has(childPath)) { + result[key] = placeholderForText(child); + } else { + result[key] = metadataWithPlaceholders(child, sections, childPath); + } + } + + return result; + } + + return value; +} + +interface RequestLogPayloadViewProps { + value: unknown; +} + +const RequestLogPayloadView: React.FC = ({ value }) => { + const normalized = normalizeJsonForDisplay(value); + const sections = extractTextSections(normalized); + const metadata = metadataWithPlaceholders(normalized, sections); + + return ( +

+
+        {formatJsonBlock(metadata)}
+      
+ {sections.map((section) => ( +
+
{section.label}
+
{section.text}
+
+ ))} +
+ ); +}; + +export default RequestLogPayloadView; diff --git a/src/app/src/renderer/utils/requestLogFormat.ts b/src/app/src/renderer/utils/requestLogFormat.ts new file mode 100644 index 000000000..0f2caa8b7 --- /dev/null +++ b/src/app/src/renderer/utils/requestLogFormat.ts @@ -0,0 +1,68 @@ +export function looksLikeJsonString(value: string): boolean { + const trimmed = value.trim(); + return trimmed.startsWith('{') || trimmed.startsWith('['); +} + +export function tryParseJsonString(value: string): unknown { + if (!looksLikeJsonString(value)) { + return value; + } + try { + return JSON.parse(value); + } catch { + return value; + } +} + +/** Recursively expand JSON-encoded strings so nested payloads pretty-print. */ +export function normalizeJsonForDisplay(value: unknown): unknown { + if (typeof value === 'string') { + let parsed: unknown = tryParseJsonString(value); + if (typeof parsed === 'string' && looksLikeJsonString(parsed)) { + parsed = tryParseJsonString(parsed); + } + if (parsed !== value) { + return normalizeJsonForDisplay(parsed); + } + return value; + } + if (Array.isArray(value)) { + return value.map((item) => normalizeJsonForDisplay(item)); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, normalizeJsonForDisplay(item)]), + ); + } + return value; +} + +export function formatJsonBlock(value: unknown): string { + if (value === null || value === undefined) { + return '—'; + } + + const normalized = normalizeJsonForDisplay(value); + if (typeof normalized === 'string') { + return normalized; + } + + return JSON.stringify(normalized, null, 2); +} + +export function payloadUsesCharCountsOnly(value: unknown): boolean { + if (!value || typeof value !== 'object') { + return false; + } + const record = value as Record; + if (record.prompt && typeof record.prompt === 'object' && record.prompt !== null) { + return 'char_count' in (record.prompt as Record); + } + if (record.messages && typeof record.messages === 'object' && record.messages !== null) { + return 'char_count' in (record.messages as Record); + } + if (record.content && typeof record.content === 'object' && record.content !== null) { + return 'char_count' in (record.content as Record); + } + return false; +} diff --git a/src/app/styles/styles.css b/src/app/styles/styles.css index 6da1230a7..c34dbf8e2 100644 --- a/src/app/styles/styles.css +++ b/src/app/styles/styles.css @@ -7881,6 +7881,44 @@ input::-webkit-calendar-picker-indicator { color: var(--text-secondary); } +.request-log-json--metadata { + max-height: 320px; +} + +.request-log-payload-view { + display: flex; + flex-direction: column; + gap: 10px; +} + +.request-log-text-section { + display: flex; + flex-direction: column; + gap: 6px; +} + +.request-log-text-label { + font-size: 0.68rem; + font-weight: 600; + color: var(--text-primary); + letter-spacing: 0.02em; +} + +.request-log-text-value { + margin: 0; + padding: 10px; + border-radius: 4px; + background: var(--bg-primary); + border: 1px solid var(--border-secondary); + font-size: 0.68rem; + line-height: 1.5; + overflow: auto; + max-height: 480px; + white-space: pre-wrap; + word-break: break-word; + color: var(--text-secondary); +} + .request-log-payload-section { margin-top: 14px; } From 2ff287fbc801c7ee7ea8117bb3f585c190531d44 Mon Sep 17 00:00:00 2001 From: Chuck Pearce Date: Thu, 18 Jun 2026 09:34:14 -0400 Subject: [PATCH 16/16] chore: drop local-only deployment and diagnostic scripts Remove fork-specific deploy/diagnostic helpers and secrets template before upstream PR review. Co-authored-by: Cursor --- data/secrets.conf | 7 - examples/find-ollama-api-clients.sh | 72 --------- scripts/deploy-ubuntu-local.sh | 229 ---------------------------- 3 files changed, 308 deletions(-) delete mode 100644 data/secrets.conf delete mode 100755 examples/find-ollama-api-clients.sh delete mode 100755 scripts/deploy-ubuntu-local.sh diff --git a/data/secrets.conf b/data/secrets.conf deleted file mode 100644 index 426f0add9..000000000 --- a/data/secrets.conf +++ /dev/null @@ -1,7 +0,0 @@ -# Installed as /etc/lemonade/conf.d/zz-secrets.conf so it loads after other -# drop-ins and keeps secrets separate from the base config file. -#LEMONADE_API_KEY= -#LEMONADE_REQUEST_LOG_ENABLED=true -#LEMONADE_REQUEST_LOG_DATABASE_URL=postgresql://lemonade:change-me@127.0.0.1:/lemonade_logs -#LEMONADE_REQUEST_LOG_RETENTION_DAYS=30 -#LEMONADE_LOG_PROMPTS=false diff --git a/examples/find-ollama-api-clients.sh b/examples/find-ollama-api-clients.sh deleted file mode 100755 index 8690d69e2..000000000 --- a/examples/find-ollama-api-clients.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -# Find local processes sending Ollama-compatible API traffic to Lemonade. -# -# Usage: -# ./examples/find-ollama-api-clients.sh -# LEMONADE_PORT=11434 ./examples/find-ollama-api-clients.sh -# LEMONADE_BASE_URL=http://127.0.0.1:11434 ./examples/find-ollama-api-clients.sh - -set -euo pipefail - -PORT="${LEMONADE_PORT:-11434}" -BASE_URL="${LEMONADE_BASE_URL:-http://127.0.0.1:${PORT}}" - -echo "=== Lemonade Ollama API client diagnostics ===" -echo "Port: ${PORT}" -echo "Base URL: ${BASE_URL}" -echo - -echo "--- TCP clients connected to :${PORT} ---" -if command -v ss >/dev/null 2>&1; then - ss -tnp 2>/dev/null | grep ":${PORT}" || echo "(no connections)" -elif command -v lsof >/dev/null 2>&1; then - lsof -nP -iTCP:"${PORT}" -sTCP:ESTABLISHED 2>/dev/null || echo "(no connections)" -else - echo "Install ss or lsof to list connected processes." -fi -echo - -echo "--- Python processes (common ollama-python clients) ---" -if pgrep -a python >/dev/null 2>&1 || pgrep -a python3 >/dev/null 2>&1; then - pgrep -a python 2>/dev/null || true - pgrep -a python3 2>/dev/null || true -else - echo "(no python processes)" -fi -echo - -echo "--- Recent POST /api/chat from request-log API (if enabled) ---" -if command -v curl >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then - headers=() - if [[ -n "${LEMONADE_API_KEY:-}" ]]; then - headers=(-H "Authorization: Bearer ${LEMONADE_API_KEY}") - elif [[ -n "${LEMONADE_ADMIN_API_KEY:-}" ]]; then - headers=(-H "Authorization: Bearer ${LEMONADE_ADMIN_API_KEY}") - fi - response="$(curl -fsS "${headers[@]}" \ - "${BASE_URL}/api/v1/request-log/search?path=%25chat%25&limit=15" 2>/dev/null || true)" - if [[ -n "${response}" ]]; then - echo "${response}" | jq -r '.entries[]? | "\(.created_at) ip=\(.client_ip) model=\(.model) ua=\(.user_agent)"' - else - echo "Request log API unavailable (logging disabled, auth required, or wrong port)." - echo "Query manually:" - echo " curl -s '${BASE_URL}/api/v1/request-log/search?path=%25chat%25&limit=15' | jq ." - fi -else - echo "Install curl and jq to query the request-log API." -fi -echo - -echo "--- Tips ---" -cat <<'EOF' -1. user_agent "ollama-python/..." means a local Python script using the PyPI - `ollama` package (pip install ollama), not Lemonade itself. -2. Default client host is http://127.0.0.1:11434 — same as Ollama's port. -3. Find the script: - pgrep -af python - sudo lsof -iTCP:11434 -sTCP:ESTABLISHED - tr '\0' ' ' < /proc//cmdline; ls -l /proc//cwd -4. Stop it: kill , or reconfigure it to use Lemonade model names from - `lemonade list`, or point OLLAMA_HOST at a real Ollama server. -5. To avoid accidental clients, run Lemonade on a non-Ollama port (e.g. 13305). -EOF diff --git a/scripts/deploy-ubuntu-local.sh b/scripts/deploy-ubuntu-local.sh deleted file mode 100755 index 194229f97..000000000 --- a/scripts/deploy-ubuntu-local.sh +++ /dev/null @@ -1,229 +0,0 @@ -#!/bin/bash -# Build lemonade-server from source (native Debian packaging, /usr layout) and -# install it on this Ubuntu machine, replacing a PPA or prior .deb install. -# -# Usage (from repo root on Ubuntu): -# ./scripts/deploy-ubuntu-local.sh [OPTIONS] -# -# Options: -# --skip-deps Skip apt build-dep -# --skip-build Reuse existing ../lemonade-server_*.deb -# --no-restart Install only; do not stop/start lemond.service -# --hold apt-mark hold lemonade-server after install -# -h, --help Show this help -set -euo pipefail - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -SKIP_DEPS=false -SKIP_BUILD=false -NO_RESTART=false -APT_HOLD=false - -print_info() { echo -e "${BLUE}[INFO]${NC} $1"; } -print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } -print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } -print_error() { echo -e "${RED}[ERROR]${NC} $1"; } - -usage() { - cat <<'EOF' -Build lemonade-server from source (native Debian packaging, /usr layout) and -install it on this Ubuntu machine, replacing a PPA or prior .deb install. - -Usage (from repo root on Ubuntu): - ./scripts/deploy-ubuntu-local.sh [OPTIONS] - -Options: - --skip-deps Skip apt build-dep - --skip-build Reuse existing ../lemonade-server_*.deb - --no-restart Install only; do not stop/start lemond.service - --hold apt-mark hold lemonade-server after install - -h, --help Show this help -EOF - exit 0 -} - -maybe_sudo() { - if [ "$(id -u)" -eq 0 ]; then - "$@" - else - sudo "$@" - fi -} - -cleanup() { - if [ -n "${REPO_ROOT:-}" ] && [ -d "${REPO_ROOT}/debian" ]; then - print_info "Removing generated debian/ directory..." - rm -rf "${REPO_ROOT}/debian" - fi -} - -while [ $# -gt 0 ]; do - case "$1" in - --skip-deps) SKIP_DEPS=true ;; - --skip-build) SKIP_BUILD=true ;; - --no-restart) NO_RESTART=true ;; - --hold) APT_HOLD=true ;; - -h|--help) usage ;; - *) - print_error "Unknown option: $1" - echo "Run with --help for usage." - exit 1 - ;; - esac - shift -done - -if [[ "${OSTYPE:-}" != linux* ]]; then - print_error "This script must run on Linux (Ubuntu)." - exit 1 -fi - -if ! command -v apt-get >/dev/null 2>&1; then - print_error "apt-get not found. This script is for Debian/Ubuntu only." - exit 1 -fi - -if ! command -v git >/dev/null 2>&1; then - print_error "git is required to determine the package version." - exit 1 -fi - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel 2>/dev/null || true)" -if [ -z "${REPO_ROOT}" ] || [ ! -f "${REPO_ROOT}/contrib/debian/control" ]; then - print_error "Run this script from a Lemonade git checkout (contrib/debian/control not found)." - exit 1 -fi - -cd "${REPO_ROOT}" -trap cleanup EXIT - -if command -v snap >/dev/null 2>&1 && snap list lemonade-server >/dev/null 2>&1; then - print_warning "Snap package 'lemonade-server' is installed and may conflict with the systemd .deb install." - print_warning "Consider: sudo snap remove lemonade-server" -fi - -if [ ! -d "contrib/debian" ]; then - print_error "Missing contrib/debian packaging tree." - exit 1 -fi - -print_info "Preparing Debian packaging metadata..." -rm -rf debian -cp -a contrib/debian debian - -GIT_VERSION="$(git describe --tags --always)" -DEB_VERSION="${GIT_VERSION#v}" -if command -v lsb_release >/dev/null 2>&1; then - UBUNTU_RELEASE="$(lsb_release -rs)" - DEB_CODENAME="$(lsb_release -cs)" - DEB_VERSION="${DEB_VERSION}~local${UBUNTU_RELEASE}" -else - DEB_CODENAME="local" - DEB_VERSION="${DEB_VERSION}~local" -fi -DEB_DATE="$(date -R)" - -sed -e "s|@@DEB_VERSION@@|${DEB_VERSION}|g" \ - -e "s|@@DEB_CODENAME@@|${DEB_CODENAME}|g" \ - -e "s|@@DEB_DATE@@|${DEB_DATE}|g" \ - debian/changelog.in > debian/changelog - -print_success "Package version: ${DEB_VERSION}" - -if [ "${SKIP_DEPS}" = false ]; then - print_info "Installing build dependencies (apt build-dep)..." - maybe_sudo apt-get update - maybe_sudo apt-get build-dep . -y - print_success "Build dependencies installed" -else - print_info "Skipping build dependencies (--skip-deps)" -fi - -if [ "${SKIP_BUILD}" = false ]; then - print_info "Building binary .deb (dpkg-buildpackage)..." - dpkg-buildpackage -us -uc -b - print_success "Package build finished" -else - print_info "Skipping package build (--skip-build)" -fi - -DEB_FILE="$(ls -t ../lemonade-server_*.deb 2>/dev/null | head -1 || true)" -if [ -z "${DEB_FILE}" ] || [ ! -f "${DEB_FILE}" ]; then - PARENT_DIR="$(cd "${REPO_ROOT}/.." && pwd)" - print_error "No lemonade-server .deb found in: ${PARENT_DIR}" - print_error "Run without --skip-build, or build manually with dpkg-buildpackage." - exit 1 -fi - -print_info "Using package: ${DEB_FILE}" - -if [ "${NO_RESTART}" = false ]; then - print_info "Stopping lemond.service (if running)..." - maybe_sudo systemctl stop lemond.service 2>/dev/null || true -fi - -print_info "Installing package (replaces PPA or prior install)..." -maybe_sudo apt-get install -y "${DEB_FILE}" -print_success "Package installed" - -if [ "${NO_RESTART}" = false ]; then - print_info "Enabling and starting lemond.service..." - maybe_sudo systemctl daemon-reload - maybe_sudo systemctl enable lemond.service - maybe_sudo systemctl start lemond.service - print_success "lemond.service started" -else - print_info "Skipping service restart (--no-restart)" -fi - -if [ "${APT_HOLD}" = true ]; then - print_info "Holding lemonade-server to prevent apt upgrade from overwriting local build..." - maybe_sudo apt-mark hold lemonade-server - print_success "lemonade-server marked as held" -fi - -if [ "${NO_RESTART}" = false ]; then - print_info "Waiting for server health check..." - HEALTH_OK=false - for _ in $(seq 1 30); do - if curl -sf "http://127.0.0.1:13305/health" >/dev/null 2>&1; then - HEALTH_OK=true - break - fi - if curl -sf "http://127.0.0.1:13305/v1/health" >/dev/null 2>&1; then - HEALTH_OK=true - break - fi - sleep 1 - done - - if [ "${HEALTH_OK}" = true ]; then - print_success "Health check passed (http://127.0.0.1:13305)" - else - print_warning "Health check did not succeed within 30s." - print_warning "Inspect logs: journalctl -u lemond.service -n 50 --no-pager" - fi -fi - -echo "" -echo "==========================================" -print_success "Deploy completed" -echo "==========================================" -echo "" -print_info "Installed package:" -dpkg -l lemonade-server 2>/dev/null | tail -1 || true -echo "" -print_info "Binary: $(command -v lemond 2>/dev/null || echo 'lemond not in PATH')" -print_info "Config/data: /var/lib/lemonade/.cache/lemonade/ (preserved across upgrades)" -echo "" -if [ "${APT_HOLD}" = false ]; then - print_warning "PPA still enabled? A future 'apt upgrade' may replace this build with the PPA version." - print_warning "To pin the local build: sudo apt-mark hold lemonade-server" - print_warning "Or re-run with: $0 --hold" -fi