victor HF Staff Claude commited on
Commit
59993d0
Β·
1 Parent(s): 85f4370

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>

Files changed (2) hide show
  1. analytics.py +63 -4
  2. 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
- COUNTS_FILE = "/data/request_counts.json"
9
- LOCK_FILE = COUNTS_FILE + ".lock"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- async def record_request() -> None:
22
- """Increment today's counter (UTC) atomically."""
 
 
 
 
 
 
 
 
 
 
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
- await record_request()
 
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
- # πŸ” Web Search MCP Server
210
-
211
- This MCP server provides web search capabilities to LLMs. It can perform general web searches
212
- or specifically search for fresh news articles, extracting the main content from results.
213
-
214
- **Search Types:**
215
- - **General Search**: Diverse results from various sources (blogs, docs, articles, etc.)
216
- - **News Search**: Fresh news articles and breaking stories from news sources
217
-
218
- **Note:** This interface is primarily designed for MCP tool usage by LLMs, but you can
219
- also test it manually below.
220
- """
221
- )
222
-
223
- gr.HTML(
224
- """
225
- <div style="margin-bottom: 24px;">
226
- <a href="https://huggingface.co/spaces/victor/websearch?view=api">
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
- with gr.Column(scale=1):
244
- search_type_input = gr.Radio(
245
- choices=["search", "news"],
246
- value="search",
247
- label="Search Type",
248
- info="Choose search type",
 
 
 
 
 
 
249
  )
250
 
251
- with gr.Row():
252
- num_results_input = gr.Slider(
253
- minimum=1,
254
- maximum=20,
255
- value=4,
256
- step=1,
257
- label="Number of Results",
258
- info="Optional: How many results to fetch (default: 4)",
259
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
 
261
- search_button = gr.Button("Search", variant="primary")
262
 
263
- output = gr.Textbox(
264
- label="Extracted Content",
265
- lines=25,
266
- max_lines=50,
267
- info="The extracted article content will appear here",
268
- )
269
 
270
- # Add examples
271
- gr.Examples(
272
- examples=[
273
- ["OpenAI GPT-5 latest developments", "news", 5],
274
- ["React hooks useState", "search", 4],
275
- ["Tesla stock price today", "news", 6],
276
- ["Apple Vision Pro reviews", "search", 4],
277
- ["best Italian restaurants NYC", "search", 4],
278
- ],
279
- inputs=[query_input, search_type_input, num_results_input],
280
- outputs=output,
281
- fn=search_web,
282
- cache_examples=False,
283
- )
284
 
285
- requests_plot = gr.BarPlot(
286
- value=last_n_days_df(14), # Show only last 14 days for better visibility
287
- x="date",
288
- y="count",
289
- title="Daily Community Request Count",
290
- tooltip=["date", "count"],
291
- height=280,
292
- x_label_angle=-45, # Rotate labels to prevent overlap
293
- container=False,
294
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
 
296
  search_button.click(
297
- fn=search_and_log, # wrapper
298
  inputs=[query_input, search_type_input, num_results_input],
299
- outputs=[output, requests_plot], # update both
300
  api_name=False, # Hide this endpoint from API & MCP
301
  )
302
 
303
- # Load fresh analytics data when the page loads
304
- demo.load(fn=lambda: last_n_days_df(14), outputs=requests_plot, api_name=False)
 
 
 
 
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")