RamshaAnwar commited on
Commit
0358051
·
unverified ·
1 Parent(s): 49206fb

feat(obs): add /metrics endpoint and structured ai_call logging (#17)

Browse files

* ci: run workflow only on PRs and skip eval on drafts

* feat(ai): track input/output tokens in ai_call logging

* feat(api): wrap hub routes with observability logger

* feat(obs): expose /metrics endpoint via transparent-fastapi

* test: add metrics test and update ai mocks for token counts

.github/workflows/ci.yml CHANGED
@@ -1,6 +1,14 @@
1
  name: ci
2
 
3
- on: [push, pull_request]
 
 
 
 
 
 
 
 
4
 
5
  jobs:
6
  test:
@@ -42,6 +50,8 @@ jobs:
42
  eval:
43
  runs-on: ubuntu-latest
44
  needs: test
 
 
45
  services:
46
  postgres:
47
  image: pgvector/pgvector:pg16
 
1
  name: ci
2
 
3
+ # ONLY run on pull requests targeting the master branch
4
+ on:
5
+ pull_request:
6
+ branches: [ master ]
7
+
8
+ # Cancel in-progress runs if a new commit is pushed to the same PR
9
+ concurrency:
10
+ group: ${{ github.workflow }}-${{ github.ref }}
11
+ cancel-in-progress: true
12
 
13
  jobs:
14
  test:
 
50
  eval:
51
  runs-on: ubuntu-latest
52
  needs: test
53
+ # ONLY run the eval job if it is a PR and it's NOT a draft
54
+ if: github.event.pull_request.draft == false
55
  services:
56
  postgres:
57
  image: pgvector/pgvector:pg16
app/main.py CHANGED
@@ -1,13 +1,18 @@
1
  import uuid
2
  import logging
3
  from fastapi import FastAPI, Request, Depends
 
4
  from fastapi.exceptions import RequestValidationError
5
  from starlette.exceptions import HTTPException as StarletteHTTPException
6
- from app.errors import AppError, error_response
 
 
7
  from app.routers import auth, hubs, documents
 
8
  from app.deps import get_current_user
9
  from app.models import User
10
  from app.services.groq_service import AiUnavailable
 
11
  logger = logging.getLogger("uvicorn.error")
12
 
13
  def create_app() -> FastAPI:
@@ -17,6 +22,17 @@ def create_app() -> FastAPI:
17
  docs_url="/docs",
18
  redoc_url=None,
19
  )
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  app.include_router(auth.router, prefix="/api/v1")
22
  app.include_router(hubs.router, prefix="/api/v1")
 
1
  import uuid
2
  import logging
3
  from fastapi import FastAPI, Request, Depends
4
+ from fastapi.middleware.cors import CORSMiddleware
5
  from fastapi.exceptions import RequestValidationError
6
  from starlette.exceptions import HTTPException as StarletteHTTPException
7
+ from transparent_fastapi import install
8
+
9
+ from app.config import settings
10
  from app.routers import auth, hubs, documents
11
+ from app.errors import AppError, error_response
12
  from app.deps import get_current_user
13
  from app.models import User
14
  from app.services.groq_service import AiUnavailable
15
+
16
  logger = logging.getLogger("uvicorn.error")
17
 
18
  def create_app() -> FastAPI:
 
22
  docs_url="/docs",
23
  redoc_url=None,
24
  )
25
+
26
+ # Initialize transparent-fastapi for /metrics
27
+ install(app)
28
+
29
+ app.add_middleware(
30
+ CORSMiddleware,
31
+ allow_origins=settings.cors_origins_list,
32
+ allow_credentials=True,
33
+ allow_methods=["*"],
34
+ allow_headers=["*"],
35
+ )
36
 
37
  app.include_router(auth.router, prefix="/api/v1")
38
  app.include_router(hubs.router, prefix="/api/v1")
app/routers/hubs.py CHANGED
@@ -3,7 +3,10 @@ from fastapi import APIRouter, Depends, status, Query, Response
3
  from sqlalchemy.orm import Session
4
  from sqlalchemy.exc import IntegrityError
5
  from sqlalchemy import text
 
6
  from app.services.groq_service import hub_insights, answer_question, expand_query
 
 
7
 
8
  from app.db import get_db
9
  from app.deps import get_current_user
@@ -16,8 +19,6 @@ from app.idempotency import (
16
  claim_key, complete_key, release_claim, replay_or_conflict
17
  )
18
  from app.chunking import chunk_text
19
- from app.services.embeddings import embed_texts
20
- from app.services.observability import log_ai_call
21
  from app.config import settings
22
  from app.rate_limit import check_query_rate
23
 
@@ -113,6 +114,7 @@ def create_hub(
113
  try:
114
  chunks_data = chunk_text(body.content)
115
  texts = [c[0] for c in chunks_data]
 
116
  embeddings = log_ai_call("embed_texts", user_id, new_hub.id, embed_texts, texts)
117
 
118
  for i, ((text, tokens), emb) in enumerate(zip(chunks_data, embeddings)):
@@ -156,12 +158,14 @@ def query_hub(
156
  if not hub:
157
  raise AppError(404, "HUB_NOT_FOUND", "Hub not found.")
158
 
159
- alt_queries = expand_query(req.question)
 
 
160
  all_queries = [req.question] + alt_queries
161
- embeddings = embed_texts(all_queries)
162
-
163
- chunks = repo.retrieve_chunks_for_queries(db, current_user.id, hub_id, embeddings)
164
 
 
 
 
165
  raw_sql = text("""
166
  SELECT id, text, chunk_index, document_id, 1 - (embedding <=> :q) as similarity
167
  FROM chunks
@@ -187,7 +191,8 @@ def query_hub(
187
  context_texts = [row.text for row in results]
188
  context = "\n\n---\n\n".join(context_texts)
189
 
190
- ai_result = answer_question(context, req.question)
 
191
 
192
  sources = []
193
  if ai_result["evidence"]:
@@ -208,4 +213,4 @@ def query_hub(
208
  grounded=ai_result["grounded"],
209
  sources=sources,
210
  retrieved_chunks=len(results)
211
- )
 
3
  from sqlalchemy.orm import Session
4
  from sqlalchemy.exc import IntegrityError
5
  from sqlalchemy import text
6
+
7
  from app.services.groq_service import hub_insights, answer_question, expand_query
8
+ from app.services.embeddings import embed_texts
9
+ from app.services.observability import log_ai_call
10
 
11
  from app.db import get_db
12
  from app.deps import get_current_user
 
19
  claim_key, complete_key, release_claim, replay_or_conflict
20
  )
21
  from app.chunking import chunk_text
 
 
22
  from app.config import settings
23
  from app.rate_limit import check_query_rate
24
 
 
114
  try:
115
  chunks_data = chunk_text(body.content)
116
  texts = [c[0] for c in chunks_data]
117
+ # Wrap embedding call
118
  embeddings = log_ai_call("embed_texts", user_id, new_hub.id, embed_texts, texts)
119
 
120
  for i, ((text, tokens), emb) in enumerate(zip(chunks_data, embeddings)):
 
158
  if not hub:
159
  raise AppError(404, "HUB_NOT_FOUND", "Hub not found.")
160
 
161
+ # Wrap expand_query
162
+ alt_queries = log_ai_call("expand_query", current_user.id, hub_id, expand_query, req.question)
163
+
164
  all_queries = [req.question] + alt_queries
 
 
 
165
 
166
+ # Wrap embed_texts
167
+ embeddings = log_ai_call("embed_texts", current_user.id, hub_id, embed_texts, all_queries)
168
+
169
  raw_sql = text("""
170
  SELECT id, text, chunk_index, document_id, 1 - (embedding <=> :q) as similarity
171
  FROM chunks
 
191
  context_texts = [row.text for row in results]
192
  context = "\n\n---\n\n".join(context_texts)
193
 
194
+ # Wrap answer_question
195
+ ai_result = log_ai_call("answer_question", current_user.id, hub_id, answer_question, context, req.question)
196
 
197
  sources = []
198
  if ai_result["evidence"]:
 
213
  grounded=ai_result["grounded"],
214
  sources=sources,
215
  retrieved_chunks=len(results)
216
+ )
app/services/groq_service.py CHANGED
@@ -25,7 +25,7 @@ EXPAND_SYSTEM = (
25
  '{"queries": ["alternative 1", "alternative 2"]}'
26
  )
27
 
28
- def _call(system: str, user: str) -> dict:
29
  try:
30
  chat = _client.chat.completions.create(
31
  messages=[{"role": "system", "content": system}, {"role": "user", "content": user}],
@@ -34,29 +34,31 @@ def _call(system: str, user: str) -> dict:
34
  response_format={"type": "json_object"},
35
  max_tokens=1024
36
  )
37
- return json.loads(chat.choices[0].message.content)
38
  except Exception as exc:
39
  raise AiUnavailable("provider", str(exc)) from exc
40
 
41
- def hub_insights(content: str) -> tuple[str | None, list[str]]:
42
  try:
43
- d = _call(INSIGHTS_SYSTEM, f"<context>{content}</context>")
44
- return str(d.get("summary", ""))[:500], [str(q)[:200] for q in d.get("questions", [])][:3]
45
  except (AiUnavailable, KeyError, TypeError):
46
- return None, []
47
 
48
- def answer_question(content: str, question: str) -> dict:
49
- d = _call(ANSWER_SYSTEM, f"<context>{content}</context>\n<question>{question}</question>")
50
  evidence = d.get("evidence")
51
  verified = (isinstance(evidence, str) and bool(evidence.strip())
52
  and evidence.lower() in content.lower())
53
- return {"answer": str(d.get("answer", "")).strip(),
54
- "evidence": evidence if verified else None,
55
- "grounded": bool(d.get("grounded")) and verified}
 
 
56
 
57
- def expand_query(question: str) -> list[str]:
58
  try:
59
- d = _call(EXPAND_SYSTEM, question)
60
- return [str(q) for q in d.get("queries", [])][:2]
61
  except Exception:
62
- return []
 
25
  '{"queries": ["alternative 1", "alternative 2"]}'
26
  )
27
 
28
+ def _call(system: str, user: str) -> tuple[dict, int, int]:
29
  try:
30
  chat = _client.chat.completions.create(
31
  messages=[{"role": "system", "content": system}, {"role": "user", "content": user}],
 
34
  response_format={"type": "json_object"},
35
  max_tokens=1024
36
  )
37
+ return json.loads(chat.choices[0].message.content), chat.usage.prompt_tokens, chat.usage.completion_tokens
38
  except Exception as exc:
39
  raise AiUnavailable("provider", str(exc)) from exc
40
 
41
+ def hub_insights(content: str) -> tuple[tuple[str | None, list[str]], int, int]:
42
  try:
43
+ d, in_tok, out_tok = _call(INSIGHTS_SYSTEM, f"<context>{content}</context>")
44
+ return (str(d.get("summary", ""))[:500], [str(q)[:200] for q in d.get("questions", [])][:3]), in_tok, out_tok
45
  except (AiUnavailable, KeyError, TypeError):
46
+ return (None, []), 0, 0
47
 
48
+ def answer_question(content: str, question: str) -> tuple[dict, int, int]:
49
+ d, in_tok, out_tok = _call(ANSWER_SYSTEM, f"<context>{content}</context>\n<question>{question}</question>")
50
  evidence = d.get("evidence")
51
  verified = (isinstance(evidence, str) and bool(evidence.strip())
52
  and evidence.lower() in content.lower())
53
+ return {
54
+ "answer": str(d.get("answer", "")).strip(),
55
+ "evidence": evidence if verified else None,
56
+ "grounded": bool(d.get("grounded")) and verified
57
+ }, in_tok, out_tok
58
 
59
+ def expand_query(question: str) -> tuple[list[str], int, int]:
60
  try:
61
+ d, in_tok, out_tok = _call(EXPAND_SYSTEM, question)
62
+ return [str(q) for q in d.get("queries", [])][:2], in_tok, out_tok
63
  except Exception:
64
+ return [], 0, 0
app/services/observability.py CHANGED
@@ -8,15 +8,21 @@ from app.services.groq_service import AiUnavailable
8
  logger = logging.getLogger("uvicorn.error")
9
 
10
  def log_ai_call(operation: str, user_id: UUID | str, hub_id: UUID | str | None, fn: Callable, *args, **kwargs) -> Any:
11
- """Wraps an AI call, logs the duration and status, and re-raises exceptions."""
12
  start = time.monotonic()
13
  status = "success"
 
 
14
 
15
  try:
16
  result = fn(*args, **kwargs)
 
 
 
 
17
  return result
18
  except Exception as e:
19
- status = "error" if not isinstance(e, AiUnavailable) else e.kind
20
  raise e
21
  finally:
22
  duration_ms = int((time.monotonic() - start) * 1000)
@@ -27,7 +33,9 @@ def log_ai_call(operation: str, user_id: UUID | str, hub_id: UUID | str | None,
27
  "user_id": str(user_id),
28
  "hub_id": str(hub_id) if hub_id else None,
29
  "duration_ms": duration_ms,
 
 
30
  "status": status,
31
  "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
32
  }
33
- logger.info(json.dumps(log_data))
 
8
  logger = logging.getLogger("uvicorn.error")
9
 
10
  def log_ai_call(operation: str, user_id: UUID | str, hub_id: UUID | str | None, fn: Callable, *args, **kwargs) -> Any:
11
+ """Wraps an AI call, logs the duration/tokens/status, and re-raises exceptions."""
12
  start = time.monotonic()
13
  status = "success"
14
+ input_tokens = 0
15
+ output_tokens = 0
16
 
17
  try:
18
  result = fn(*args, **kwargs)
19
+ # If the wrapped function returns (data, in_tok, out_tok), unpack it
20
+ if isinstance(result, tuple) and len(result) == 3 and isinstance(result[1], int) and isinstance(result[2], int):
21
+ data, input_tokens, output_tokens = result
22
+ return data
23
  return result
24
  except Exception as e:
25
+ status = getattr(e, "kind", "error")
26
  raise e
27
  finally:
28
  duration_ms = int((time.monotonic() - start) * 1000)
 
33
  "user_id": str(user_id),
34
  "hub_id": str(hub_id) if hub_id else None,
35
  "duration_ms": duration_ms,
36
+ "input_tokens": input_tokens,
37
+ "output_tokens": output_tokens,
38
  "status": status,
39
  "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
40
  }
41
+ logger.info(json.dumps(log_data))
tests/test_ai.py CHANGED
@@ -7,14 +7,19 @@ def test_hub_insights_success():
7
  mock_res = MagicMock()
8
  mock_res.choices = [MagicMock()]
9
  mock_res.choices[0].message.content = '{"summary": "Test summary", "questions": ["Q1", "Q2"]}'
 
 
10
  mock_create.return_value = mock_res
11
- summary, questions = hub_insights("Test content")
 
12
  assert summary == "Test summary"
13
  assert questions == ["Q1", "Q2"]
 
 
14
 
15
  def test_hub_insights_failure_returns_none():
16
  with patch.object(_client.chat.completions, "create", side_effect=Exception("API Down")):
17
- summary, questions = hub_insights("Test content")
18
  assert summary is None
19
  assert questions == []
20
 
@@ -23,15 +28,33 @@ def test_answer_question_verifies_evidence():
23
  mock_res1 = MagicMock()
24
  mock_res1.choices = [MagicMock()]
25
  mock_res1.choices[0].message.content = '{"answer": "20 days", "evidence": "20 days", "grounded": true}'
 
 
26
  mock_create.return_value = mock_res1
27
- result = answer_question("Employees get 20 days of leave.", "How many days?")
 
28
  assert result["grounded"] is True
29
  assert result["evidence"] == "20 days"
30
 
31
  mock_res2 = MagicMock()
32
  mock_res2.choices = [MagicMock()]
33
  mock_res2.choices[0].message.content = '{"answer": "20 days", "evidence": "40 days", "grounded": true}'
 
 
34
  mock_create.return_value = mock_res2
35
- result = answer_question("Employees get 20 days of leave.", "How many days?")
 
36
  assert result["grounded"] is False
37
- assert result["evidence"] is None
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  mock_res = MagicMock()
8
  mock_res.choices = [MagicMock()]
9
  mock_res.choices[0].message.content = '{"summary": "Test summary", "questions": ["Q1", "Q2"]}'
10
+ mock_res.usage.prompt_tokens = 10
11
+ mock_res.usage.completion_tokens = 20
12
  mock_create.return_value = mock_res
13
+
14
+ (summary, questions), in_tok, out_tok = hub_insights("Test content")
15
  assert summary == "Test summary"
16
  assert questions == ["Q1", "Q2"]
17
+ assert in_tok == 10
18
+ assert out_tok == 20
19
 
20
  def test_hub_insights_failure_returns_none():
21
  with patch.object(_client.chat.completions, "create", side_effect=Exception("API Down")):
22
+ (summary, questions), in_tok, out_tok = hub_insights("Test content")
23
  assert summary is None
24
  assert questions == []
25
 
 
28
  mock_res1 = MagicMock()
29
  mock_res1.choices = [MagicMock()]
30
  mock_res1.choices[0].message.content = '{"answer": "20 days", "evidence": "20 days", "grounded": true}'
31
+ mock_res1.usage.prompt_tokens = 10
32
+ mock_res1.usage.completion_tokens = 20
33
  mock_create.return_value = mock_res1
34
+
35
+ result, _, _ = answer_question("Employees get 20 days of leave.", "How many days?")
36
  assert result["grounded"] is True
37
  assert result["evidence"] == "20 days"
38
 
39
  mock_res2 = MagicMock()
40
  mock_res2.choices = [MagicMock()]
41
  mock_res2.choices[0].message.content = '{"answer": "20 days", "evidence": "40 days", "grounded": true}'
42
+ mock_res2.usage.prompt_tokens = 10
43
+ mock_res2.usage.completion_tokens = 20
44
  mock_create.return_value = mock_res2
45
+
46
+ result, _, _ = answer_question("Employees get 20 days of leave.", "How many days?")
47
  assert result["grounded"] is False
48
+ assert result["evidence"] is None
49
+
50
+ def test_expand_query_success():
51
+ with patch.object(_client.chat.completions, "create") as mock_create:
52
+ mock_res = MagicMock()
53
+ mock_res.choices = [MagicMock()]
54
+ mock_res.choices[0].message.content = '{"queries": ["alt1", "alt2"]}'
55
+ mock_res.usage.prompt_tokens = 10
56
+ mock_res.usage.completion_tokens = 20
57
+ mock_create.return_value = mock_res
58
+
59
+ alternatives, _, _ = expand_query("What is the policy?")
60
+ assert alternatives == ["alt1", "alt2"]
tests/test_metrics.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ def test_metrics_endpoint_returns_prometheus_text(client):
4
+ """Verify that /metrics is public and returns valid Prometheus format."""
5
+ res = client.get("/metrics")
6
+ assert res.status_code == 200
7
+ assert "text/plain" in res.headers["content-type"]
8
+
9
+ # Prometheus format usually includes HELP and TYPE lines
10
+ content = res.text
11
+ assert "# HELP" in content or "# TYPE" in content
12
+
13
+ # It should track HTTP requests (this might require a request to happen first)
14
+ # Trigger a request to increment the counter
15
+ client.get("/healthz")
16
+
17
+ res2 = client.get("/metrics")
18
+ assert "http_requests_total" in res2.text