-
Notifications
You must be signed in to change notification settings - Fork 133
Collect QueryWeaver errors in GenAI analytics #710
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
302ea65
3dfda2c
dc73d13
03b9f0b
09ed5c4
de1698c
6417113
d7ab4fb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| """Best-effort error reporting to the organization analytics graph.""" | ||
|
|
||
| import asyncio | ||
| import logging | ||
| from typing import Optional | ||
|
|
||
| from fastapi import Request | ||
|
|
||
| from api.config import ORGANIZATIONS_GRAPH | ||
| from api.helpers.redaction import redact_sensitive_text | ||
|
|
||
| LOGGER = logging.getLogger(__name__) | ||
| _DB_OVERRIDE = None | ||
|
|
||
|
|
||
| def _safe_message(exc: Exception) -> str: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Medium —
Fix: allow an optional scheme word in front of the secret and consume the whole remaining value for |
||
| """Redact common credential forms before persisting an exception message.""" | ||
| return redact_sensitive_text(str(exc)) | ||
|
|
||
|
|
||
| async def _write_error(request: Request, exc: Exception) -> None: | ||
| """Write one error to the organization graph.""" | ||
| if _DB_OVERRIDE is None: | ||
| # pylint: disable=import-outside-toplevel | ||
| from api.core.db_resolver import resolve_db | ||
|
|
||
| database = resolve_db() | ||
| else: | ||
| database = _DB_OVERRIDE | ||
| graph = database.select_graph(ORGANIZATIONS_GRAPH) | ||
| user_email = getattr(request.state, "user_email", None) | ||
| await graph.query( | ||
| """ | ||
| CREATE (e:Error { | ||
| source: 'queryweaver', | ||
| type: $type, | ||
| message: $message, | ||
| endpoint: $endpoint, | ||
| method: $method, | ||
| timestamp: timestamp() | ||
| }) | ||
| WITH e | ||
| OPTIONAL MATCH (u:User {email: $user_email}) | ||
| FOREACH (_ IN CASE WHEN u IS NULL THEN [] ELSE [1] END | | ||
| CREATE (u)-[:ENCOUNTERED]->(e) | ||
| ) | ||
| """, | ||
| { | ||
| "type": type(exc).__name__, | ||
| "message": _safe_message(exc), | ||
| "endpoint": request.url.path, | ||
| "method": request.method, | ||
| "user_email": user_email, | ||
| }, | ||
| ) | ||
|
|
||
|
|
||
| def report_error( | ||
| request: Request, | ||
| exc: Exception, | ||
| task_sink: Optional[set] = None, | ||
| ) -> None: | ||
| """Schedule an unhandled-error write without delaying the response.""" | ||
| task = asyncio.create_task(_write_error(request, exc)) | ||
| sink = task_sink | ||
| if sink is None: | ||
| try: | ||
| # pylint: disable=import-outside-toplevel | ||
| from api.core.pipeline import background_tasks_var | ||
|
|
||
| sink = background_tasks_var.get() | ||
| except ImportError: | ||
| sink = None | ||
|
Comment on lines
+66
to
+73
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
ast-grep outline api/core/pipeline.py --items all
rg -n -C 5 'background_tasks_var|ContextVar|\.set\(|\.reset\(' api/core/pipeline.py
rg -n -C 4 'report_error\(|background_tasks_var' api testsRepository: FalkorDB/QueryWeaver Length of output: 3005 🏁 Script executed: # Try direct file inspection if repo is available locally
cat api/analytics.py 2>/dev/null | sed -n '60,80p' || echo "File not directly accessible"
# Also try to understand Python ContextVar behavior with a simple script
python3 - <<'PYTHON'
import contextvars
# Demonstrate ContextVar.get() behavior
test_var = contextvars.ContextVar('test')
# Case 1: get() without default when unset
try:
test_var.get()
except LookupError as e:
print(f"LookupError raised when unset: {type(e).__name__}")
# Case 2: get() with default when unset
result = test_var.get(None)
print(f"get(None) returns: {result}")
# Case 3: after set
test_var.set('value')
print(f"After set, get() returns: {test_var.get()}")
PYTHONRepository: FalkorDB/QueryWeaver Length of output: 2976 Catch When Proposed fix except ImportError:
+ except (ImportError, LookupError):
sink = None🤖 Prompt for AI Agents |
||
| if sink is not None: | ||
| sink.add(task) | ||
| task.add_done_callback(sink.discard) | ||
|
|
||
| def _log_done(done: "asyncio.Task") -> None: | ||
| if done.cancelled(): | ||
| return | ||
| analytics_error = done.exception() | ||
| if analytics_error is not None: | ||
| LOGGER.error( | ||
| "Failed to report QueryWeaver error to analytics: %s", | ||
| analytics_error, | ||
| exc_info=( | ||
| type(analytics_error), | ||
| analytics_error, | ||
| analytics_error.__traceback__, | ||
| ), | ||
| ) | ||
|
|
||
| task.add_done_callback(_log_done) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| """Redact credentials from text before it is persisted or logged.""" | ||
|
|
||
| import re | ||
|
|
||
| _SENSITIVE_VALUE = re.compile( | ||
| r"""(?ix) | ||
| (?P<quote>["']?) | ||
| (?P<key>password|token|secret|api[_-]?key|authorization) | ||
| (?P=quote) | ||
| \s*[=:]\s* | ||
| (?P<value_quote>["']?) | ||
| (?:(?:bearer|basic|token)\s+)? | ||
| [^\s,;}]+ | ||
| (?P=value_quote) | ||
| """ | ||
| ) | ||
| _CONNECTION_PASSWORD = re.compile( | ||
| r"(?i)\b([a-z][a-z0-9+.\-]*://[^:@/\s]*:)[^@/\s]+@" | ||
| ) | ||
|
|
||
|
|
||
| def redact_sensitive_text(value: str, limit: int = 4000) -> str: | ||
| """Remove common credential forms and embedded URL passwords.""" | ||
| message = _SENSITIVE_VALUE.sub( | ||
| lambda match: f"{match.group('quote')}{match.group('key')}" | ||
| f"{match.group('quote')}: [REDACTED]", | ||
| value, | ||
| ) | ||
| message = _CONNECTION_PASSWORD.sub(r"\1[REDACTED]@", message) | ||
| return message[:limit] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
|
|
||
| import json | ||
| import logging | ||
| import uuid | ||
| from fastapi import APIRouter, Request, HTTPException, UploadFile, File | ||
| from fastapi.responses import JSONResponse, StreamingResponse | ||
| from pydantic import BaseModel | ||
|
|
@@ -34,7 +35,9 @@ | |
| graphs_router = APIRouter(tags=["Graphs & Databases"]) | ||
|
|
||
|
|
||
| async def _serialize_pipeline(gen, *, user_id, namespaced): | ||
| async def _serialize_pipeline( # pylint: disable=too-many-arguments | ||
| gen, *, user_id: str, namespaced: str, question: str, query_id: str, endpoint: str | ||
| ): | ||
| """Serialize pipeline events to the wire format and stop on ``_Final``. | ||
|
|
||
| Pure encoding loop — no exception handling here. Each route handler | ||
|
|
@@ -61,6 +64,10 @@ async def _serialize_pipeline(gen, *, user_id, namespaced): | |
| record_query_usage_background( | ||
| user_id, namespaced, | ||
| success=final.is_valid and final.error_message is None, | ||
| question=question, | ||
| error=final.error_message or "", | ||
| query_id=query_id, | ||
| endpoint=endpoint, | ||
| ) | ||
|
|
||
|
|
||
|
|
@@ -195,10 +202,16 @@ async def query_graph( | |
| return JSONResponse(content={"error": "Invalid query request"}, status_code=400) | ||
|
|
||
| async def stream(): | ||
| question = chat_data.chat[-1] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [major]: Guard before indexing (e.g., validate non-empty chat and return 400) or default question safely before generating |
||
| query_id = str(uuid.uuid4()) | ||
| try: | ||
| async for chunk in _serialize_pipeline( | ||
| run_query(request.state.user_id, graph_id, chat_data), | ||
| user_id=request.state.user_id, namespaced=namespaced, | ||
| user_id=request.state.user_id, | ||
| namespaced=namespaced, | ||
| question=question, | ||
| query_id=query_id, | ||
|
Comment on lines
+205
to
+213
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
ast-grep outline api/routes/graphs.py --items all --type function
ast-grep outline api/app_factory.py --items all --type function
rg -n -C 5 'query_id|request\.state|exception_handler|Error' \
api/routes/graphs.py api/app_factory.pyRepository: FalkorDB/QueryWeaver Length of output: 16613 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- api/routes/graphs.py imports and streaming handlers ---'
sed -n '1,80p;170,292p' api/routes/graphs.py
printf '%s\n' '--- api/app_factory.py middleware and exception handler ---'
sed -n '1,115p;300,370p' api/app_factory.py
printf '%s\n' '--- usage tracking implementation and call sites ---'
fd -i -t f 'usage' .
rg -n -C 8 'record_query_usage_background|query_id' api tests 2>/dev/null || true
printf '%s\n' '--- relevant tests ---'
rg -n -C 8 'query_graph|confirm_destructive_operation|StreamingResponse|handle_oauth_error|exception_handler|query_id' . -g '*test*' -g '*.py' 2>/dev/null || trueRepository: FalkorDB/QueryWeaver Length of output: 50376 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- report_error definition and callers ---'
rg -n -C 12 'def report_error|async def report_error|report_error\(' api tests -g '*.py'
printf '%s\n' '--- request-state assignments and middleware ordering ---'
rg -n -C 8 'request\.state\.[A-Za-z_]+\s*=|add_middleware|SecurityMiddleware|CSRFMiddleware|BaseHTTPMiddleware' api -g '*.py'
printf '%s\n' '--- validation and request model definitions ---'
rg -n -C 15 'class ChatRequest|class ConfirmRequest|def validate_and_truncate_chat|chat\s*:' api -g '*.py'
printf '%s\n' '--- exact relevant tests only ---'
rg -l 'query_graph|confirm_destructive_operation|StreamingResponse|report_error|request\.state' tests -g '*.py' |
while IFS= read -r file; do
echo "FILE: $file"
rg -n -C 5 'query_graph|confirm_destructive_operation|StreamingResponse|report_error|request\.state' "$file"
doneRepository: FalkorDB/QueryWeaver Length of output: 19318 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- complete error analytics implementation ---'
sed -n '1,140p' api/analytics.py
printf '%s\n' '--- complete chat validation implementation ---'
sed -n '360,430p' api/core/pipeline.py
printf '%s\n' '--- application construction and middleware context ---'
sed -n '130,285p' api/app_factory.py
printf '%s\n' '--- all analytics schema or query_id references ---'
rg -n -C 8 'Error|error_type|error_message|query_id|Unhandled' api tests -g '*.py'Repository: FalkorDB/QueryWeaver Length of output: 50377 Expose Generate 🤖 Prompt for AI Agents |
||
| endpoint=request.url.path, | ||
| ): | ||
| yield chunk | ||
| except Exception: # pylint: disable=broad-exception-caught | ||
|
|
@@ -208,7 +221,13 @@ async def stream(): | |
| # Pipeline crashed before _Final, so _serialize_pipeline didn't | ||
| # record — count this attempt as a failure here. | ||
| record_query_usage_background( | ||
| request.state.user_id, namespaced, success=False | ||
| request.state.user_id, | ||
| namespaced, | ||
| success=False, | ||
| question=question, | ||
| error="Unhandled streaming query failure", | ||
| query_id=query_id, | ||
| endpoint=request.url.path, | ||
| ) | ||
| yield json.dumps({ | ||
| "type": "error", | ||
|
|
@@ -246,18 +265,30 @@ async def confirm_destructive_operation( | |
| return JSONResponse(content={"error": "Invalid confirmation request"}, status_code=400) | ||
|
|
||
| async def stream(): | ||
| question = str(confirm_data.chat[-1]) if confirm_data.chat else "" | ||
| query_id = str(uuid.uuid4()) | ||
| try: | ||
| async for chunk in _serialize_pipeline( | ||
| run_confirmed(request.state.user_id, graph_id, confirm_data), | ||
| user_id=request.state.user_id, namespaced=namespaced, | ||
| user_id=request.state.user_id, | ||
| namespaced=namespaced, | ||
| question=question, | ||
| query_id=query_id, | ||
| endpoint=request.url.path, | ||
| ): | ||
| yield chunk | ||
| except Exception: # pylint: disable=broad-exception-caught | ||
| # See note on the query endpoint above (CodeQL). | ||
| logging.exception("Streaming confirmed-destructive query failed") | ||
| # Pipeline crashed before _Final — record the failed attempt here. | ||
| record_query_usage_background( | ||
| request.state.user_id, namespaced, success=False | ||
| request.state.user_id, | ||
| namespaced, | ||
| success=False, | ||
| question=question, | ||
| error="Unhandled confirmed-query failure", | ||
| query_id=query_id, | ||
| endpoint=request.url.path, | ||
| ) | ||
| yield json.dumps({ | ||
| "type": "error", | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -13,8 +13,10 @@ | |||||
| (``query_count``/``success_count``/``error_count``/``last_active``/ | ||||||
| ``first_query_at``) for cheap reads. | ||||||
| * A per-query ``(:UsageEvent)`` node linked ``(User)-[:PERFORMED]->`` carrying | ||||||
| ``graph_id``/``is_demo``/``success``/``timestamp`` for time-series, per-DB | ||||||
| and success-rate analytics. | ||||||
| ``query_id``/``graph_id``/``is_demo``/``success``/``question``/``error``/ | ||||||
| ``timestamp`` for time-series, per-DB, and success-rate analytics. Failed | ||||||
| executions also create ``(UsageEvent)-[:FAILED_WITH]->(Error)`` and | ||||||
| ``(User)-[:ENCOUNTERED]->(Error)`` relationships. | ||||||
|
|
||||||
| Writes never block or fail a request: they run as background tasks whose | ||||||
| exceptions are logged and swallowed, mirroring | ||||||
|
|
@@ -26,11 +28,13 @@ | |||||
| import binascii | ||||||
| import hashlib | ||||||
| import logging | ||||||
| import uuid | ||||||
| from typing import Optional | ||||||
|
|
||||||
| from api.config import ORGANIZATIONS_GRAPH | ||||||
| from api.core.db_resolver import resolve_db | ||||||
| from api.core.pipeline import background_tasks_var, is_general_graph | ||||||
| from api.helpers.redaction import redact_sensitive_text | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Lower priority — the module docstring no longer describes what is retained. (The text is on lines 15-17, which aren't in the diff.) It still documents the Fix: extend that bullet to list the new properties and the linked |
||||||
|
|
||||||
| # Single round-trip: bump the User counters/timestamps and append a UsageEvent. | ||||||
| # Uses MATCH (not MERGE) on User so an unknown email is a silent no-op rather | ||||||
|
|
@@ -40,15 +44,30 @@ | |||||
| MATCH (u:User {email: $email}) | ||||||
| SET u.query_count = coalesce(u.query_count, 0) + 1, | ||||||
| u.success_count = coalesce(u.success_count, 0) + (CASE WHEN $success THEN 1 ELSE 0 END), | ||||||
| u.error_count = coalesce(u.error_count, 0) + (CASE WHEN $success THEN 0 ELSE 1 END), | ||||||
| u.error_count = coalesce(u.error_count, 0) + (CASE WHEN $error = '' THEN 0 ELSE 1 END), | ||||||
| u.last_active = timestamp(), | ||||||
| u.first_query_at = coalesce(u.first_query_at, timestamp()) | ||||||
| CREATE (u)-[:PERFORMED]->(e:UsageEvent { | ||||||
| query_id: $query_id, | ||||||
| graph_id: $graph_id, | ||||||
| is_demo: $is_demo, | ||||||
| success: $success, | ||||||
| question: $question, | ||||||
| error: $error, | ||||||
| timestamp: timestamp() | ||||||
| }) | ||||||
| FOREACH (_ IN CASE WHEN $error = '' THEN [] ELSE [1] END | | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should be fixed before merge — this gate and The FOREACH moved to Measured by running the current Cypher against a live FalkorDB with three events (off-topic, real failure, success):
Either gate the counter the same way:
Suggested change
(that suggestion belongs on line 45 — GitHub will only let me anchor it here) or keep the current semantics and rename it to |
||||||
| CREATE (error:Error { | ||||||
| source: 'queryweaver', | ||||||
| type: 'QueryError', | ||||||
| message: $error, | ||||||
| endpoint: $endpoint, | ||||||
| method: 'POST', | ||||||
| timestamp: timestamp() | ||||||
| }) | ||||||
| CREATE (u)-[:ENCOUNTERED]->(error) | ||||||
| CREATE (e)-[:FAILED_WITH]->(error) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [major]: Failed usage writes create an Error node but never persist or reuse the same identifier from the global unhandled-error reporter, so a single failure can produce duplicate, unlinked Error nodes and prevent exact attribution. Include a shared error/query correlation key (e.g., query_id on Error) and MERGE on that key (or pass global reporter ID into usage tracking) instead of always CREATEing a new Error node. |
||||||
| ) | ||||||
| """ | ||||||
|
|
||||||
|
|
||||||
|
|
@@ -74,16 +93,30 @@ def _decode_email(user_id: str) -> Optional[str]: | |||||
| return email | ||||||
|
|
||||||
|
|
||||||
| async def _write_usage(email: str, graph_id: str, is_demo: bool, success: bool, db) -> None: | ||||||
| async def _write_usage( # pylint: disable=too-many-arguments,too-many-positional-arguments | ||||||
| email: str, | ||||||
| query_id: str, | ||||||
| graph_id: str, | ||||||
| is_demo: bool, | ||||||
| success: bool, | ||||||
| question: str, | ||||||
| error: str, | ||||||
| endpoint: str, | ||||||
| db, | ||||||
| ) -> None: | ||||||
| """Perform the single Cypher write against the Organizations graph.""" | ||||||
| organizations_graph = resolve_db(db).select_graph(ORGANIZATIONS_GRAPH) | ||||||
| await organizations_graph.query( | ||||||
| _RECORD_USAGE_CYPHER, | ||||||
| { | ||||||
| "email": email, | ||||||
| "query_id": query_id, | ||||||
| "graph_id": graph_id, | ||||||
| "is_demo": is_demo, | ||||||
| "success": success, | ||||||
| "question": question, | ||||||
| "error": error, | ||||||
| "endpoint": endpoint, | ||||||
| }, | ||||||
| ) | ||||||
| # Structured-ish log line so usage is visible to log aggregators even | ||||||
|
|
@@ -98,10 +131,14 @@ async def _write_usage(email: str, graph_id: str, is_demo: bool, success: bool, | |||||
| ) | ||||||
|
|
||||||
|
|
||||||
| def record_query_usage_background( | ||||||
| def record_query_usage_background( # pylint: disable=too-many-arguments,too-many-positional-arguments | ||||||
| user_id: str, | ||||||
| namespaced: str, | ||||||
| success: bool, | ||||||
| question: str, | ||||||
| error: str = "", | ||||||
| query_id: Optional[str] = None, | ||||||
| endpoint: str = "", | ||||||
| *, | ||||||
| db=None, | ||||||
| task_sink: Optional[set] = None, | ||||||
|
|
@@ -118,6 +155,10 @@ def record_query_usage_background( | |||||
| namespaced: The fully-namespaced graph name the query ran against; | ||||||
| already demo-aware, so it doubles as the recorded ``graph_id``. | ||||||
| success: Whether SQL execution succeeded (no execution error). | ||||||
| question: The natural-language question associated with the attempt. | ||||||
| error: The pipeline or execution error for failed attempts. | ||||||
| query_id: Request-scoped identifier attached to the UsageEvent for correlation. | ||||||
| endpoint: Route path that handled the query. | ||||||
| db: Optional FalkorDB handle; resolves to the server singleton when None. | ||||||
| task_sink: Optional set the scheduled task is added to (and auto-removed | ||||||
| from on completion) so callers can await any in-flight tracking | ||||||
|
|
@@ -131,7 +172,17 @@ def record_query_usage_background( | |||||
| sink = task_sink if task_sink is not None else background_tasks_var.get() | ||||||
|
|
||||||
| task = asyncio.create_task( | ||||||
| _write_usage(email, namespaced, is_demo, success, db) | ||||||
| _write_usage( | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [major]: Attach the discard callback immediately after task creation and independently of sink mutation safety, e.g. always add a done callback that removes from sink when sink is not None before any other logic. |
||||||
| email, | ||||||
| query_id or str(uuid.uuid4()), | ||||||
| namespaced, | ||||||
| is_demo, | ||||||
| success, | ||||||
| redact_sensitive_text(str(question)), | ||||||
| redact_sensitive_text(error), | ||||||
| endpoint, | ||||||
| db, | ||||||
|
Comment on lines
+179
to
+184
|
||||||
| ) | ||||||
| ) | ||||||
|
|
||||||
| if sink is not None: | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Low — graph name duplicated instead of imported from
api.config.This diverges from
api/config.py:20, which usesos.getenv("ORGANIZATIONS_GRAPH") or "Organizations". WithORGANIZATIONS_GRAPH=""set in the environment, usage events go toOrganizationswhile errors go toselect_graph("")— two different graphs.Fix: drop this constant and
from api.config import ORGANIZATIONS_GRAPH, as every other module does (api/routes/usage_tracking.py:31,api/routes/tokens.py:12,api/auth/user_management.py:12).