Spaces:
Running
Running
Add request timing analytics and improve UI organization
Browse files- Reorganized app into separate "App" and "Analytics" tabs
- Added request timing tracking to measure performance
- Display average request time chart alongside request count
- Updated data storage logic for Hugging Face Spaces compatibility
- Added speed-focused messaging highlighting <3 second performance
- Changed timing chart from LinePlot to BarPlot for better visualization
π€ Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
- analytics.py +63 -4
- app.py +134 -92
analytics.py
CHANGED
@@ -5,8 +5,24 @@ from datetime import datetime, timedelta, timezone
|
|
5 |
from filelock import FileLock # pip install filelock
|
6 |
import pandas as pd # already available in HF images
|
7 |
|
8 |
-
|
9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
|
11 |
def _load() -> dict:
|
12 |
if not os.path.exists(COUNTS_FILE):
|
@@ -18,13 +34,32 @@ def _save(data: dict):
|
|
18 |
with open(COUNTS_FILE, "w") as f:
|
19 |
json.dump(data, f)
|
20 |
|
21 |
-
|
22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
23 |
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
24 |
with FileLock(LOCK_FILE):
|
|
|
25 |
data = _load()
|
26 |
data[today] = data.get(today, 0) + 1
|
27 |
_save(data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
28 |
|
29 |
def last_n_days_df(n: int = 30) -> pd.DataFrame:
|
30 |
"""Return a DataFrame with a row for each of the past *n* days."""
|
@@ -42,4 +77,28 @@ def last_n_days_df(n: int = 30) -> pd.DataFrame:
|
|
42 |
"count": data.get(day_str, 0),
|
43 |
"full_date": day_str # Keep full date for tooltip
|
44 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
45 |
return pd.DataFrame(records)
|
|
|
5 |
from filelock import FileLock # pip install filelock
|
6 |
import pandas as pd # already available in HF images
|
7 |
|
8 |
+
# Determine data directory based on environment
|
9 |
+
# 1. Check for environment variable override
|
10 |
+
# 2. Use /data if it exists and is writable (Hugging Face Spaces with persistent storage)
|
11 |
+
# 3. Use ./data for local development
|
12 |
+
DATA_DIR = os.getenv("ANALYTICS_DATA_DIR")
|
13 |
+
if not DATA_DIR:
|
14 |
+
if os.path.exists("/data") and os.access("/data", os.W_OK):
|
15 |
+
DATA_DIR = "/data"
|
16 |
+
print("[Analytics] Using persistent storage at /data")
|
17 |
+
else:
|
18 |
+
DATA_DIR = "./data"
|
19 |
+
print("[Analytics] Using local storage at ./data")
|
20 |
+
|
21 |
+
os.makedirs(DATA_DIR, exist_ok=True)
|
22 |
+
|
23 |
+
COUNTS_FILE = os.path.join(DATA_DIR, "request_counts.json")
|
24 |
+
TIMES_FILE = os.path.join(DATA_DIR, "request_times.json")
|
25 |
+
LOCK_FILE = os.path.join(DATA_DIR, "analytics.lock")
|
26 |
|
27 |
def _load() -> dict:
|
28 |
if not os.path.exists(COUNTS_FILE):
|
|
|
34 |
with open(COUNTS_FILE, "w") as f:
|
35 |
json.dump(data, f)
|
36 |
|
37 |
+
def _load_times() -> dict:
|
38 |
+
if not os.path.exists(TIMES_FILE):
|
39 |
+
return {}
|
40 |
+
with open(TIMES_FILE) as f:
|
41 |
+
return json.load(f)
|
42 |
+
|
43 |
+
def _save_times(data: dict):
|
44 |
+
with open(TIMES_FILE, "w") as f:
|
45 |
+
json.dump(data, f)
|
46 |
+
|
47 |
+
async def record_request(duration: float = None) -> None:
|
48 |
+
"""Increment today's counter (UTC) atomically and optionally record request duration."""
|
49 |
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
50 |
with FileLock(LOCK_FILE):
|
51 |
+
# Update counts
|
52 |
data = _load()
|
53 |
data[today] = data.get(today, 0) + 1
|
54 |
_save(data)
|
55 |
+
|
56 |
+
# Update times if duration provided
|
57 |
+
if duration is not None:
|
58 |
+
times = _load_times()
|
59 |
+
if today not in times:
|
60 |
+
times[today] = []
|
61 |
+
times[today].append(round(duration, 2))
|
62 |
+
_save_times(times)
|
63 |
|
64 |
def last_n_days_df(n: int = 30) -> pd.DataFrame:
|
65 |
"""Return a DataFrame with a row for each of the past *n* days."""
|
|
|
77 |
"count": data.get(day_str, 0),
|
78 |
"full_date": day_str # Keep full date for tooltip
|
79 |
})
|
80 |
+
return pd.DataFrame(records)
|
81 |
+
|
82 |
+
def last_n_days_avg_time_df(n: int = 30) -> pd.DataFrame:
|
83 |
+
"""Return a DataFrame with average request time for each of the past *n* days."""
|
84 |
+
now = datetime.now(timezone.utc)
|
85 |
+
with FileLock(LOCK_FILE):
|
86 |
+
times = _load_times()
|
87 |
+
records = []
|
88 |
+
for i in range(n):
|
89 |
+
day = (now - timedelta(days=n - 1 - i))
|
90 |
+
day_str = day.strftime("%Y-%m-%d")
|
91 |
+
# Format date for display (MMM DD)
|
92 |
+
display_date = day.strftime("%b %d")
|
93 |
+
|
94 |
+
# Calculate average time for the day
|
95 |
+
day_times = times.get(day_str, [])
|
96 |
+
avg_time = round(sum(day_times) / len(day_times), 2) if day_times else 0
|
97 |
+
|
98 |
+
records.append({
|
99 |
+
"date": display_date,
|
100 |
+
"avg_time": avg_time,
|
101 |
+
"request_count": len(day_times),
|
102 |
+
"full_date": day_str # Keep full date for tooltip
|
103 |
+
})
|
104 |
return pd.DataFrame(records)
|
app.py
CHANGED
@@ -1,5 +1,6 @@
|
|
1 |
import os
|
2 |
import asyncio
|
|
|
3 |
from typing import Optional
|
4 |
from datetime import datetime
|
5 |
import httpx
|
@@ -9,7 +10,7 @@ from dateutil import parser as dateparser
|
|
9 |
from limits import parse
|
10 |
from limits.aio.storage import MemoryStorage
|
11 |
from limits.aio.strategies import MovingWindowRateLimiter
|
12 |
-
from analytics import record_request, last_n_days_df
|
13 |
|
14 |
# Configuration
|
15 |
SERPER_API_KEY = os.getenv("SERPER_API_KEY")
|
@@ -65,8 +66,10 @@ async def search_web(
|
|
65 |
- search_web("stock market today", "news", 10) - Get 10 news articles about today's market
|
66 |
- search_web("machine learning basics") - Get 4 general search results (all defaults)
|
67 |
"""
|
68 |
-
|
|
|
69 |
if not SERPER_API_KEY:
|
|
|
70 |
return "Error: SERPER_API_KEY environment variable is not set. Please set it to use this tool."
|
71 |
|
72 |
# Validate and constrain num_results
|
@@ -82,6 +85,8 @@ async def search_web(
|
|
82 |
# Check rate limit
|
83 |
if not await limiter.hit(rate_limit, "global"):
|
84 |
print(f"[{datetime.now().isoformat()}] Rate limit exceeded")
|
|
|
|
|
85 |
return "Error: Rate limit exceeded. Please try again later (limit: 500 requests per hour)."
|
86 |
|
87 |
# Select endpoint based on search type
|
@@ -99,6 +104,8 @@ async def search_web(
|
|
99 |
resp = await client.post(endpoint, headers=HEADERS, json=payload)
|
100 |
|
101 |
if resp.status_code != 200:
|
|
|
|
|
102 |
return f"Error: Search API returned status {resp.status_code}. Please check your API key and try again."
|
103 |
|
104 |
# Extract results based on search type
|
@@ -108,6 +115,8 @@ async def search_web(
|
|
108 |
results = resp.json().get("organic", [])
|
109 |
|
110 |
if not results:
|
|
|
|
|
111 |
return f"No {search_type} results found for query: '{query}'. Try a different search term or search type."
|
112 |
|
113 |
# Fetch HTML content concurrently
|
@@ -172,6 +181,8 @@ async def search_web(
|
|
172 |
chunks.append(chunk)
|
173 |
|
174 |
if not chunks:
|
|
|
|
|
175 |
return f"Found {len(results)} {search_type} results for '{query}', but couldn't extract readable content from any of them. The websites might be blocking automated access."
|
176 |
|
177 |
result = "\n---\n".join(chunks)
|
@@ -180,17 +191,20 @@ async def search_web(
|
|
180 |
print(
|
181 |
f"[{datetime.now().isoformat()}] Extraction complete: {successful_extractions}/{len(results)} successful for query '{query}'"
|
182 |
)
|
|
|
|
|
|
|
|
|
|
|
183 |
return summary + result
|
184 |
|
185 |
except Exception as e:
|
|
|
|
|
|
|
186 |
return f"Error occurred while searching: {str(e)}. Please try again or check your query."
|
187 |
|
188 |
|
189 |
-
async def search_and_log(query, search_type, num_results):
|
190 |
-
text = await search_web(query, search_type, num_results)
|
191 |
-
chart_df = last_n_days_df(14) # Show last 14 days
|
192 |
-
return text, chart_df
|
193 |
-
|
194 |
|
195 |
# Create Gradio interface
|
196 |
with gr.Blocks(title="Web Search MCP Server") as demo:
|
@@ -204,104 +218,132 @@ with gr.Blocks(title="Web Search MCP Server") as demo:
|
|
204 |
"""
|
205 |
)
|
206 |
|
207 |
-
gr.Markdown(
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
<img src="https://huggingface.co/datasets/huggingface/badges/resolve/main/use-with-mcp-lg-dark.svg"
|
228 |
-
alt="Use with MCP"
|
229 |
-
style="height: 36px;">
|
230 |
-
</a>
|
231 |
-
</div>
|
232 |
-
""",
|
233 |
-
padding=0,
|
234 |
-
)
|
235 |
-
|
236 |
-
with gr.Row():
|
237 |
-
with gr.Column(scale=3):
|
238 |
-
query_input = gr.Textbox(
|
239 |
-
label="Search Query",
|
240 |
-
placeholder='e.g. "OpenAI news", "climate change 2024", "AI developments"',
|
241 |
-
info="Required: Enter your search query",
|
242 |
)
|
243 |
-
|
244 |
-
|
245 |
-
|
246 |
-
|
247 |
-
|
248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
249 |
)
|
250 |
|
251 |
-
|
252 |
-
|
253 |
-
|
254 |
-
|
255 |
-
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
260 |
|
261 |
-
|
262 |
|
263 |
-
|
264 |
-
|
265 |
-
|
266 |
-
|
267 |
-
|
268 |
-
|
269 |
|
270 |
-
|
271 |
-
|
272 |
-
|
273 |
-
|
274 |
-
|
275 |
-
|
276 |
-
|
277 |
-
|
278 |
-
|
279 |
-
|
280 |
-
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
|
285 |
-
|
286 |
-
|
287 |
-
|
288 |
-
|
289 |
-
|
290 |
-
|
291 |
-
|
292 |
-
|
293 |
-
|
294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
295 |
|
296 |
search_button.click(
|
297 |
-
fn=
|
298 |
inputs=[query_input, search_type_input, num_results_input],
|
299 |
-
outputs=
|
300 |
api_name=False, # Hide this endpoint from API & MCP
|
301 |
)
|
302 |
|
303 |
-
# Load fresh analytics data when the page loads
|
304 |
-
demo.load(
|
|
|
|
|
|
|
|
|
305 |
|
306 |
# Expose search_web as the only MCP tool
|
307 |
gr.api(search_web, api_name="search_web")
|
|
|
1 |
import os
|
2 |
import asyncio
|
3 |
+
import time
|
4 |
from typing import Optional
|
5 |
from datetime import datetime
|
6 |
import httpx
|
|
|
10 |
from limits import parse
|
11 |
from limits.aio.storage import MemoryStorage
|
12 |
from limits.aio.strategies import MovingWindowRateLimiter
|
13 |
+
from analytics import record_request, last_n_days_df, last_n_days_avg_time_df
|
14 |
|
15 |
# Configuration
|
16 |
SERPER_API_KEY = os.getenv("SERPER_API_KEY")
|
|
|
66 |
- search_web("stock market today", "news", 10) - Get 10 news articles about today's market
|
67 |
- search_web("machine learning basics") - Get 4 general search results (all defaults)
|
68 |
"""
|
69 |
+
start_time = time.time()
|
70 |
+
|
71 |
if not SERPER_API_KEY:
|
72 |
+
await record_request() # Record even failed requests
|
73 |
return "Error: SERPER_API_KEY environment variable is not set. Please set it to use this tool."
|
74 |
|
75 |
# Validate and constrain num_results
|
|
|
85 |
# Check rate limit
|
86 |
if not await limiter.hit(rate_limit, "global"):
|
87 |
print(f"[{datetime.now().isoformat()}] Rate limit exceeded")
|
88 |
+
duration = time.time() - start_time
|
89 |
+
await record_request(duration)
|
90 |
return "Error: Rate limit exceeded. Please try again later (limit: 500 requests per hour)."
|
91 |
|
92 |
# Select endpoint based on search type
|
|
|
104 |
resp = await client.post(endpoint, headers=HEADERS, json=payload)
|
105 |
|
106 |
if resp.status_code != 200:
|
107 |
+
duration = time.time() - start_time
|
108 |
+
await record_request(duration)
|
109 |
return f"Error: Search API returned status {resp.status_code}. Please check your API key and try again."
|
110 |
|
111 |
# Extract results based on search type
|
|
|
115 |
results = resp.json().get("organic", [])
|
116 |
|
117 |
if not results:
|
118 |
+
duration = time.time() - start_time
|
119 |
+
await record_request(duration)
|
120 |
return f"No {search_type} results found for query: '{query}'. Try a different search term or search type."
|
121 |
|
122 |
# Fetch HTML content concurrently
|
|
|
181 |
chunks.append(chunk)
|
182 |
|
183 |
if not chunks:
|
184 |
+
duration = time.time() - start_time
|
185 |
+
await record_request(duration)
|
186 |
return f"Found {len(results)} {search_type} results for '{query}', but couldn't extract readable content from any of them. The websites might be blocking automated access."
|
187 |
|
188 |
result = "\n---\n".join(chunks)
|
|
|
191 |
print(
|
192 |
f"[{datetime.now().isoformat()}] Extraction complete: {successful_extractions}/{len(results)} successful for query '{query}'"
|
193 |
)
|
194 |
+
|
195 |
+
# Record successful request with duration
|
196 |
+
duration = time.time() - start_time
|
197 |
+
await record_request(duration)
|
198 |
+
|
199 |
return summary + result
|
200 |
|
201 |
except Exception as e:
|
202 |
+
# Record failed request with duration
|
203 |
+
duration = time.time() - start_time
|
204 |
+
await record_request(duration)
|
205 |
return f"Error occurred while searching: {str(e)}. Please try again or check your query."
|
206 |
|
207 |
|
|
|
|
|
|
|
|
|
|
|
208 |
|
209 |
# Create Gradio interface
|
210 |
with gr.Blocks(title="Web Search MCP Server") as demo:
|
|
|
218 |
"""
|
219 |
)
|
220 |
|
221 |
+
gr.Markdown("# π Web Search MCP Server")
|
222 |
+
|
223 |
+
with gr.Tabs():
|
224 |
+
with gr.Tab("App"):
|
225 |
+
gr.Markdown(
|
226 |
+
"""
|
227 |
+
This MCP server provides web search capabilities to LLMs. It can perform general web searches
|
228 |
+
or specifically search for fresh news articles, extracting the main content from results.
|
229 |
+
|
230 |
+
**β‘ Speed-Focused:** Optimized to complete the entire search process - from query to
|
231 |
+
fully extracted web content - in under 3 seconds. Check out the Analytics tab
|
232 |
+
to see real-time performance metrics.
|
233 |
+
|
234 |
+
**Search Types:**
|
235 |
+
- **General Search**: Diverse results from various sources (blogs, docs, articles, etc.)
|
236 |
+
- **News Search**: Fresh news articles and breaking stories from news sources
|
237 |
+
|
238 |
+
**Note:** This interface is primarily designed for MCP tool usage by LLMs, but you can
|
239 |
+
also test it manually below.
|
240 |
+
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
241 |
)
|
242 |
+
|
243 |
+
gr.HTML(
|
244 |
+
"""
|
245 |
+
<div style="margin-bottom: 24px;">
|
246 |
+
<a href="https://huggingface.co/spaces/victor/websearch?view=api">
|
247 |
+
<img src="https://huggingface.co/datasets/huggingface/badges/resolve/main/use-with-mcp-lg-dark.svg"
|
248 |
+
alt="Use with MCP"
|
249 |
+
style="height: 36px;">
|
250 |
+
</a>
|
251 |
+
</div>
|
252 |
+
""",
|
253 |
+
padding=0,
|
254 |
)
|
255 |
|
256 |
+
with gr.Row():
|
257 |
+
with gr.Column(scale=3):
|
258 |
+
query_input = gr.Textbox(
|
259 |
+
label="Search Query",
|
260 |
+
placeholder='e.g. "OpenAI news", "climate change 2024", "AI developments"',
|
261 |
+
info="Required: Enter your search query",
|
262 |
+
)
|
263 |
+
with gr.Column(scale=1):
|
264 |
+
search_type_input = gr.Radio(
|
265 |
+
choices=["search", "news"],
|
266 |
+
value="search",
|
267 |
+
label="Search Type",
|
268 |
+
info="Choose search type",
|
269 |
+
)
|
270 |
+
|
271 |
+
with gr.Row():
|
272 |
+
num_results_input = gr.Slider(
|
273 |
+
minimum=1,
|
274 |
+
maximum=20,
|
275 |
+
value=4,
|
276 |
+
step=1,
|
277 |
+
label="Number of Results",
|
278 |
+
info="Optional: How many results to fetch (default: 4)",
|
279 |
+
)
|
280 |
|
281 |
+
search_button = gr.Button("Search", variant="primary")
|
282 |
|
283 |
+
output = gr.Textbox(
|
284 |
+
label="Extracted Content",
|
285 |
+
lines=25,
|
286 |
+
max_lines=50,
|
287 |
+
info="The extracted article content will appear here",
|
288 |
+
)
|
289 |
|
290 |
+
# Add examples
|
291 |
+
gr.Examples(
|
292 |
+
examples=[
|
293 |
+
["OpenAI GPT-5 latest developments", "news", 5],
|
294 |
+
["React hooks useState", "search", 4],
|
295 |
+
["Tesla stock price today", "news", 6],
|
296 |
+
["Apple Vision Pro reviews", "search", 4],
|
297 |
+
["best Italian restaurants NYC", "search", 4],
|
298 |
+
],
|
299 |
+
inputs=[query_input, search_type_input, num_results_input],
|
300 |
+
outputs=output,
|
301 |
+
fn=search_web,
|
302 |
+
cache_examples=False,
|
303 |
+
)
|
304 |
|
305 |
+
with gr.Tab("Analytics"):
|
306 |
+
gr.Markdown("## Community Usage Analytics")
|
307 |
+
gr.Markdown("Track daily request counts and average response times from all community users.")
|
308 |
+
|
309 |
+
with gr.Row():
|
310 |
+
with gr.Column():
|
311 |
+
requests_plot = gr.BarPlot(
|
312 |
+
value=last_n_days_df(14), # Show only last 14 days for better visibility
|
313 |
+
x="date",
|
314 |
+
y="count",
|
315 |
+
title="Daily Request Count",
|
316 |
+
tooltip=["date", "count"],
|
317 |
+
height=350,
|
318 |
+
x_label_angle=-45, # Rotate labels to prevent overlap
|
319 |
+
container=False,
|
320 |
+
)
|
321 |
+
|
322 |
+
with gr.Column():
|
323 |
+
avg_time_plot = gr.BarPlot(
|
324 |
+
value=last_n_days_avg_time_df(14), # Show only last 14 days
|
325 |
+
x="date",
|
326 |
+
y="avg_time",
|
327 |
+
title="Average Request Time (seconds)",
|
328 |
+
tooltip=["date", "avg_time", "request_count"],
|
329 |
+
height=350,
|
330 |
+
x_label_angle=-45,
|
331 |
+
container=False,
|
332 |
+
)
|
333 |
|
334 |
search_button.click(
|
335 |
+
fn=search_web, # Use search_web directly instead of search_and_log
|
336 |
inputs=[query_input, search_type_input, num_results_input],
|
337 |
+
outputs=output,
|
338 |
api_name=False, # Hide this endpoint from API & MCP
|
339 |
)
|
340 |
|
341 |
+
# Load fresh analytics data when the page loads or Analytics tab is clicked
|
342 |
+
demo.load(
|
343 |
+
fn=lambda: (last_n_days_df(14), last_n_days_avg_time_df(14)),
|
344 |
+
outputs=[requests_plot, avg_time_plot],
|
345 |
+
api_name=False
|
346 |
+
)
|
347 |
|
348 |
# Expose search_web as the only MCP tool
|
349 |
gr.api(search_web, api_name="search_web")
|