fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
1117 lines
48 KiB
Python
1117 lines
48 KiB
Python
"""Analytics and reporting routes."""
|
|
|
|
import datetime
|
|
|
|
from flask import current_app, jsonify, make_response, request
|
|
from flask_restx import fields, Namespace, Resource
|
|
from sqlalchemy import text as _sql_text
|
|
|
|
from application.api import api
|
|
from application.api.user.base import (
|
|
generate_date_range,
|
|
generate_hourly_range,
|
|
generate_minute_range,
|
|
)
|
|
from application.storage.db.redaction import redact_secrets
|
|
from application.storage.db.repositories.agents import AgentsRepository
|
|
from application.storage.db.repositories.token_usage import TokenUsageRepository
|
|
from application.storage.db.session import db_readonly
|
|
|
|
|
|
analytics_ns = Namespace(
|
|
"analytics", description="Analytics and reporting operations", path="/api"
|
|
)
|
|
|
|
|
|
_FILTER_BUCKETS = {
|
|
"last_hour": ("minute", "%Y-%m-%d %H:%M:00", "YYYY-MM-DD HH24:MI:00"),
|
|
"last_24_hour": ("hour", "%Y-%m-%d %H:00", "YYYY-MM-DD HH24:00"),
|
|
"last_7_days": ("day", "%Y-%m-%d", "YYYY-MM-DD"),
|
|
"last_15_days": ("day", "%Y-%m-%d", "YYYY-MM-DD"),
|
|
"last_30_days": ("day", "%Y-%m-%d", "YYYY-MM-DD"),
|
|
}
|
|
|
|
|
|
def _range_for_filter(filter_option: str):
|
|
"""Return ``(start_date, end_date, bucket_unit, pg_fmt)`` for the filter.
|
|
|
|
Returns ``None`` on invalid filter.
|
|
"""
|
|
if filter_option not in _FILTER_BUCKETS:
|
|
return None
|
|
end_date = datetime.datetime.now(datetime.timezone.utc)
|
|
bucket_unit, _py_fmt, pg_fmt = _FILTER_BUCKETS[filter_option]
|
|
|
|
if filter_option == "last_hour":
|
|
start_date = end_date - datetime.timedelta(hours=1)
|
|
elif filter_option == "last_24_hour":
|
|
start_date = end_date - datetime.timedelta(hours=24)
|
|
else:
|
|
days = {
|
|
"last_7_days": 6,
|
|
"last_15_days": 14,
|
|
"last_30_days": 29,
|
|
}[filter_option]
|
|
start_date = end_date - datetime.timedelta(days=days)
|
|
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
end_date = end_date.replace(
|
|
hour=23, minute=59, second=59, microsecond=999999
|
|
)
|
|
return start_date, end_date, bucket_unit, pg_fmt
|
|
|
|
|
|
def _intervals_for_filter(filter_option, start_date, end_date):
|
|
if filter_option == "last_hour":
|
|
return generate_minute_range(start_date, end_date)
|
|
if filter_option == "last_24_hour":
|
|
return generate_hourly_range(start_date, end_date)
|
|
return generate_date_range(start_date, end_date)
|
|
|
|
|
|
def _resolve_agent(conn, api_key_id, user_id):
|
|
"""Owner-scoped agent lookup for analytics filters.
|
|
|
|
Returns ``(agent, api_key, agent_pg_id)``. ``agent`` is ``None`` when
|
|
the id doesn't resolve to one of the caller's agents — callers must
|
|
short-circuit with an empty result, not fall back to sentinel filter
|
|
values. ``api_key`` is ``None`` (never ``""``) for key-less agents:
|
|
draft agents store ``key = ''``, and an ``''`` filter would match the
|
|
``''`` that writers like ``stack_logs`` stamp on every key-less
|
|
request — leaking rows across users. NULL matches nothing. Accepts
|
|
UUID or legacy Mongo ObjectId ids.
|
|
"""
|
|
agent = (
|
|
AgentsRepository(conn).get_any(api_key_id, user_id)
|
|
if api_key_id
|
|
else None
|
|
)
|
|
api_key = (agent or {}).get("key") or None
|
|
agent_pg_id = str(agent["id"]) if agent else None
|
|
return agent, api_key, agent_pg_id
|
|
|
|
|
|
@analytics_ns.route("/get_message_analytics")
|
|
class GetMessageAnalytics(Resource):
|
|
get_message_analytics_model = api.model(
|
|
"GetMessageAnalyticsModel",
|
|
{
|
|
"api_key_id": fields.String(required=False, description="API Key ID"),
|
|
"filter_option": fields.String(
|
|
required=False,
|
|
description="Filter option for analytics",
|
|
default="last_30_days",
|
|
enum=list(_FILTER_BUCKETS.keys()),
|
|
),
|
|
},
|
|
)
|
|
|
|
@api.expect(get_message_analytics_model)
|
|
@api.doc(description="Get message analytics based on filter option")
|
|
def post(self):
|
|
decoded_token = request.decoded_token
|
|
if not decoded_token:
|
|
return make_response(jsonify({"success": False}), 401)
|
|
user = decoded_token.get("sub")
|
|
data = request.get_json() or {}
|
|
api_key_id = data.get("api_key_id")
|
|
filter_option = data.get("filter_option", "last_30_days")
|
|
|
|
window = _range_for_filter(filter_option)
|
|
if window is None:
|
|
return make_response(
|
|
jsonify({"success": False, "message": "Invalid option"}), 400
|
|
)
|
|
start_date, end_date, _bucket_unit, pg_fmt = window
|
|
|
|
try:
|
|
with db_readonly() as conn:
|
|
agent, api_key, agent_pg_id = _resolve_agent(
|
|
conn, api_key_id, user
|
|
)
|
|
if api_key_id and agent is None:
|
|
# Unknown / not-owned agent: empty result, not a
|
|
# sentinel filter (see _resolve_agent).
|
|
intervals = _intervals_for_filter(
|
|
filter_option, start_date, end_date
|
|
)
|
|
return make_response(
|
|
jsonify(
|
|
{
|
|
"success": True,
|
|
"messages": {i: 0 for i in intervals},
|
|
}
|
|
),
|
|
200,
|
|
)
|
|
|
|
# Count messages per bucket. When filtering by agent the
|
|
# owner-scoped lookup above already gates access, so the
|
|
# user clause is dropped (matching tokens / tools / logs):
|
|
# a shared agent's conversations carry the caller's
|
|
# user_id, and the owner should see that traffic on their
|
|
# own agent's dashboard. Agent matching covers both
|
|
# shapes: external traffic stamps ``api_key``, owner /
|
|
# shared chats stamp ``agent_id``.
|
|
clauses = [
|
|
"m.timestamp >= :start",
|
|
"m.timestamp <= :end",
|
|
]
|
|
params: dict = {
|
|
"start": start_date,
|
|
"end": end_date,
|
|
"fmt": pg_fmt,
|
|
}
|
|
if api_key_id:
|
|
clauses.append(
|
|
"(c.api_key = :api_key"
|
|
" OR c.agent_id = CAST(:agent_pg_id AS uuid))"
|
|
)
|
|
params["api_key"] = api_key
|
|
params["agent_pg_id"] = agent_pg_id
|
|
else:
|
|
clauses.append("c.user_id = :user_id")
|
|
params["user_id"] = user
|
|
where = " AND ".join(clauses)
|
|
sql = (
|
|
"SELECT to_char(m.timestamp AT TIME ZONE 'UTC', :fmt) AS bucket, "
|
|
"COUNT(*) AS count "
|
|
"FROM conversation_messages m "
|
|
"JOIN conversations c ON c.id = m.conversation_id "
|
|
f"WHERE {where} "
|
|
"GROUP BY bucket ORDER BY bucket ASC"
|
|
)
|
|
rows = conn.execute(_sql_text(sql), params).fetchall()
|
|
|
|
intervals = _intervals_for_filter(filter_option, start_date, end_date)
|
|
daily_messages = {interval: 0 for interval in intervals}
|
|
for row in rows:
|
|
daily_messages[row._mapping["bucket"]] = int(row._mapping["count"])
|
|
except Exception as err:
|
|
current_app.logger.error(
|
|
f"Error getting message analytics: {err}", exc_info=True
|
|
)
|
|
return make_response(jsonify({"success": False}), 400)
|
|
return make_response(
|
|
jsonify({"success": True, "messages": daily_messages}), 200
|
|
)
|
|
|
|
|
|
@analytics_ns.route("/get_token_analytics")
|
|
class GetTokenAnalytics(Resource):
|
|
get_token_analytics_model = api.model(
|
|
"GetTokenAnalyticsModel",
|
|
{
|
|
"api_key_id": fields.String(required=False, description="API Key ID"),
|
|
"filter_option": fields.String(
|
|
required=False,
|
|
description="Filter option for analytics",
|
|
default="last_30_days",
|
|
enum=list(_FILTER_BUCKETS.keys()),
|
|
),
|
|
"group_by": fields.String(
|
|
required=False,
|
|
description="Second grouping dimension for the series",
|
|
default="none",
|
|
enum=["none", "model", "agent", "source"],
|
|
),
|
|
"include_side_channel": fields.Boolean(
|
|
required=False,
|
|
description=(
|
|
"Include non-user-initiated token usage (title "
|
|
"generation, compression, RAG condensing, fallback)"
|
|
),
|
|
default=True,
|
|
),
|
|
},
|
|
)
|
|
|
|
@api.expect(get_token_analytics_model)
|
|
@api.doc(description="Get token analytics data")
|
|
def post(self):
|
|
decoded_token = request.decoded_token
|
|
if not decoded_token:
|
|
return make_response(jsonify({"success": False}), 401)
|
|
user = decoded_token.get("sub")
|
|
data = request.get_json() or {}
|
|
api_key_id = data.get("api_key_id")
|
|
filter_option = data.get("filter_option", "last_30_days")
|
|
group_by = data.get("group_by") or "none"
|
|
# ``@api.expect`` documents but never validates/coerces — a JSON
|
|
# string like "false" must not truthy-coerce to True.
|
|
raw_side = data.get("include_side_channel", True)
|
|
if isinstance(raw_side, str):
|
|
include_side_channel = raw_side.strip().lower() not in (
|
|
"false",
|
|
"0",
|
|
"no",
|
|
)
|
|
else:
|
|
include_side_channel = bool(raw_side)
|
|
|
|
window = _range_for_filter(filter_option)
|
|
if window is None or group_by not in ("none", "model", "agent", "source"):
|
|
return make_response(
|
|
jsonify({"success": False, "message": "Invalid option"}), 400
|
|
)
|
|
start_date, end_date, bucket_unit, _pg_fmt = window
|
|
|
|
try:
|
|
with db_readonly() as conn:
|
|
agent, api_key, agent_pg_id = _resolve_agent(
|
|
conn, api_key_id, user
|
|
)
|
|
if api_key_id and agent is None:
|
|
# Unknown / not-owned agent: empty result, not a
|
|
# sentinel filter (see _resolve_agent).
|
|
rows = []
|
|
else:
|
|
# The owner-scoped lookup gates access, so the
|
|
# user_id filter is dropped when agent-filtering
|
|
# (shared-agent rows carry the caller's user_id).
|
|
# The agent match is key-OR-id: chat stamps the
|
|
# key, headless runs stamp agent_id.
|
|
rows = TokenUsageRepository(conn).bucketed_totals(
|
|
bucket_unit=bucket_unit,
|
|
user_id=None if api_key_id else user,
|
|
api_key=api_key,
|
|
agent_id=agent_pg_id,
|
|
timestamp_gte=start_date,
|
|
timestamp_lt=end_date,
|
|
group_by=None if group_by == "none" else group_by,
|
|
include_side_channel=include_side_channel,
|
|
)
|
|
|
|
intervals = _intervals_for_filter(filter_option, start_date, end_date)
|
|
daily_token_usage = {interval: 0 for interval in intervals}
|
|
# ``series`` is the multi-dataset shape the dashboard renders
|
|
# as stacked bars: {series_key: {bucket: tokens}}. Without
|
|
# grouping the two series are the prompt/generated split.
|
|
series: dict = {}
|
|
if group_by == "none":
|
|
series = {
|
|
"prompt": {interval: 0 for interval in intervals},
|
|
"generated": {interval: 0 for interval in intervals},
|
|
}
|
|
for entry in rows:
|
|
bucket = entry["bucket"]
|
|
daily_token_usage[bucket] = int(
|
|
entry["prompt_tokens"] + entry["generated_tokens"]
|
|
)
|
|
series["prompt"][bucket] = int(entry["prompt_tokens"])
|
|
series["generated"][bucket] = int(entry["generated_tokens"])
|
|
else:
|
|
for entry in rows:
|
|
bucket = entry["bucket"]
|
|
total = int(entry["prompt_tokens"] + entry["generated_tokens"])
|
|
daily_token_usage[bucket] = (
|
|
daily_token_usage.get(bucket, 0) + total
|
|
)
|
|
key = entry["group_key"]
|
|
if key not in series:
|
|
series[key] = {interval: 0 for interval in intervals}
|
|
series[key][bucket] = series[key].get(bucket, 0) + total
|
|
except Exception as err:
|
|
current_app.logger.error(
|
|
f"Error getting token analytics: {err}", exc_info=True
|
|
)
|
|
return make_response(jsonify({"success": False}), 400)
|
|
return make_response(
|
|
jsonify(
|
|
{
|
|
"success": True,
|
|
"token_usage": daily_token_usage,
|
|
"group_by": group_by,
|
|
"series": series,
|
|
}
|
|
),
|
|
200,
|
|
)
|
|
|
|
|
|
@analytics_ns.route("/get_feedback_analytics")
|
|
class GetFeedbackAnalytics(Resource):
|
|
get_feedback_analytics_model = api.model(
|
|
"GetFeedbackAnalyticsModel",
|
|
{
|
|
"api_key_id": fields.String(required=False, description="API Key ID"),
|
|
"filter_option": fields.String(
|
|
required=False,
|
|
description="Filter option for analytics",
|
|
default="last_30_days",
|
|
enum=list(_FILTER_BUCKETS.keys()),
|
|
),
|
|
},
|
|
)
|
|
|
|
@api.expect(get_feedback_analytics_model)
|
|
@api.doc(description="Get feedback analytics data")
|
|
def post(self):
|
|
decoded_token = request.decoded_token
|
|
if not decoded_token:
|
|
return make_response(jsonify({"success": False}), 401)
|
|
user = decoded_token.get("sub")
|
|
data = request.get_json() or {}
|
|
api_key_id = data.get("api_key_id")
|
|
filter_option = data.get("filter_option", "last_30_days")
|
|
|
|
window = _range_for_filter(filter_option)
|
|
if window is None:
|
|
return make_response(
|
|
jsonify({"success": False, "message": "Invalid option"}), 400
|
|
)
|
|
start_date, end_date, _bucket_unit, pg_fmt = window
|
|
|
|
try:
|
|
with db_readonly() as conn:
|
|
agent, api_key, agent_pg_id = _resolve_agent(
|
|
conn, api_key_id, user
|
|
)
|
|
if api_key_id and agent is None:
|
|
intervals = _intervals_for_filter(
|
|
filter_option, start_date, end_date
|
|
)
|
|
return make_response(
|
|
jsonify(
|
|
{
|
|
"success": True,
|
|
"feedback": {
|
|
i: {"positive": 0, "negative": 0}
|
|
for i in intervals
|
|
},
|
|
}
|
|
),
|
|
200,
|
|
)
|
|
|
|
# Feedback lives inside the ``conversation_messages.feedback``
|
|
# JSONB as ``{"text": "like"|"dislike", "timestamp": "..."}``.
|
|
# There is no scalar ``feedback_timestamp`` column — extract
|
|
# the timestamp from the JSONB and cast it to timestamptz for
|
|
# the range filter + bucket grouping.
|
|
clauses = [
|
|
"m.feedback IS NOT NULL",
|
|
"(m.feedback->>'timestamp')::timestamptz >= :start",
|
|
"(m.feedback->>'timestamp')::timestamptz <= :end",
|
|
]
|
|
params: dict = {
|
|
"start": start_date,
|
|
"end": end_date,
|
|
"fmt": pg_fmt,
|
|
}
|
|
if api_key_id:
|
|
# Owner-gated agent match (see GetMessageAnalytics):
|
|
# drop the user clause so shared-agent feedback is
|
|
# visible to the owner, consistent with the other
|
|
# per-agent charts.
|
|
clauses.append(
|
|
"(c.api_key = :api_key"
|
|
" OR c.agent_id = CAST(:agent_pg_id AS uuid))"
|
|
)
|
|
params["api_key"] = api_key
|
|
params["agent_pg_id"] = agent_pg_id
|
|
else:
|
|
clauses.append("c.user_id = :user_id")
|
|
params["user_id"] = user
|
|
where = " AND ".join(clauses)
|
|
sql = (
|
|
"SELECT to_char("
|
|
"(m.feedback->>'timestamp')::timestamptz AT TIME ZONE 'UTC', :fmt"
|
|
") AS bucket, "
|
|
"SUM(CASE WHEN m.feedback->>'text' = 'like' THEN 1 ELSE 0 END) AS positive, "
|
|
"SUM(CASE WHEN m.feedback->>'text' = 'dislike' THEN 1 ELSE 0 END) AS negative "
|
|
"FROM conversation_messages m "
|
|
"JOIN conversations c ON c.id = m.conversation_id "
|
|
f"WHERE {where} "
|
|
"GROUP BY bucket ORDER BY bucket ASC"
|
|
)
|
|
rows = conn.execute(_sql_text(sql), params).fetchall()
|
|
|
|
intervals = _intervals_for_filter(filter_option, start_date, end_date)
|
|
daily_feedback = {
|
|
interval: {"positive": 0, "negative": 0} for interval in intervals
|
|
}
|
|
for row in rows:
|
|
bucket = row._mapping["bucket"]
|
|
daily_feedback[bucket] = {
|
|
"positive": int(row._mapping["positive"] or 0),
|
|
"negative": int(row._mapping["negative"] or 0),
|
|
}
|
|
except Exception as err:
|
|
current_app.logger.error(
|
|
f"Error getting feedback analytics: {err}", exc_info=True
|
|
)
|
|
return make_response(jsonify({"success": False}), 400)
|
|
return make_response(
|
|
jsonify({"success": True, "feedback": daily_feedback}), 200
|
|
)
|
|
|
|
|
|
@analytics_ns.route("/get_tool_analytics")
|
|
class GetToolAnalytics(Resource):
|
|
get_tool_analytics_model = api.model(
|
|
"GetToolAnalyticsModel",
|
|
{
|
|
"api_key_id": fields.String(required=False, description="API Key ID"),
|
|
"filter_option": fields.String(
|
|
required=False,
|
|
description="Filter option for analytics",
|
|
default="last_30_days",
|
|
enum=list(_FILTER_BUCKETS.keys()),
|
|
),
|
|
},
|
|
)
|
|
|
|
@api.expect(get_tool_analytics_model)
|
|
@api.doc(description="Get tool call analytics from the tool execution journal")
|
|
def post(self):
|
|
decoded_token = request.decoded_token
|
|
if not decoded_token:
|
|
return make_response(jsonify({"success": False}), 401)
|
|
user = decoded_token.get("sub")
|
|
data = request.get_json() or {}
|
|
api_key_id = data.get("api_key_id")
|
|
filter_option = data.get("filter_option", "last_30_days")
|
|
|
|
window = _range_for_filter(filter_option)
|
|
if window is None:
|
|
return make_response(
|
|
jsonify({"success": False, "message": "Invalid option"}), 400
|
|
)
|
|
start_date, end_date, _bucket_unit, _pg_fmt = window
|
|
|
|
try:
|
|
with db_readonly() as conn:
|
|
agent, api_key, agent_pg_id = _resolve_agent(
|
|
conn, api_key_id, user
|
|
)
|
|
if api_key_id and agent is None:
|
|
return make_response(
|
|
jsonify({"success": True, "tools": []}), 200
|
|
)
|
|
|
|
# Terminal rows only. ``proposed`` (pending) and
|
|
# ``executed`` (ran, not yet finalized) are non-terminal:
|
|
# counting them inflates ``calls`` and — since the client
|
|
# computes successful = calls - failures — renders them as
|
|
# phantom successes that later flip to failures when the
|
|
# reconciler escalates a stuck row. ``confirmed`` is the
|
|
# only success state; ``failed`` the only failure.
|
|
clauses = [
|
|
"t.status IN ('confirmed', 'failed')",
|
|
"t.attempted_at >= :start",
|
|
"t.attempted_at <= :end",
|
|
]
|
|
params: dict = {
|
|
"start": start_date,
|
|
"end": end_date,
|
|
}
|
|
join = (
|
|
"LEFT JOIN conversation_messages m ON m.id = t.message_id "
|
|
"LEFT JOIN conversations c ON c.id = m.conversation_id "
|
|
)
|
|
if api_key_id:
|
|
# Match by direct agent stamp (headless), the
|
|
# conversation's api_key (external chat), or the
|
|
# conversation's agent_id (owner chats / pre-0018
|
|
# rows). The owner-scoped lookup gates access, so
|
|
# no user clause — the owner also sees shared-agent
|
|
# traffic logged under callers' user_ids.
|
|
clauses.append(
|
|
"(t.agent_id = CAST(:agent_pg_id AS uuid)"
|
|
" OR c.api_key = :api_key"
|
|
" OR c.agent_id = CAST(:agent_pg_id AS uuid))"
|
|
)
|
|
params["agent_pg_id"] = agent_pg_id
|
|
params["api_key"] = api_key
|
|
else:
|
|
# ``t.user_id`` is stamped at propose time (0018);
|
|
# pre-migration rows fall back to the parent
|
|
# message's user (LEFT join — headless runs have no
|
|
# message). OR rather than COALESCE keeps the first
|
|
# arm index-sargable.
|
|
clauses.append(
|
|
"(t.user_id = :user_id OR m.user_id = :user_id)"
|
|
)
|
|
params["user_id"] = user
|
|
where = " AND ".join(clauses)
|
|
sql = (
|
|
"SELECT t.tool_name, "
|
|
"COUNT(*) AS calls, "
|
|
"COUNT(*) FILTER (WHERE t.status = 'failed') AS failures "
|
|
"FROM tool_call_attempts t "
|
|
f"{join}"
|
|
f"WHERE {where} "
|
|
"GROUP BY t.tool_name "
|
|
"ORDER BY calls DESC"
|
|
)
|
|
rows = conn.execute(_sql_text(sql), params).fetchall()
|
|
|
|
tools = [
|
|
{
|
|
"tool_name": row._mapping["tool_name"],
|
|
"calls": int(row._mapping["calls"]),
|
|
"failures": int(row._mapping["failures"]),
|
|
}
|
|
for row in rows
|
|
]
|
|
except Exception as err:
|
|
current_app.logger.error(
|
|
f"Error getting tool analytics: {err}", exc_info=True
|
|
)
|
|
return make_response(jsonify({"success": False}), 400)
|
|
return make_response(jsonify({"success": True, "tools": tools}), 200)
|
|
|
|
|
|
@analytics_ns.route("/get_schedule_analytics")
|
|
class GetScheduleAnalytics(Resource):
|
|
get_schedule_analytics_model = api.model(
|
|
"GetScheduleAnalyticsModel",
|
|
{
|
|
"api_key_id": fields.String(required=False, description="API Key ID"),
|
|
"filter_option": fields.String(
|
|
required=False,
|
|
description="Filter option for analytics",
|
|
default="last_30_days",
|
|
enum=list(_FILTER_BUCKETS.keys()),
|
|
),
|
|
},
|
|
)
|
|
|
|
@api.expect(get_schedule_analytics_model)
|
|
@api.doc(description="Get scheduled agent run outcomes over time")
|
|
def post(self):
|
|
decoded_token = request.decoded_token
|
|
if not decoded_token:
|
|
return make_response(jsonify({"success": False}), 401)
|
|
user = decoded_token.get("sub")
|
|
data = request.get_json() or {}
|
|
api_key_id = data.get("api_key_id")
|
|
filter_option = data.get("filter_option", "last_30_days")
|
|
|
|
window = _range_for_filter(filter_option)
|
|
if window is None:
|
|
return make_response(
|
|
jsonify({"success": False, "message": "Invalid option"}), 400
|
|
)
|
|
start_date, end_date, _bucket_unit, pg_fmt = window
|
|
|
|
try:
|
|
with db_readonly() as conn:
|
|
agent, _api_key, agent_pg_id = _resolve_agent(
|
|
conn, api_key_id, user
|
|
)
|
|
if api_key_id and agent is None:
|
|
intervals = _intervals_for_filter(
|
|
filter_option, start_date, end_date
|
|
)
|
|
return make_response(
|
|
jsonify(
|
|
{
|
|
"success": True,
|
|
"runs": {
|
|
i: {
|
|
"completed": 0,
|
|
"failed": 0,
|
|
"skipped": 0,
|
|
}
|
|
for i in intervals
|
|
},
|
|
}
|
|
),
|
|
200,
|
|
)
|
|
|
|
# A run's effective time is when it finished (fell back to
|
|
# started/scheduled for runs that never got that far).
|
|
ts = "COALESCE(r.finished_at, r.started_at, r.scheduled_for)"
|
|
clauses = [
|
|
f"{ts} >= :start",
|
|
f"{ts} <= :end",
|
|
]
|
|
params: dict = {
|
|
"start": start_date,
|
|
"end": end_date,
|
|
"fmt": pg_fmt,
|
|
}
|
|
if api_key_id:
|
|
# Owner-gated agent match: drop the user clause so a
|
|
# shared agent's runs (created by callers under their
|
|
# own user_id via the scheduler tool) are visible to
|
|
# the owner, consistent with the per-agent timeline.
|
|
clauses.append("r.agent_id = CAST(:agent_id AS uuid)")
|
|
params["agent_id"] = agent_pg_id
|
|
else:
|
|
clauses.append("r.user_id = :user_id")
|
|
params["user_id"] = user
|
|
where = " AND ".join(clauses)
|
|
# The worker writes ``success`` / ``failed`` / ``timeout`` /
|
|
# ``skipped`` (scheduler_worker.py); ``completed`` is kept in
|
|
# the success bucket defensively. Timeouts count as failures.
|
|
sql = (
|
|
f"SELECT to_char({ts} AT TIME ZONE 'UTC', :fmt) AS bucket, "
|
|
"COUNT(*) FILTER (WHERE r.status IN ('success', 'completed')) AS completed, "
|
|
"COUNT(*) FILTER (WHERE r.status IN ('failed', 'timeout')) AS failed, "
|
|
"COUNT(*) FILTER (WHERE r.status = 'skipped') AS skipped "
|
|
"FROM schedule_runs r "
|
|
f"WHERE {where} "
|
|
"GROUP BY bucket ORDER BY bucket ASC"
|
|
)
|
|
rows = conn.execute(_sql_text(sql), params).fetchall()
|
|
|
|
intervals = _intervals_for_filter(filter_option, start_date, end_date)
|
|
runs = {
|
|
interval: {"completed": 0, "failed": 0, "skipped": 0}
|
|
for interval in intervals
|
|
}
|
|
for row in rows:
|
|
runs[row._mapping["bucket"]] = {
|
|
"completed": int(row._mapping["completed"] or 0),
|
|
"failed": int(row._mapping["failed"] or 0),
|
|
"skipped": int(row._mapping["skipped"] or 0),
|
|
}
|
|
except Exception as err:
|
|
current_app.logger.error(
|
|
f"Error getting schedule analytics: {err}", exc_info=True
|
|
)
|
|
return make_response(jsonify({"success": False}), 400)
|
|
return make_response(jsonify({"success": True, "runs": runs}), 200)
|
|
|
|
|
|
@analytics_ns.route("/get_user_logs")
|
|
class GetUserLogs(Resource):
|
|
get_user_logs_model = api.model(
|
|
"GetUserLogsModel",
|
|
{
|
|
"page": fields.Integer(
|
|
required=False,
|
|
description="Page number for pagination",
|
|
default=1,
|
|
),
|
|
"api_key_id": fields.String(required=False, description="API Key ID"),
|
|
"page_size": fields.Integer(
|
|
required=False,
|
|
description="Number of logs per page",
|
|
default=10,
|
|
),
|
|
"level": fields.String(
|
|
required=False,
|
|
description="Filter by log level",
|
|
enum=["info", "error", "warning"],
|
|
),
|
|
"event_type": fields.String(
|
|
required=False,
|
|
description="Filter by event source",
|
|
enum=["chat", "schedule", "webhook", "workflow", "system"],
|
|
),
|
|
"search": fields.String(
|
|
required=False, description="Substring filter on the summary"
|
|
),
|
|
},
|
|
)
|
|
|
|
@api.expect(get_user_logs_model)
|
|
@api.doc(
|
|
description=(
|
|
"Get user activity logs with pagination. Merges chat answers "
|
|
"(user_logs), request errors (stack_logs) and scheduled agent "
|
|
"runs (schedule_runs) into one timeline."
|
|
)
|
|
)
|
|
def post(self):
|
|
decoded_token = request.decoded_token
|
|
if not decoded_token:
|
|
return make_response(jsonify({"success": False}), 401)
|
|
user = decoded_token.get("sub")
|
|
data = request.get_json() or {}
|
|
try:
|
|
page = max(1, int(data.get("page") or 1))
|
|
page_size = max(1, min(100, int(data.get("page_size") or 10)))
|
|
except (TypeError, ValueError):
|
|
return make_response(
|
|
jsonify({"success": False, "message": "Invalid option"}), 400
|
|
)
|
|
api_key_id = data.get("api_key_id")
|
|
level = data.get("level")
|
|
event_type = data.get("event_type")
|
|
search = data.get("search")
|
|
if level not in (None, "info", "error", "warning") or event_type not in (
|
|
None,
|
|
"chat",
|
|
"schedule",
|
|
"webhook",
|
|
"workflow",
|
|
"system",
|
|
):
|
|
return make_response(
|
|
jsonify({"success": False, "message": "Invalid option"}), 400
|
|
)
|
|
|
|
try:
|
|
with db_readonly() as conn:
|
|
agent, api_key, agent_pg_id = _resolve_agent(
|
|
conn, api_key_id, user
|
|
)
|
|
if api_key_id and agent is None:
|
|
# Unknown / not-owned agent: empty page, not a
|
|
# sentinel filter (see _resolve_agent).
|
|
return make_response(
|
|
jsonify(
|
|
{
|
|
"success": True,
|
|
"logs": [],
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"has_more": False,
|
|
}
|
|
),
|
|
200,
|
|
)
|
|
params: dict = {
|
|
"user_id": user,
|
|
"limit": page_size + 1,
|
|
"offset": (page - 1) * page_size,
|
|
}
|
|
|
|
# ``schedule`` / ``webhook`` errors are first-class events in
|
|
# their own branches; keep them out of ``system`` so a failed
|
|
# run doesn't appear twice. A failed webhook activity writes
|
|
# both an error row and an info row for the same activity_id
|
|
# (logging.py:_consume_and_log); NOT-EXISTS drops the info twin.
|
|
webhook_dedupe = (
|
|
"NOT (s.level = 'info' AND EXISTS ("
|
|
"SELECT 1 FROM stack_logs e "
|
|
"WHERE e.activity_id = s.activity_id "
|
|
"AND e.level = 'error'))"
|
|
)
|
|
if api_key_id:
|
|
# The owner-scoped lookup gates access, so the
|
|
# chat/webhook/system branches match on the agent
|
|
# key/id alone — the owner also sees shared-agent
|
|
# traffic logged under callers' user_ids. The
|
|
# agent_id arm covers key-less (draft) agents,
|
|
# whose owner chats log a null api_key.
|
|
params["api_key"] = api_key
|
|
params["agent_pg_id"] = agent_pg_id
|
|
params["agent_workflow_id"] = (
|
|
str(agent["workflow_id"])
|
|
if agent and agent.get("workflow_id")
|
|
else None
|
|
)
|
|
chat_where = [
|
|
"(l.data->>'api_key' = :api_key"
|
|
" OR l.data->>'agent_id' = :agent_pg_id)"
|
|
]
|
|
webhook_where = [
|
|
"COALESCE(s.endpoint, '') = 'webhook'",
|
|
"s.api_key = :api_key",
|
|
webhook_dedupe,
|
|
]
|
|
system_where = [
|
|
"s.level = 'error'",
|
|
"COALESCE(s.endpoint, '') NOT IN ('webhook', 'schedule')",
|
|
"s.api_key = :api_key",
|
|
]
|
|
# Owner-gated agent match: drop the user clause so a
|
|
# shared agent's runs (stamped with the caller's
|
|
# user_id by the scheduler tool) appear on the owner's
|
|
# per-agent timeline, consistent with the chat branch.
|
|
schedule_where = [
|
|
"r.status IN ('success', 'completed', 'failed', 'timeout', 'skipped')",
|
|
"r.agent_id = CAST(:agent_pg_id AS uuid)",
|
|
]
|
|
workflow_where = [
|
|
"wr.user_id = :user_id",
|
|
"wr.workflow_id = CAST(:agent_workflow_id AS uuid)",
|
|
]
|
|
else:
|
|
chat_where = ["l.user_id = :user_id"]
|
|
webhook_where = [
|
|
"s.user_id = :user_id",
|
|
"COALESCE(s.endpoint, '') = 'webhook'",
|
|
webhook_dedupe,
|
|
]
|
|
system_where = [
|
|
"s.user_id = :user_id",
|
|
"s.level = 'error'",
|
|
"COALESCE(s.endpoint, '') NOT IN ('webhook', 'schedule')",
|
|
]
|
|
# Terminal statuses only (worker writes ``success`` /
|
|
# ``failed`` / ``timeout`` / ``skipped``; ``completed``
|
|
# kept defensively). Pending/running runs aren't log
|
|
# entries yet.
|
|
schedule_where = [
|
|
"r.user_id = :user_id",
|
|
"r.status IN ('success', 'completed', 'failed', 'timeout', 'skipped')",
|
|
]
|
|
workflow_where = ["wr.user_id = :user_id"]
|
|
|
|
# One normalized timeline over five event sources.
|
|
# ``payload`` carries the per-type detail; the outer query
|
|
# paginates the merged, time-ordered result. level /
|
|
# event_type / search are pushed into each branch so a
|
|
# filtered request only scans the branches it can match.
|
|
branches = [
|
|
{
|
|
"name": "chat",
|
|
"level": "COALESCE(l.data->>'level', 'info')",
|
|
"summary": "l.data->>'question'",
|
|
"where": chat_where,
|
|
"sql": """
|
|
SELECT 'chat' AS event_type,
|
|
CAST(l.id AS text) AS id,
|
|
l.user_id AS user_id,
|
|
l.timestamp AS timestamp,
|
|
{level} AS level,
|
|
COALESCE(l.data->>'action', 'stream_answer') AS action,
|
|
{summary} AS summary,
|
|
l.data AS payload
|
|
FROM user_logs l
|
|
WHERE {where}
|
|
""",
|
|
},
|
|
{
|
|
"name": "system",
|
|
"level": "'error'",
|
|
"summary": "s.query",
|
|
"where": system_where,
|
|
"sql": """
|
|
SELECT 'system' AS event_type,
|
|
CAST(s.id AS text) AS id,
|
|
s.user_id AS user_id,
|
|
s.timestamp AS timestamp,
|
|
{level} AS level,
|
|
COALESCE(s.endpoint, 'request') AS action,
|
|
{summary} AS summary,
|
|
jsonb_build_object(
|
|
'endpoint', s.endpoint,
|
|
'stacks', s.stacks
|
|
) AS payload
|
|
FROM stack_logs s
|
|
WHERE {where}
|
|
""",
|
|
},
|
|
{
|
|
"name": "webhook",
|
|
"level": "COALESCE(s.level, 'info')",
|
|
"summary": "s.query",
|
|
"where": webhook_where,
|
|
"sql": """
|
|
SELECT 'webhook' AS event_type,
|
|
CAST(s.id AS text) AS id,
|
|
s.user_id AS user_id,
|
|
s.timestamp AS timestamp,
|
|
{level} AS level,
|
|
'webhook_run' AS action,
|
|
{summary} AS summary,
|
|
jsonb_build_object(
|
|
'endpoint', s.endpoint,
|
|
'stacks', s.stacks
|
|
) AS payload
|
|
FROM stack_logs s
|
|
WHERE {where}
|
|
""",
|
|
},
|
|
{
|
|
"name": "workflow",
|
|
"level": (
|
|
"CASE WHEN wr.status = 'failed' "
|
|
"THEN 'error' ELSE 'info' END"
|
|
),
|
|
"summary": (
|
|
"COALESCE(wr.inputs->>'query', w.name, 'Workflow run')"
|
|
),
|
|
"where": workflow_where,
|
|
"sql": """
|
|
SELECT 'workflow' AS event_type,
|
|
CAST(wr.id AS text) AS id,
|
|
wr.user_id AS user_id,
|
|
COALESCE(wr.ended_at, wr.started_at) AS timestamp,
|
|
{level} AS level,
|
|
'workflow_run' AS action,
|
|
{summary} AS summary,
|
|
jsonb_build_object(
|
|
'status', wr.status,
|
|
'workflow_name', w.name,
|
|
'result', wr.result,
|
|
'steps', wr.steps,
|
|
'started_at', wr.started_at,
|
|
'finished_at', wr.ended_at
|
|
) AS payload
|
|
FROM workflow_runs wr
|
|
LEFT JOIN workflows w ON w.id = wr.workflow_id
|
|
WHERE {where}
|
|
""",
|
|
},
|
|
{
|
|
"name": "schedule",
|
|
"level": (
|
|
"CASE WHEN r.status IN ('failed', 'timeout') "
|
|
"THEN 'error' WHEN r.status = 'skipped' "
|
|
"THEN 'warning' ELSE 'info' END"
|
|
),
|
|
"summary": (
|
|
"COALESCE(sc.name, sc.instruction, 'Scheduled run')"
|
|
),
|
|
"where": schedule_where,
|
|
"sql": """
|
|
SELECT 'schedule' AS event_type,
|
|
CAST(r.id AS text) AS id,
|
|
r.user_id AS user_id,
|
|
COALESCE(r.finished_at, r.started_at, r.scheduled_for) AS timestamp,
|
|
{level} AS level,
|
|
'scheduled_run' AS action,
|
|
{summary} AS summary,
|
|
jsonb_build_object(
|
|
'status', r.status,
|
|
'trigger_source', r.trigger_source,
|
|
'schedule_name', sc.name,
|
|
'instruction', sc.instruction,
|
|
'output', r.output,
|
|
'error', r.error,
|
|
'error_type', r.error_type,
|
|
'prompt_tokens', r.prompt_tokens,
|
|
'generated_tokens', r.generated_tokens,
|
|
'conversation_id', r.conversation_id,
|
|
'scheduled_for', r.scheduled_for,
|
|
'started_at', r.started_at,
|
|
'finished_at', r.finished_at
|
|
) AS payload
|
|
FROM schedule_runs r
|
|
LEFT JOIN schedules sc ON sc.id = r.schedule_id
|
|
WHERE {where}
|
|
""",
|
|
},
|
|
]
|
|
|
|
if level:
|
|
params["level"] = level
|
|
if search:
|
|
escaped = (
|
|
search.replace("\\", "\\\\")
|
|
.replace("%", "\\%")
|
|
.replace("_", "\\_")
|
|
)
|
|
params["search"] = f"%{escaped}%"
|
|
|
|
branch_sqls = []
|
|
for branch in branches:
|
|
if event_type and branch["name"] != event_type:
|
|
continue
|
|
where = list(branch["where"])
|
|
if level:
|
|
where.append(f"{branch['level']} = :level")
|
|
if search:
|
|
where.append(
|
|
f"{branch['summary']} ILIKE :search ESCAPE '\\'"
|
|
)
|
|
branch_sqls.append(
|
|
branch["sql"].format(
|
|
level=branch["level"],
|
|
summary=branch["summary"],
|
|
where=" AND ".join(where),
|
|
)
|
|
)
|
|
|
|
# ``ev.id`` is a unique per-branch tiebreaker: equal
|
|
# timestamps (transaction-stable ``now()`` makes ties
|
|
# routine) would otherwise sort non-deterministically
|
|
# across page queries, duplicating a row on one page and
|
|
# dropping its sibling from the next under OFFSET paging.
|
|
sql = (
|
|
"SELECT * FROM ("
|
|
+ " UNION ALL ".join(branch_sqls)
|
|
+ ") ev ORDER BY ev.timestamp DESC, ev.id DESC"
|
|
" LIMIT :limit OFFSET :offset"
|
|
)
|
|
|
|
rows = conn.execute(_sql_text(sql), params).fetchall()
|
|
|
|
has_more = len(rows) > page_size
|
|
results = []
|
|
for row in rows[:page_size]:
|
|
m = row._mapping
|
|
payload = m["payload"] or {}
|
|
item = {
|
|
# Prefix with the source so ids stay unique across the
|
|
# merged tables (each has its own id sequence).
|
|
"id": f"{m['event_type']}-{m['id']}",
|
|
"event_type": m["event_type"],
|
|
"action": m["action"],
|
|
"level": m["level"],
|
|
"user": m["user_id"],
|
|
"question": m["summary"],
|
|
"timestamp": (
|
|
m["timestamp"].isoformat()
|
|
if hasattr(m["timestamp"], "isoformat")
|
|
else m["timestamp"]
|
|
),
|
|
}
|
|
if m["event_type"] == "chat":
|
|
item.update(
|
|
{
|
|
"response": payload.get("response"),
|
|
"sources": payload.get("sources"),
|
|
"tool_calls": payload.get("tool_calls"),
|
|
"agent_id": payload.get("agent_id"),
|
|
"attachments": payload.get("attachments"),
|
|
}
|
|
)
|
|
elif m["event_type"] in ("system", "webhook"):
|
|
item.update(
|
|
{
|
|
"endpoint": payload.get("endpoint"),
|
|
# Redact on the way out too: rows written
|
|
# before write-time redaction still carry the
|
|
# reflected provider/user secrets in ``stacks``.
|
|
"stacks": redact_secrets(payload.get("stacks")),
|
|
}
|
|
)
|
|
elif m["event_type"] == "workflow":
|
|
item.update(
|
|
{
|
|
"status": payload.get("status"),
|
|
"workflow_name": payload.get("workflow_name"),
|
|
"result": payload.get("result"),
|
|
"steps": payload.get("steps"),
|
|
"started_at": payload.get("started_at"),
|
|
"finished_at": payload.get("finished_at"),
|
|
}
|
|
)
|
|
else: # schedule
|
|
item.update(
|
|
{
|
|
"status": payload.get("status"),
|
|
"trigger_source": payload.get("trigger_source"),
|
|
"schedule_name": payload.get("schedule_name"),
|
|
"instruction": payload.get("instruction"),
|
|
"output": payload.get("output"),
|
|
"error": payload.get("error"),
|
|
"error_type": payload.get("error_type"),
|
|
"prompt_tokens": payload.get("prompt_tokens"),
|
|
"generated_tokens": payload.get("generated_tokens"),
|
|
"conversation_id": payload.get("conversation_id"),
|
|
"scheduled_for": payload.get("scheduled_for"),
|
|
"started_at": payload.get("started_at"),
|
|
"finished_at": payload.get("finished_at"),
|
|
}
|
|
)
|
|
results.append(item)
|
|
except Exception as err:
|
|
current_app.logger.error(
|
|
f"Error getting user logs: {err}", exc_info=True
|
|
)
|
|
return make_response(jsonify({"success": False}), 400)
|
|
|
|
return make_response(
|
|
jsonify(
|
|
{
|
|
"success": True,
|
|
"logs": results,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"has_more": has_more,
|
|
}
|
|
),
|
|
200,
|
|
)
|