Skip to content

Commit 5a96c8c

Browse files
committed
feat: add detailed metrics, active paste count tracking, and Redis integration
- Implemented metrics for paste, cache, storage, and cleanup operations - Introduced `ActivePastesCounter` for accurate active paste count tracking - Enable periodic refresh of `active_pastes` gauge from the database - Added Redis-backed counters and gauges for multi-instance support - Enhanced cleanup service to update metrics and active pastes count - Integrated metrics initialization and Redis client setup in app lifecycle - Updated API routes to collect cache operation metrics - Updated `PasteService` to increment/decrement counters during operations
1 parent 9b58c80 commit 5a96c8c

File tree

6 files changed

+446
-8
lines changed

6 files changed

+446
-8
lines changed

backend/app/api/subroutes/pastes.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from app.ratelimit import create_limit_resolver, get_exempt_key, limiter
2323
from app.services.paste_service import PasteService
2424
from app.utils.LRUMemoryCache import LRUMemoryCache
25+
from app.utils.metrics import cache_operations
2526

2627
if TYPE_CHECKING:
2728
from aiocache import RedisCache
@@ -58,6 +59,7 @@ async def get_paste(
5859
):
5960
cached_result = await cache.get(paste_id)
6061
if cached_result:
62+
cache_operations.labels(operation="get", result="hit").inc()
6163
return Response(
6264
cached_result,
6365
headers={
@@ -66,6 +68,7 @@ async def get_paste(
6668
},
6769
)
6870

71+
cache_operations.labels(operation="get", result="miss").inc()
6972
paste_result = await paste_service.get_legacy_paste_by_name(paste_id)
7073
if not paste_result:
7174
return Response(
@@ -82,6 +85,7 @@ async def get_paste(
8285
paste_result = paste_result.model_dump_json()
8386

8487
await cache.set(paste_id, paste_result, ttl=config.CACHE_TTL)
88+
cache_operations.labels(operation="set", result="success").inc()
8589

8690
return Response(
8791
paste_result,
@@ -105,6 +109,7 @@ async def get_paste(
105109
):
106110
cached_result = await cache.get(paste_id)
107111
if cached_result:
112+
cache_operations.labels(operation="get", result="hit").inc()
108113
return Response(
109114
cached_result,
110115
headers={
@@ -113,6 +118,7 @@ async def get_paste(
113118
},
114119
)
115120

121+
cache_operations.labels(operation="get", result="miss").inc()
116122
paste_result = await paste_service.get_paste_by_id(paste_id)
117123
if not paste_result:
118124
return Response(
@@ -128,6 +134,7 @@ async def get_paste(
128134
paste_result = paste_result.model_dump_json()
129135

130136
await cache.set(paste_id, paste_result, ttl=config.CACHE_TTL)
137+
cache_operations.labels(operation="set", result="success").inc()
131138

132139
return Response(
133140
paste_result,
@@ -170,6 +177,7 @@ async def edit_paste(
170177
)
171178
# Invalidate cache after successful edit
172179
await cache.delete(paste_id)
180+
cache_operations.labels(operation="delete", result="success").inc()
173181
return result
174182

175183

@@ -193,4 +201,5 @@ async def delete_paste(
193201
)
194202
# Invalidate cache after successful delete
195203
await cache.delete(paste_id)
204+
cache_operations.labels(operation="delete", result="success").inc()
196205
return {"message": "Paste deleted successfully"}

backend/app/services/cleanup_service.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import contextlib
33
import logging
4+
import time
45
from collections.abc import Coroutine
56
from datetime import UTC, datetime, timedelta
67
from pathlib import Path
@@ -12,6 +13,8 @@
1213
from app.config import config
1314
from app.db.models import PasteEntity
1415
from app.locks import DistributedLock
16+
from app.utils.active_pastes_counter import get_active_pastes_counter
17+
from app.utils.metrics import cleanup_duration
1518

1619

1720
class CleanupService:
@@ -76,6 +79,7 @@ async def _cleanup_expired_pastes(self):
7679
"""Remove expired pastes and their files"""
7780
from app.api.subroutes.pastes import cache
7881

82+
start_time = time.monotonic()
7983
try:
8084
BATCH_SIZE = 100
8185
total_cleaned = 0
@@ -123,12 +127,19 @@ async def _cleanup_expired_pastes(self):
123127
total_cleaned += len(batch)
124128

125129
if total_cleaned > 0:
130+
# Update active_pastes counter
131+
counter = get_active_pastes_counter()
132+
if counter:
133+
counter.dec(total_cleaned)
126134
if not error:
127135
self.logger.info("Successfully cleaned up %d expired pastes", total_cleaned)
128136
else:
129137
self.logger.info("Cleaned up %d expired pastes with some errors", total_cleaned)
130138
except Exception as exc:
131139
self.logger.error("Failed to cleanup expired pastes: %s", exc)
140+
finally:
141+
duration = time.monotonic() - start_time
142+
cleanup_duration.observe(duration)
132143

133144
async def _cleanup_deleted_pastes(self):
134145
"""Remove deleted pastes that have been marked for deletion beyond the configured time"""
@@ -139,6 +150,7 @@ async def _cleanup_deleted_pastes(self):
139150
self.logger.info("Cleaning up deleted pastes")
140151
from app.api.subroutes.pastes import cache
141152

153+
start_time = time.monotonic()
142154
try:
143155
BATCH_SIZE = 100
144156
total_cleaned = 0
@@ -195,3 +207,6 @@ async def _cleanup_deleted_pastes(self):
195207
self.logger.info("Cleaned up %d deleted pastes with some errors", total_cleaned)
196208
except Exception as exc:
197209
self.logger.error("Failed to cleanup deleted pastes: %s", exc)
210+
finally:
211+
duration = time.monotonic() - start_time
212+
cleanup_duration.observe(duration)

backend/app/services/paste_service.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@
2828
from app.db.models import PasteEntity
2929
from app.services.cleanup_service import CleanupService
3030
from app.storage import StorageClient
31+
from app.utils.active_pastes_counter import get_active_pastes_counter
32+
from app.utils.metrics import (
33+
compressed_pastes,
34+
paste_operations,
35+
paste_size,
36+
storage_operations,
37+
)
3138
from app.utils.token_utils import hash_token, is_token_hashed, verify_token
3239

3340

@@ -44,6 +51,18 @@ def __init__(
4451
self._cleanup_task: asyncio.Task[Coroutine[None, None, None]] | None = None
4552
self._lock_file: Path = Path(".cleanup.lock")
4653
self._cleanup_service: CleanupService = cleanup_service
54+
self._storage_backend_name: str = self._get_storage_backend_name()
55+
56+
def _get_storage_backend_name(self) -> str:
57+
"""Get storage backend name for metrics labels."""
58+
class_name = self.storage_client.__class__.__name__
59+
if "Local" in class_name:
60+
return "local"
61+
elif "S3" in class_name:
62+
return "s3"
63+
elif "Minio" in class_name or "MinIO" in class_name:
64+
return "minio"
65+
return "unknown"
4766

4867
async def _read_content(self, paste_path: str, is_compressed: bool = False) -> str | None:
4968
"""
@@ -60,8 +79,15 @@ async def _read_content(self, paste_path: str, is_compressed: bool = False) -> s
6079
data = await self.storage_client.get_object(paste_path)
6180
if data is None:
6281
self.logger.error("Paste content not found: %s", paste_path)
82+
storage_operations.labels(
83+
operation="get", backend=self._storage_backend_name, status="not_found"
84+
).inc()
6385
return None
6486

87+
storage_operations.labels(
88+
operation="get", backend=self._storage_backend_name, status="success"
89+
).inc()
90+
6591
if is_compressed:
6692
from app.utils.compression import CompressionError, decompress_content
6793

@@ -74,6 +100,9 @@ async def _read_content(self, paste_path: str, is_compressed: bool = False) -> s
74100
return data.decode("utf-8")
75101
except Exception as exc:
76102
self.logger.error("Failed to read paste content: %s", exc)
103+
storage_operations.labels(
104+
operation="get", backend=self._storage_backend_name, status="error"
105+
).inc()
77106
return None
78107

79108
async def _save_content(self, paste_id: str, content: str) -> tuple[str, int, bool, int | None] | None:
@@ -126,23 +155,38 @@ async def _save_content(self, paste_id: str, content: str) -> tuple[str, int, bo
126155
# Write content (compressed or uncompressed)
127156
if use_compression and compressed_data:
128157
await self.storage_client.put_object(storage_key, compressed_data)
158+
storage_operations.labels(
159+
operation="put", backend=self._storage_backend_name, status="success"
160+
).inc()
129161
content_size = len(compressed_data)
130162
return storage_key, content_size, True, original_size
131163
else:
132164
await self.storage_client.put_object(storage_key, content.encode("utf-8"))
165+
storage_operations.labels(
166+
operation="put", backend=self._storage_backend_name, status="success"
167+
).inc()
133168
content_size = original_size
134169
return storage_key, content_size, False, None
135170

136171
except Exception as exc:
137172
self.logger.error("Failed to save paste content: %s", exc)
173+
storage_operations.labels(
174+
operation="put", backend=self._storage_backend_name, status="error"
175+
).inc()
138176
return None
139177

140178
async def _remove_file(self, storage_key: str):
141179
"""Remove paste file from storage."""
142180
try:
143181
await self.storage_client.delete_object(storage_key)
182+
storage_operations.labels(
183+
operation="delete", backend=self._storage_backend_name, status="success"
184+
).inc()
144185
except Exception as exc:
145186
self.logger.error("Failed to remove file %s: %s", storage_key, exc)
187+
storage_operations.labels(
188+
operation="delete", backend=self._storage_backend_name, status="error"
189+
).inc()
146190

147191
def verify_storage_limit(self):
148192
"""Verify storage limit (only applicable for local storage)."""
@@ -202,11 +246,13 @@ async def get_paste_by_id(self, paste_id: UUID4) -> PasteResponse | None:
202246
)
203247
result: PasteEntity | None = (await session.execute(stmt)).scalar_one_or_none()
204248
if result is None:
249+
paste_operations.labels(operation="get", status="not_found").inc()
205250
return None
206251
content = await self._read_content(
207252
result.content_path,
208253
is_compressed=result.is_compressed,
209254
)
255+
paste_operations.labels(operation="get", status="success").inc()
210256
return PasteResponse(
211257
id=result.id,
212258
title=result.title,
@@ -233,6 +279,7 @@ async def edit_paste(self, paste_id: UUID4, edit_paste: EditPaste, edit_token: s
233279
result: PasteEntity | None = (await session.execute(stmt)).scalar_one_or_none()
234280

235281
if result is None:
282+
paste_operations.labels(operation="edit", status="not_found").inc()
236283
return None
237284

238285
# Verify token - support both hashed (new) and plaintext (legacy)
@@ -249,6 +296,7 @@ async def edit_paste(self, paste_id: UUID4, edit_paste: EditPaste, edit_token: s
249296
self.logger.info("Upgraded edit token to hashed format for paste %s", paste_id)
250297

251298
if not token_valid:
299+
paste_operations.labels(operation="edit", status="unauthorized").inc()
252300
return None
253301

254302
# Update only the fields that are provided (not None)
@@ -290,6 +338,7 @@ async def edit_paste(self, paste_id: UUID4, edit_paste: EditPaste, edit_token: s
290338
)
291339
)
292340

341+
paste_operations.labels(operation="edit", status="success").inc()
293342
return PasteResponse(
294343
id=result.id,
295344
title=result.title,
@@ -316,6 +365,7 @@ async def delete_paste(self, paste_id: UUID4, delete_token: str) -> bool:
316365
result: PasteEntity | None = (await session.execute(stmt)).scalar_one_or_none()
317366

318367
if result is None:
368+
paste_operations.labels(operation="delete", status="not_found").inc()
319369
return False
320370

321371
# Verify token - support both hashed (new) and plaintext (legacy)
@@ -329,6 +379,7 @@ async def delete_paste(self, paste_id: UUID4, delete_token: str) -> bool:
329379
# No need to upgrade here since we're deleting anyway
330380

331381
if not token_valid:
382+
paste_operations.labels(operation="delete", status="unauthorized").inc()
332383
return False
333384

334385
# Remove file
@@ -340,10 +391,15 @@ async def delete_paste(self, paste_id: UUID4, delete_token: str) -> bool:
340391
# Delete from database
341392
await session.delete(result)
342393
await session.commit()
394+
paste_operations.labels(operation="delete", status="success").inc()
395+
counter = get_active_pastes_counter()
396+
if counter:
397+
counter.dec()
343398
return True
344399

345400
async def create_paste(self, paste: CreatePaste, user_data: UserMetaData) -> PasteResponse:
346401
if not self.verify_storage_limit():
402+
paste_operations.labels(operation="create", status="storage_limit").inc()
347403
raise HTTPException(
348404
status_code=500,
349405
detail="Storage limit reached, contact administration",
@@ -355,6 +411,7 @@ async def create_paste(self, paste: CreatePaste, user_data: UserMetaData) -> Pas
355411
paste.content,
356412
)
357413
if not save_result:
414+
paste_operations.labels(operation="create", status="error").inc()
358415
raise HTTPException(
359416
status_code=500,
360417
detail="Failed to save paste content",
@@ -391,6 +448,15 @@ async def create_paste(self, paste: CreatePaste, user_data: UserMetaData) -> Pas
391448
await session.commit()
392449
await session.refresh(entity)
393450

451+
# Record metrics on successful create
452+
paste_operations.labels(operation="create", status="success").inc()
453+
paste_size.observe(original_size if original_size else content_size)
454+
counter = get_active_pastes_counter()
455+
if counter:
456+
counter.inc()
457+
if is_compressed:
458+
compressed_pastes.inc()
459+
394460
return CreatePasteResponse(
395461
id=entity.id,
396462
title=entity.title,
@@ -405,6 +471,7 @@ async def create_paste(self, paste: CreatePaste, user_data: UserMetaData) -> Pas
405471
except Exception as exc:
406472
self.logger.error("Failed to create paste: %s", exc)
407473
await self._remove_file(paste_path)
474+
paste_operations.labels(operation="create", status="error").inc()
408475
raise HTTPException(
409476
status_code=500,
410477
detail="Failed to create paste",

0 commit comments

Comments
 (0)