mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 20:15:26 +00:00
revamp analytics & logs — per-agent attribution, unified log timeline
- Move analytics endpoints to Postgres with agent filtering that matches both stamps (api_key for external traffic, agent_id for owner/headless) - Add tool & schedule analytics, token grouping (model/agent/source) and side-channel toggle - Merge chat/system/webhook/workflow/schedule events into one logs timeline with level/type/search filters - Stamp user/agent on tool_call_attempts at propose time (migration 0018) and backfill via parent message - Frontend: revamped Analytics charts and Logs page
This commit is contained in:
1 parent
10cc16eab0
commit
64e19f4d11
16 files changed
+1475
-234
No files matched your search
@@ -30,6 +30,9 @@ def _record_proposed(
|
||||
arguments: Any,
|
||||
*,
|
||||
tool_id: Optional[str] = None,
|
||||
message_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Insert a ``proposed`` row; swallow infra failures so tool calls
|
||||
still run when the journal is unreachable. Returns True iff the row
|
||||
@@ -43,6 +46,13 @@ def _record_proposed(
|
||||
action_name,
|
||||
arguments,
|
||||
tool_id=tool_id if tool_id and looks_like_uuid(tool_id) else None,
|
||||
message_id=message_id,
|
||||
user_id=user_id,
|
||||
agent_id=(
|
||||
str(agent_id)
|
||||
if agent_id and looks_like_uuid(str(agent_id))
|
||||
else None
|
||||
),
|
||||
)
|
||||
if not inserted:
|
||||
logger.warning(
|
||||
@@ -518,6 +528,17 @@ class ToolExecutor:
|
||||
"arguments": call_args or {},
|
||||
"result": f"Failed to parse tool call. Invalid tool name format: {llm_name}",
|
||||
}
|
||||
# Journal the malformed call so it still shows up in tool analytics.
|
||||
if _record_proposed(
|
||||
call_id,
|
||||
"unknown",
|
||||
llm_name or "unknown",
|
||||
call_args if isinstance(call_args, dict) else {},
|
||||
message_id=self.message_id,
|
||||
user_id=self.user,
|
||||
agent_id=self.agent_id,
|
||||
):
|
||||
_mark_failed(call_id, tool_call_data["result"])
|
||||
yield {"type": "tool_call", "data": {**tool_call_data, "status": "error"}}
|
||||
self.tool_calls.append(tool_call_data)
|
||||
return "Failed to parse tool call.", call_id
|
||||
@@ -541,6 +562,17 @@ class ToolExecutor:
|
||||
"arguments": call_args,
|
||||
"result": f"Tool with ID {tool_id} not found. Available tools: {list(tools_dict.keys())}",
|
||||
}
|
||||
# Journal the unresolvable call so it still shows up in tool analytics.
|
||||
if _record_proposed(
|
||||
call_id,
|
||||
"unknown",
|
||||
llm_name or "unknown",
|
||||
call_args if isinstance(call_args, dict) else {},
|
||||
message_id=self.message_id,
|
||||
user_id=self.user,
|
||||
agent_id=self.agent_id,
|
||||
):
|
||||
_mark_failed(call_id, f"Tool with ID {tool_id} not found.")
|
||||
yield {"type": "tool_call", "data": {**tool_call_data, "status": "error"}}
|
||||
self.tool_calls.append(tool_call_data)
|
||||
return f"Tool with ID {tool_id} not found.", call_id
|
||||
@@ -566,6 +598,9 @@ class ToolExecutor:
|
||||
action_name,
|
||||
call_args if isinstance(call_args, dict) else {},
|
||||
tool_id=tool_data.get("id"),
|
||||
message_id=self.message_id,
|
||||
user_id=self.user,
|
||||
agent_id=self.agent_id,
|
||||
)
|
||||
# Defensive guard: a non-dict ``call_args`` (e.g. malformed
|
||||
# JSON on the resume path) would crash the param walk below
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""0018 tool_call_attempts attribution — record user/agent on the journal.
|
||||
|
||||
Adds ``tool_call_attempts.user_id`` / ``agent_id`` so tool analytics can
|
||||
attribute attempts that never get a ``message_id`` — headless runs
|
||||
(scheduled / webhook) execute tools before any conversation message
|
||||
exists, and parse-failure rows never reach one at all. Existing rows are
|
||||
backfilled through their parent message where one exists.
|
||||
|
||||
Revision ID: 0018_tool_attempts_attribution
|
||||
Revises: 0017_oidc_scim
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0018_tool_attempts_attribution"
|
||||
down_revision: Union[str, None] = "0017_oidc_scim"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE tool_call_attempts ADD COLUMN user_id TEXT;")
|
||||
op.execute("ALTER TABLE tool_call_attempts ADD COLUMN agent_id UUID;")
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE tool_call_attempts t
|
||||
SET user_id = m.user_id,
|
||||
agent_id = c.agent_id
|
||||
FROM conversation_messages m
|
||||
LEFT JOIN conversations c ON c.id = m.conversation_id
|
||||
WHERE t.message_id = m.id
|
||||
AND t.user_id IS NULL;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX tool_call_attempts_user_ts_idx "
|
||||
"ON tool_call_attempts (user_id, attempted_at DESC) "
|
||||
"WHERE user_id IS NOT NULL;"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS tool_call_attempts_user_ts_idx;")
|
||||
op.execute("ALTER TABLE tool_call_attempts DROP COLUMN IF EXISTS agent_id;")
|
||||
op.execute("ALTER TABLE tool_call_attempts DROP COLUMN IF EXISTS user_id;")
|
||||
@@ -14,7 +14,6 @@ from application.api.user.base import (
|
||||
)
|
||||
from application.storage.db.repositories.agents import AgentsRepository
|
||||
from application.storage.db.repositories.token_usage import TokenUsageRepository
|
||||
from application.storage.db.repositories.user_logs import UserLogsRepository
|
||||
from application.storage.db.session import db_readonly
|
||||
|
||||
|
||||
@@ -68,16 +67,23 @@ def _intervals_for_filter(filter_option, start_date, end_date):
|
||||
return generate_date_range(start_date, end_date)
|
||||
|
||||
|
||||
def _resolve_api_key(conn, api_key_id, user_id):
|
||||
"""Look up the ``agents.key`` value for a given agent id.
|
||||
def _resolve_agent(conn, api_key_id, user_id):
|
||||
"""Owner-scoped agent lookup for analytics filters.
|
||||
|
||||
Scoped by ``user_id`` so an authenticated caller can't probe another
|
||||
user's agents. Accepts either UUID or legacy Mongo ObjectId shape.
|
||||
Returns ``(agent, api_key, agent_pg_id)``. ``api_key`` falls back to
|
||||
``""`` and ``agent_pg_id`` to ``None``, neither of which matches any
|
||||
row, so filtering by an unknown (or another user's) agent returns
|
||||
nothing rather than everything. Accepts UUID or legacy Mongo
|
||||
ObjectId ids.
|
||||
"""
|
||||
if not api_key_id:
|
||||
return None
|
||||
agent = AgentsRepository(conn).get_any(api_key_id, user_id)
|
||||
return (agent or {}).get("key") if agent else None
|
||||
agent = (
|
||||
AgentsRepository(conn).get_any(api_key_id, user_id)
|
||||
if api_key_id
|
||||
else None
|
||||
)
|
||||
api_key = (agent or {}).get("key") or ""
|
||||
agent_pg_id = str(agent["id"]) if agent else None
|
||||
return agent, api_key, agent_pg_id
|
||||
|
||||
|
||||
@analytics_ns.route("/get_message_analytics")
|
||||
@@ -115,12 +121,16 @@ class GetMessageAnalytics(Resource):
|
||||
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
api_key = _resolve_api_key(conn, api_key_id, user)
|
||||
_agent, api_key, agent_pg_id = _resolve_agent(
|
||||
conn, api_key_id, user
|
||||
)
|
||||
|
||||
# Count messages per bucket, filtered by the conversation's
|
||||
# owner (user_id) and optionally the agent api_key. The
|
||||
# ``user_id`` filter is always applied post-cutover to
|
||||
# prevent cross-tenant leakage on admin dashboards.
|
||||
# owner (user_id) and optionally the agent. The ``user_id``
|
||||
# filter is always applied post-cutover to prevent
|
||||
# cross-tenant leakage on admin dashboards. Agent matching
|
||||
# covers both shapes: external traffic stamps ``api_key``,
|
||||
# owner chats stamp ``agent_id``.
|
||||
clauses = [
|
||||
"c.user_id = :user_id",
|
||||
"m.timestamp >= :start",
|
||||
@@ -132,9 +142,13 @@ class GetMessageAnalytics(Resource):
|
||||
"end": end_date,
|
||||
"fmt": pg_fmt,
|
||||
}
|
||||
if api_key:
|
||||
clauses.append("c.api_key = :api_key")
|
||||
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
|
||||
where = " AND ".join(clauses)
|
||||
sql = (
|
||||
"SELECT to_char(m.timestamp AT TIME ZONE 'UTC', :fmt) AS bucket, "
|
||||
@@ -172,6 +186,20 @@ class GetTokenAnalytics(Resource):
|
||||
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,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -185,9 +213,11 @@ class GetTokenAnalytics(Resource):
|
||||
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"
|
||||
include_side_channel = bool(data.get("include_side_channel", True))
|
||||
|
||||
window = _range_for_filter(filter_option)
|
||||
if window is None:
|
||||
if window is None or group_by not in ("none", "model", "agent", "source"):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Invalid option"}), 400
|
||||
)
|
||||
@@ -195,30 +225,67 @@ class GetTokenAnalytics(Resource):
|
||||
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
api_key = _resolve_api_key(conn, api_key_id, user)
|
||||
# ``bucketed_totals`` applies user_id / api_key filters
|
||||
# directly — no need to reshape a Mongo pipeline.
|
||||
_agent, api_key, agent_pg_id = _resolve_agent(
|
||||
conn, api_key_id, user
|
||||
)
|
||||
# The owner-scoped lookup above gates access, so the
|
||||
# user_id filter is dropped when agent-filtering —
|
||||
# external API-key rows have no user_id.
|
||||
rows = TokenUsageRepository(conn).bucketed_totals(
|
||||
bucket_unit=bucket_unit,
|
||||
user_id=user,
|
||||
api_key=api_key,
|
||||
user_id=None if api_key_id else user,
|
||||
api_key=api_key if api_key_id else None,
|
||||
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}
|
||||
for entry in rows:
|
||||
daily_token_usage[entry["bucket"]] = int(
|
||||
entry["prompt_tokens"] + entry["generated_tokens"]
|
||||
)
|
||||
# ``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}), 200
|
||||
jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"token_usage": daily_token_usage,
|
||||
"group_by": group_by,
|
||||
"series": series,
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
@@ -257,7 +324,9 @@ class GetFeedbackAnalytics(Resource):
|
||||
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
api_key = _resolve_api_key(conn, api_key_id, user)
|
||||
_agent, api_key, agent_pg_id = _resolve_agent(
|
||||
conn, api_key_id, user
|
||||
)
|
||||
|
||||
# Feedback lives inside the ``conversation_messages.feedback``
|
||||
# JSONB as ``{"text": "like"|"dislike", "timestamp": "..."}``.
|
||||
@@ -276,9 +345,13 @@ class GetFeedbackAnalytics(Resource):
|
||||
"end": end_date,
|
||||
"fmt": pg_fmt,
|
||||
}
|
||||
if api_key:
|
||||
clauses.append("c.api_key = :api_key")
|
||||
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
|
||||
where = " AND ".join(clauses)
|
||||
sql = (
|
||||
"SELECT to_char("
|
||||
@@ -313,6 +386,196 @@ class GetFeedbackAnalytics(Resource):
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
)
|
||||
|
||||
# Attribution: ``t.user_id`` is stamped at propose time
|
||||
# (0018); rows from before the migration fall back to the
|
||||
# parent message's user. Headless runs (scheduled / webhook)
|
||||
# have no message, so the message join is LEFT.
|
||||
clauses = [
|
||||
"COALESCE(t.user_id, m.user_id) = :user_id",
|
||||
"t.attempted_at >= :start",
|
||||
"t.attempted_at <= :end",
|
||||
]
|
||||
params: dict = {
|
||||
"user_id": user,
|
||||
"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), by the
|
||||
# conversation's api_key (external chat), or by the
|
||||
# conversation's agent_id (owner chats / rows from
|
||||
# before 0018 stamped attempts directly).
|
||||
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
|
||||
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
|
||||
)
|
||||
|
||||
# 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 = [
|
||||
"r.user_id = :user_id",
|
||||
f"{ts} >= :start",
|
||||
f"{ts} <= :end",
|
||||
]
|
||||
params: dict = {
|
||||
"user_id": user,
|
||||
"start": start_date,
|
||||
"end": end_date,
|
||||
"fmt": pg_fmt,
|
||||
}
|
||||
if api_key_id:
|
||||
# Filtering by an agent that doesn't exist (or isn't
|
||||
# the caller's) must return nothing, not everything.
|
||||
clauses.append("r.agent_id = CAST(:agent_id AS uuid)")
|
||||
params["agent_id"] = agent_pg_id
|
||||
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(
|
||||
@@ -329,11 +592,30 @@ class GetUserLogs(Resource):
|
||||
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 logs with pagination")
|
||||
@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:
|
||||
@@ -343,80 +625,271 @@ class GetUserLogs(Resource):
|
||||
page = int(data.get("page", 1))
|
||||
api_key_id = data.get("api_key_id")
|
||||
page_size = int(data.get("page_size", 10))
|
||||
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:
|
||||
api_key = _resolve_api_key(conn, api_key_id, user)
|
||||
logs_repo = UserLogsRepository(conn)
|
||||
if api_key:
|
||||
# ``find_by_api_key`` filters on ``data->>'api_key'``
|
||||
# — the PG shape of the legacy top-level ``api_key``
|
||||
# filter. Paginate client-side using offset/limit.
|
||||
all_rows = logs_repo.find_by_api_key(api_key)
|
||||
offset = (page - 1) * page_size
|
||||
window = all_rows[offset: offset + page_size + 1]
|
||||
items = window
|
||||
else:
|
||||
items, has_more_flag = logs_repo.list_paginated(
|
||||
user_id=user,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
agent, api_key, agent_pg_id = _resolve_agent(
|
||||
conn, api_key_id, user
|
||||
)
|
||||
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.
|
||||
chat_where = ["l.user_id = :user_id"]
|
||||
webhook_where = [
|
||||
"s.user_id = :user_id",
|
||||
"COALESCE(s.endpoint, '') = 'webhook'",
|
||||
]
|
||||
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"]
|
||||
if api_key_id:
|
||||
# Filter each source by the selected agent. An unknown
|
||||
# agent (or one without a key) must match nothing. The
|
||||
# agent lookup above is already owner-scoped, so the
|
||||
# chat/webhook/system branches match on the agent key
|
||||
# alone — shared agents log external callers under the
|
||||
# caller's user_id, and the owner should still see that
|
||||
# traffic on the agent's own logs page (legacy
|
||||
# ``find_by_api_key`` behavior).
|
||||
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
|
||||
)
|
||||
# list_paginated already trims to page_size and
|
||||
# returns has_more separately.
|
||||
results = [
|
||||
{
|
||||
"id": str(item.get("id") or item.get("_id")),
|
||||
"action": (item.get("data") or {}).get("action"),
|
||||
"level": (item.get("data") or {}).get("level"),
|
||||
"user": item.get("user_id"),
|
||||
"question": (item.get("data") or {}).get("question"),
|
||||
"sources": (item.get("data") or {}).get("sources"),
|
||||
"retriever_params": (item.get("data") or {}).get(
|
||||
"retriever_params"
|
||||
),
|
||||
"timestamp": (
|
||||
item["timestamp"].isoformat()
|
||||
if hasattr(item.get("timestamp"), "isoformat")
|
||||
else item.get("timestamp")
|
||||
),
|
||||
}
|
||||
for item in items
|
||||
chat_where = ["l.data->>'api_key' = :api_key"]
|
||||
webhook_where = [
|
||||
"COALESCE(s.endpoint, '') = 'webhook'",
|
||||
"s.api_key = :api_key",
|
||||
]
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"logs": results,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"has_more": has_more_flag,
|
||||
}
|
||||
),
|
||||
200,
|
||||
system_where = [
|
||||
"s.level = 'error'",
|
||||
"COALESCE(s.endpoint, '') NOT IN ('webhook', 'schedule')",
|
||||
"s.api_key = :api_key",
|
||||
]
|
||||
schedule_where.append(
|
||||
"r.agent_id = CAST(:agent_pg_id AS uuid)"
|
||||
)
|
||||
workflow_where.append(
|
||||
"wr.workflow_id = CAST(:agent_workflow_id AS uuid)"
|
||||
)
|
||||
|
||||
has_more = len(items) > page_size
|
||||
items = items[:page_size]
|
||||
results = [
|
||||
{
|
||||
"id": str(item.get("id") or item.get("_id")),
|
||||
"action": (item.get("data") or {}).get("action"),
|
||||
"level": (item.get("data") or {}).get("level"),
|
||||
"user": item.get("user_id"),
|
||||
"question": (item.get("data") or {}).get("question"),
|
||||
"sources": (item.get("data") or {}).get("sources"),
|
||||
"retriever_params": (item.get("data") or {}).get(
|
||||
"retriever_params"
|
||||
),
|
||||
# One normalized timeline over the three event sources.
|
||||
# ``payload`` carries the per-type detail; the outer query
|
||||
# paginates the merged, time-ordered result.
|
||||
sql = f"""
|
||||
SELECT * FROM (
|
||||
SELECT 'chat' AS event_type,
|
||||
CAST(l.id AS text) AS id,
|
||||
l.user_id AS user_id,
|
||||
l.timestamp AS timestamp,
|
||||
COALESCE(l.data->>'level', 'info') AS level,
|
||||
COALESCE(l.data->>'action', 'stream_answer') AS action,
|
||||
l.data->>'question' AS summary,
|
||||
l.data AS payload
|
||||
FROM user_logs l
|
||||
WHERE {' AND '.join(chat_where)}
|
||||
UNION ALL
|
||||
SELECT 'system',
|
||||
CAST(s.id AS text),
|
||||
s.user_id,
|
||||
s.timestamp,
|
||||
COALESCE(s.level, 'error'),
|
||||
COALESCE(s.endpoint, 'request'),
|
||||
s.query,
|
||||
jsonb_build_object(
|
||||
'endpoint', s.endpoint,
|
||||
'stacks', s.stacks
|
||||
)
|
||||
FROM stack_logs s
|
||||
WHERE {' AND '.join(system_where)}
|
||||
UNION ALL
|
||||
SELECT 'webhook',
|
||||
CAST(s.id AS text),
|
||||
s.user_id,
|
||||
s.timestamp,
|
||||
COALESCE(s.level, 'info'),
|
||||
'webhook_run',
|
||||
s.query,
|
||||
jsonb_build_object(
|
||||
'endpoint', s.endpoint,
|
||||
'stacks', s.stacks
|
||||
)
|
||||
FROM stack_logs s
|
||||
WHERE {' AND '.join(webhook_where)}
|
||||
UNION ALL
|
||||
SELECT 'workflow',
|
||||
CAST(wr.id AS text),
|
||||
wr.user_id,
|
||||
COALESCE(wr.ended_at, wr.started_at),
|
||||
CASE
|
||||
WHEN wr.status = 'failed' THEN 'error'
|
||||
ELSE 'info'
|
||||
END,
|
||||
'workflow_run',
|
||||
COALESCE(wr.inputs->>'query', w.name, 'Workflow run'),
|
||||
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
|
||||
)
|
||||
FROM workflow_runs wr
|
||||
LEFT JOIN workflows w ON w.id = wr.workflow_id
|
||||
WHERE {' AND '.join(workflow_where)}
|
||||
UNION ALL
|
||||
SELECT 'schedule',
|
||||
CAST(r.id AS text),
|
||||
r.user_id,
|
||||
COALESCE(r.finished_at, r.started_at, r.scheduled_for),
|
||||
CASE
|
||||
WHEN r.status IN ('failed', 'timeout') THEN 'error'
|
||||
WHEN r.status = 'skipped' THEN 'warning'
|
||||
ELSE 'info'
|
||||
END,
|
||||
'scheduled_run',
|
||||
COALESCE(sc.name, sc.instruction, 'Scheduled run'),
|
||||
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
|
||||
)
|
||||
FROM schedule_runs r
|
||||
LEFT JOIN schedules sc ON sc.id = r.schedule_id
|
||||
WHERE {' AND '.join(schedule_where)}
|
||||
) ev
|
||||
"""
|
||||
outer = []
|
||||
if level:
|
||||
outer.append("ev.level = :level")
|
||||
params["level"] = level
|
||||
if event_type:
|
||||
outer.append("ev.event_type = :event_type")
|
||||
params["event_type"] = event_type
|
||||
if search:
|
||||
outer.append("ev.summary ILIKE :search ESCAPE '\\'")
|
||||
escaped = (
|
||||
search.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
params["search"] = f"%{escaped}%"
|
||||
if outer:
|
||||
sql += " WHERE " + " AND ".join(outer)
|
||||
sql += " ORDER BY ev.timestamp 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": (
|
||||
item["timestamp"].isoformat()
|
||||
if hasattr(item.get("timestamp"), "isoformat")
|
||||
else item.get("timestamp")
|
||||
m["timestamp"].isoformat()
|
||||
if hasattr(m["timestamp"], "isoformat")
|
||||
else m["timestamp"]
|
||||
),
|
||||
}
|
||||
for item in items
|
||||
]
|
||||
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"),
|
||||
"stacks": 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
|
||||
|
||||
@@ -956,6 +956,9 @@ class LLMHandler(ABC):
|
||||
pause_info["action_name"],
|
||||
pause_info.get("arguments") or {},
|
||||
tool_id=pause_info.get("tool_id"),
|
||||
message_id=agent.tool_executor.message_id,
|
||||
user_id=agent.tool_executor.user,
|
||||
agent_id=agent.tool_executor.agent_id,
|
||||
)
|
||||
_mark_failed(
|
||||
pause_info["call_id"],
|
||||
|
||||
@@ -508,6 +508,11 @@ tool_call_attempts_table = Table(
|
||||
ForeignKey("conversation_messages.id", ondelete="SET NULL"),
|
||||
),
|
||||
Column("tool_id", UUID(as_uuid=True)),
|
||||
# Direct attribution (0018): headless runs (scheduled / webhook) and
|
||||
# parse-failure rows never get a message_id, so user/agent are stamped
|
||||
# at propose time instead of derived through the parent message.
|
||||
Column("user_id", Text),
|
||||
Column("agent_id", UUID(as_uuid=True)),
|
||||
Column("tool_name", Text, nullable=False),
|
||||
Column("action_name", Text, nullable=False),
|
||||
Column("arguments", JSONB, nullable=False),
|
||||
|
||||
@@ -107,6 +107,29 @@ class TokenUsageRepository:
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
# Token usage written outside a user-initiated request (conversation
|
||||
# title generation, history compression, RAG question condensing,
|
||||
# provider fallback). Mirrors the exclusion list in ``count_in_range``.
|
||||
SIDE_CHANNEL_SOURCES = ("title", "compression", "rag_condense", "fallback")
|
||||
|
||||
# Run-level roll-ups that duplicate per-call rows. The scheduler worker
|
||||
# inserts one ``source='schedule'`` row summing a run's tokens, but the
|
||||
# run's individual LLM calls were already persisted as ``agent_stream``
|
||||
# rows by the usage decorators — counting both doubles scheduled spend.
|
||||
# The rollup is never used as a fallback: if a per-call insert failed
|
||||
# (logged in usage.py), that call's tokens go uncounted, the same loss
|
||||
# mode as any other traffic whose insert fails.
|
||||
ROLLUP_SOURCES = ("schedule",)
|
||||
|
||||
# Allowed ``group_by`` values → the SQL expression producing the
|
||||
# group key. ``agent`` resolves to the agent's display name so the
|
||||
# dashboard never has to map UUIDs client-side.
|
||||
_GROUP_KEY_EXPRS = {
|
||||
"model": "COALESCE(tu.model_id, 'unknown')",
|
||||
"agent": "COALESCE(a.name, 'No agent')",
|
||||
"source": "COALESCE(tu.source, 'agent_stream')",
|
||||
}
|
||||
|
||||
def bucketed_totals(
|
||||
self,
|
||||
*,
|
||||
@@ -116,6 +139,8 @@ class TokenUsageRepository:
|
||||
agent_id: Optional[str] = None,
|
||||
timestamp_gte: Optional[datetime] = None,
|
||||
timestamp_lt: Optional[datetime] = None,
|
||||
group_by: Optional[str] = None,
|
||||
include_side_channel: bool = True,
|
||||
) -> list[dict]:
|
||||
"""Sum ``prompt_tokens`` / ``generated_tokens`` bucketed by time.
|
||||
|
||||
@@ -124,6 +149,11 @@ class TokenUsageRepository:
|
||||
mirrors Mongo's output so the route layer doesn't reshape:
|
||||
``"YYYY-MM-DD HH:MM:00"`` (minute), ``"YYYY-MM-DD HH:00"``
|
||||
(hour), ``"YYYY-MM-DD"`` (day). Rows are ordered by bucket ASC.
|
||||
|
||||
``group_by`` (``"model"`` / ``"agent"`` / ``"source"``) adds a
|
||||
second grouping dimension; each returned row then carries a
|
||||
``group_key``. ``include_side_channel=False`` drops rows whose
|
||||
``source`` is a side-channel call (title generation etc.).
|
||||
"""
|
||||
formats = {
|
||||
"minute": "YYYY-MM-DD HH24:MI:00",
|
||||
@@ -132,35 +162,65 @@ class TokenUsageRepository:
|
||||
}
|
||||
if bucket_unit not in formats:
|
||||
raise ValueError(f"unsupported bucket_unit: {bucket_unit!r}")
|
||||
if group_by is not None and group_by not in self._GROUP_KEY_EXPRS:
|
||||
raise ValueError(f"unsupported group_by: {group_by!r}")
|
||||
fmt = formats[bucket_unit]
|
||||
|
||||
clauses: list[str] = []
|
||||
params: dict = {"fmt": fmt}
|
||||
if user_id is not None:
|
||||
clauses.append("user_id = :user_id")
|
||||
clauses.append("tu.user_id = :user_id")
|
||||
params["user_id"] = user_id
|
||||
# Rows stamp ``api_key`` (external traffic) or ``agent_id``
|
||||
# (owner chats / headless runs), so a per-agent filter must
|
||||
# match either shape.
|
||||
agent_clauses: list[str] = []
|
||||
if api_key is not None:
|
||||
clauses.append("api_key = :api_key")
|
||||
agent_clauses.append("tu.api_key = :api_key")
|
||||
params["api_key"] = api_key
|
||||
if agent_id is not None:
|
||||
clauses.append("agent_id = CAST(:agent_id AS uuid)")
|
||||
agent_clauses.append("tu.agent_id = CAST(:agent_id AS uuid)")
|
||||
params["agent_id"] = agent_id
|
||||
if agent_clauses:
|
||||
clauses.append(f"({' OR '.join(agent_clauses)})")
|
||||
if timestamp_gte is not None:
|
||||
clauses.append("timestamp >= :timestamp_gte")
|
||||
clauses.append("tu.timestamp >= :timestamp_gte")
|
||||
params["timestamp_gte"] = timestamp_gte
|
||||
if timestamp_lt is not None:
|
||||
clauses.append("timestamp < :timestamp_lt")
|
||||
clauses.append("tu.timestamp < :timestamp_lt")
|
||||
params["timestamp_lt"] = timestamp_lt
|
||||
excluded_sources = list(self.ROLLUP_SOURCES)
|
||||
if not include_side_channel:
|
||||
excluded_sources.extend(self.SIDE_CHANNEL_SOURCES)
|
||||
placeholders = []
|
||||
for i, src in enumerate(excluded_sources):
|
||||
key = f"excl_src_{i}"
|
||||
placeholders.append(f":{key}")
|
||||
params[key] = src
|
||||
clauses.append(
|
||||
f"COALESCE(tu.source, 'agent_stream') NOT IN ({', '.join(placeholders)})"
|
||||
)
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
|
||||
group_select = ""
|
||||
group_clause = ""
|
||||
join = ""
|
||||
if group_by is not None:
|
||||
group_select = f", {self._GROUP_KEY_EXPRS[group_by]} AS group_key"
|
||||
group_clause = ", group_key"
|
||||
if group_by == "agent":
|
||||
join = "LEFT JOIN agents a ON a.id = tu.agent_id"
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT to_char(timestamp AT TIME ZONE 'UTC', :fmt) AS bucket,
|
||||
COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
|
||||
COALESCE(SUM(generated_tokens), 0) AS generated_tokens
|
||||
FROM token_usage
|
||||
SELECT to_char(tu.timestamp AT TIME ZONE 'UTC', :fmt) AS bucket,
|
||||
COALESCE(SUM(tu.prompt_tokens), 0) AS prompt_tokens,
|
||||
COALESCE(SUM(tu.generated_tokens), 0) AS generated_tokens
|
||||
{group_select}
|
||||
FROM token_usage tu
|
||||
{join}
|
||||
{where}
|
||||
GROUP BY bucket
|
||||
GROUP BY bucket{group_clause}
|
||||
ORDER BY bucket ASC
|
||||
"""
|
||||
),
|
||||
@@ -171,6 +231,11 @@ class TokenUsageRepository:
|
||||
"bucket": row._mapping["bucket"],
|
||||
"prompt_tokens": int(row._mapping["prompt_tokens"]),
|
||||
"generated_tokens": int(row._mapping["generated_tokens"]),
|
||||
**(
|
||||
{"group_key": row._mapping["group_key"]}
|
||||
if group_by is not None
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for row in result.fetchall()
|
||||
]
|
||||
|
||||
@@ -22,6 +22,9 @@ class ToolCallAttemptsRepository:
|
||||
arguments: Any,
|
||||
*,
|
||||
tool_id: Optional[str] = None,
|
||||
message_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Insert a ``proposed`` row before the tool executes.
|
||||
|
||||
@@ -29,15 +32,22 @@ class ToolCallAttemptsRepository:
|
||||
guards against the LLM emitting a duplicate ``call_id``: the
|
||||
existing row stays put rather than a re-insert raising
|
||||
``IntegrityError``.
|
||||
|
||||
``message_id`` / ``user_id`` / ``agent_id`` attribute the attempt
|
||||
from the start — headless runs and parse-failure rows never reach
|
||||
``mark_executed``, and headless runs have no message at all.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO tool_call_attempts
|
||||
(call_id, tool_id, tool_name, action_name, arguments, status)
|
||||
(call_id, tool_id, tool_name, action_name, arguments,
|
||||
message_id, user_id, agent_id, status)
|
||||
VALUES
|
||||
(:call_id, CAST(:tool_id AS uuid), :tool_name,
|
||||
:action_name, CAST(:arguments AS jsonb), 'proposed')
|
||||
:action_name, CAST(:arguments AS jsonb),
|
||||
CAST(:message_id AS uuid), :user_id,
|
||||
CAST(:agent_id AS uuid), 'proposed')
|
||||
ON CONFLICT (call_id) DO NOTHING
|
||||
"""
|
||||
),
|
||||
@@ -47,6 +57,9 @@ class ToolCallAttemptsRepository:
|
||||
"tool_name": tool_name,
|
||||
"action_name": action_name,
|
||||
"arguments": json.dumps(arguments if arguments is not None else {}, cls=PGNativeJSONEncoder),
|
||||
"message_id": message_id,
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
Generated
+2
-4
@@ -4640,7 +4640,6 @@
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -4650,7 +4649,7 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -5961,7 +5960,6 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cytoscape": {
|
||||
@@ -12958,7 +12956,7 @@
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
||||
@@ -35,6 +35,8 @@ const endpoints = {
|
||||
MESSAGE_ANALYTICS: '/api/get_message_analytics',
|
||||
TOKEN_ANALYTICS: '/api/get_token_analytics',
|
||||
FEEDBACK_ANALYTICS: '/api/get_feedback_analytics',
|
||||
TOOL_ANALYTICS: '/api/get_tool_analytics',
|
||||
SCHEDULE_ANALYTICS: '/api/get_schedule_analytics',
|
||||
LOGS: `/api/get_user_logs`,
|
||||
MANAGE_SYNC: '/api/manage_sync',
|
||||
SYNC_SOURCE: '/api/sync_source',
|
||||
|
||||
@@ -73,6 +73,10 @@ const userService = {
|
||||
apiClient.post(endpoints.USER.TOKEN_ANALYTICS, data, token),
|
||||
getFeedbackAnalytics: (data: any, token: string | null): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.FEEDBACK_ANALYTICS, data, token),
|
||||
getToolAnalytics: (data: any, token: string | null): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.TOOL_ANALYTICS, data, token),
|
||||
getScheduleAnalytics: (data: any, token: string | null): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.SCHEDULE_ANALYTICS, data, token),
|
||||
getLogs: (data: any, token: string | null): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.LOGS, data, token),
|
||||
manageSync: (data: any, token: string | null): Promise<any> =>
|
||||
|
||||
@@ -147,15 +147,74 @@
|
||||
"filterPlaceholder": "Filter",
|
||||
"none": "None",
|
||||
"positiveFeedback": "Positive Feedback",
|
||||
"negativeFeedback": "Negative Feedback"
|
||||
"negativeFeedback": "Negative Feedback",
|
||||
"groupBy": "Group by",
|
||||
"groupOptions": {
|
||||
"total": "Total",
|
||||
"model": "By model",
|
||||
"agent": "By agent",
|
||||
"source": "By source"
|
||||
},
|
||||
"includeSideChannel": "Background tokens",
|
||||
"promptTokens": "Prompt Tokens",
|
||||
"generatedTokens": "Generated Tokens",
|
||||
"scheduledRuns": "Scheduled Runs",
|
||||
"toolUsage": "Tool Usage",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"skipped": "Skipped",
|
||||
"successful": "Successful",
|
||||
"stats": {
|
||||
"messages": "Messages",
|
||||
"tokens": "Tokens",
|
||||
"toolCalls": "Tool Calls",
|
||||
"runSuccess": "Run Success",
|
||||
"feedback": "Feedback"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"label": "Logs",
|
||||
"subtitle": "View and monitor API calls and conversation logs",
|
||||
"subtitle": "View and monitor API calls, scheduled runs and errors across your account",
|
||||
"filterByChatbot": "Filter by chatbot",
|
||||
"selectChatbot": "Select chatbot",
|
||||
"none": "None",
|
||||
"tableHeader": "API generated / chatbot conversations"
|
||||
"tableHeader": "API generated / chatbot conversations",
|
||||
"searchPlaceholder": "Search logs...",
|
||||
"noLogs": "No logs found",
|
||||
"levels": {
|
||||
"all": "All levels",
|
||||
"info": "Info",
|
||||
"error": "Error",
|
||||
"warning": "Warning"
|
||||
},
|
||||
"types": {
|
||||
"all": "All events",
|
||||
"chat": "Chat",
|
||||
"schedule": "Scheduled",
|
||||
"webhook": "Webhook",
|
||||
"workflow": "Workflow",
|
||||
"system": "System"
|
||||
},
|
||||
"detail": {
|
||||
"agent": "Agent",
|
||||
"status": "Status",
|
||||
"trigger": "Trigger",
|
||||
"errorType": "Error type",
|
||||
"tokens": "Tokens",
|
||||
"duration": "Duration",
|
||||
"conversation": "Conversation",
|
||||
"endpoint": "Endpoint",
|
||||
"instruction": "Instruction",
|
||||
"response": "Response",
|
||||
"output": "Output",
|
||||
"error": "Error",
|
||||
"toolCalls": "Tool calls",
|
||||
"sources": "Sources",
|
||||
"workflow": "Workflow",
|
||||
"activity": "Activity",
|
||||
"steps": "Steps",
|
||||
"result": "Result"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"label": "Tools",
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../components/ui/select';
|
||||
import { Switch } from '../components/ui/switch';
|
||||
import { useDarkTheme, useLoaderState } from '../hooks';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { htmlLegendPlugin } from '../utils/chartUtils';
|
||||
@@ -51,6 +52,20 @@ ChartJS.register(
|
||||
Legend,
|
||||
);
|
||||
|
||||
// Extra series colors for grouped/stacked charts. The first dataset always
|
||||
// uses the resolved `--primary` color; these follow for datasets 2..n.
|
||||
const SERIES_COLORS = [
|
||||
'#FF6384',
|
||||
'#36A2EB',
|
||||
'#FFCE56',
|
||||
'#4BC0C0',
|
||||
'#9966FF',
|
||||
'#FF9F40',
|
||||
'#2BC596',
|
||||
];
|
||||
|
||||
type TokenGroupBy = 'none' | 'model' | 'agent' | 'source';
|
||||
|
||||
type AnalyticsProps = {
|
||||
agentId?: string;
|
||||
};
|
||||
@@ -79,6 +94,21 @@ export default function Analytics({ agentId }: AnalyticsProps) {
|
||||
},
|
||||
];
|
||||
|
||||
const groupByOptions: { label: string; value: TokenGroupBy }[] = [
|
||||
{ label: t('settings.analytics.groupOptions.total'), value: 'none' },
|
||||
{ label: t('settings.analytics.groupOptions.model'), value: 'model' },
|
||||
// Grouping by agent is meaningless on a single agent's page.
|
||||
...(agentId
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: t('settings.analytics.groupOptions.agent'),
|
||||
value: 'agent' as TokenGroupBy,
|
||||
},
|
||||
]),
|
||||
{ label: t('settings.analytics.groupOptions.source'), value: 'source' },
|
||||
];
|
||||
|
||||
const [messagesData, setMessagesData] = useState<Record<
|
||||
string,
|
||||
number
|
||||
@@ -87,35 +117,39 @@ export default function Analytics({ agentId }: AnalyticsProps) {
|
||||
string,
|
||||
number
|
||||
> | null>(null);
|
||||
const [tokenSeries, setTokenSeries] = useState<Record<
|
||||
string,
|
||||
Record<string, number>
|
||||
> | null>(null);
|
||||
const [feedbackData, setFeedbackData] = useState<Record<
|
||||
string,
|
||||
{ positive: number; negative: number }
|
||||
> | null>(null);
|
||||
const [messagesFilter, setMessagesFilter] = useState<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>({
|
||||
label: t('settings.analytics.filterOptions.last30Days'),
|
||||
value: 'last_30_days',
|
||||
});
|
||||
const [tokenUsageFilter, setTokenUsageFilter] = useState<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>({
|
||||
label: t('settings.analytics.filterOptions.last30Days'),
|
||||
value: 'last_30_days',
|
||||
});
|
||||
const [feedbackFilter, setFeedbackFilter] = useState<{
|
||||
const [toolsData, setToolsData] = useState<
|
||||
{ tool_name: string; calls: number; failures: number }[] | null
|
||||
>(null);
|
||||
const [scheduleData, setScheduleData] = useState<Record<
|
||||
string,
|
||||
{ completed: number; failed: number; skipped: number }
|
||||
> | null>(null);
|
||||
|
||||
const [timeFilter, setTimeFilter] = useState<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>({
|
||||
label: t('settings.analytics.filterOptions.last30Days'),
|
||||
value: 'last_30_days',
|
||||
});
|
||||
const [tokenGroupBy, setTokenGroupBy] = useState<TokenGroupBy>('none');
|
||||
// Side-channel = tokens spent outside a user request (title generation,
|
||||
// history compression, RAG condensing).
|
||||
const [includeSideChannel, setIncludeSideChannel] = useState(false);
|
||||
|
||||
const [loadingMessages, setLoadingMessages] = useLoaderState(true);
|
||||
const [loadingTokens, setLoadingTokens] = useLoaderState(true);
|
||||
const [loadingFeedback, setLoadingFeedback] = useLoaderState(true);
|
||||
const [loadingTools, setLoadingTools] = useLoaderState(true);
|
||||
const [loadingSchedules, setLoadingSchedules] = useLoaderState(true);
|
||||
const [isDarkTheme] = useDarkTheme();
|
||||
const primaryColor = useMemo(
|
||||
() => readCssVar('--primary', '#7d54d1'),
|
||||
@@ -123,6 +157,10 @@ export default function Analytics({ agentId }: AnalyticsProps) {
|
||||
// resolved value of `--primary`; re-read whenever it flips.
|
||||
[isDarkTheme],
|
||||
);
|
||||
const seriesColor = (index: number) =>
|
||||
index === 0
|
||||
? primaryColor
|
||||
: SERIES_COLORS[(index - 1) % SERIES_COLORS.length];
|
||||
|
||||
const fetchMessagesData = async (agent_id?: string, filter?: string) => {
|
||||
setLoadingMessages(true);
|
||||
@@ -144,19 +182,27 @@ export default function Analytics({ agentId }: AnalyticsProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTokenData = async (agent_id?: string, filter?: string) => {
|
||||
const fetchTokenData = async (
|
||||
agent_id?: string,
|
||||
filter?: string,
|
||||
groupBy?: TokenGroupBy,
|
||||
sideChannel?: boolean,
|
||||
) => {
|
||||
setLoadingTokens(true);
|
||||
try {
|
||||
const response = await userService.getTokenAnalytics(
|
||||
{
|
||||
api_key_id: agent_id,
|
||||
filter_option: filter,
|
||||
group_by: groupBy,
|
||||
include_side_channel: sideChannel,
|
||||
},
|
||||
token,
|
||||
);
|
||||
if (!response.ok) throw new Error('Failed to fetch analytics data');
|
||||
const data = await response.json();
|
||||
setTokenUsageData(data.token_usage);
|
||||
setTokenSeries(data.series);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
@@ -184,28 +230,174 @@ export default function Analytics({ agentId }: AnalyticsProps) {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const id = agentId;
|
||||
const filter = messagesFilter;
|
||||
fetchMessagesData(id, filter?.value);
|
||||
}, [agentId, messagesFilter]);
|
||||
const fetchToolsData = async (agent_id?: string, filter?: string) => {
|
||||
setLoadingTools(true);
|
||||
try {
|
||||
const response = await userService.getToolAnalytics(
|
||||
{
|
||||
api_key_id: agent_id,
|
||||
filter_option: filter,
|
||||
},
|
||||
token,
|
||||
);
|
||||
if (!response.ok) throw new Error('Failed to fetch analytics data');
|
||||
const data = await response.json();
|
||||
setToolsData(data.tools);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoadingTools(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchScheduleData = async (agent_id?: string, filter?: string) => {
|
||||
setLoadingSchedules(true);
|
||||
try {
|
||||
const response = await userService.getScheduleAnalytics(
|
||||
{
|
||||
api_key_id: agent_id,
|
||||
filter_option: filter,
|
||||
},
|
||||
token,
|
||||
);
|
||||
if (!response.ok) throw new Error('Failed to fetch analytics data');
|
||||
const data = await response.json();
|
||||
setScheduleData(data.runs);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoadingSchedules(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const id = agentId;
|
||||
const filter = tokenUsageFilter;
|
||||
fetchTokenData(id, filter?.value);
|
||||
}, [agentId, tokenUsageFilter]);
|
||||
fetchMessagesData(agentId, timeFilter.value);
|
||||
fetchFeedbackData(agentId, timeFilter.value);
|
||||
fetchToolsData(agentId, timeFilter.value);
|
||||
fetchScheduleData(agentId, timeFilter.value);
|
||||
}, [agentId, timeFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = agentId;
|
||||
const filter = feedbackFilter;
|
||||
fetchFeedbackData(id, filter?.value);
|
||||
}, [agentId, feedbackFilter]);
|
||||
fetchTokenData(agentId, timeFilter.value, tokenGroupBy, includeSideChannel);
|
||||
}, [agentId, timeFilter, tokenGroupBy, includeSideChannel]);
|
||||
|
||||
const totalMessages = Object.values(messagesData || {}).reduce(
|
||||
(sum, value) => sum + value,
|
||||
0,
|
||||
);
|
||||
const totalTokens = Object.values(tokenUsageData || {}).reduce(
|
||||
(sum, value) => sum + value,
|
||||
0,
|
||||
);
|
||||
const totalToolCalls = (toolsData || []).reduce(
|
||||
(sum, tool) => sum + tool.calls,
|
||||
0,
|
||||
);
|
||||
const feedbackTotals = Object.values(feedbackData || {}).reduce(
|
||||
(sum, value) => ({
|
||||
positive: sum.positive + value.positive,
|
||||
negative: sum.negative + value.negative,
|
||||
}),
|
||||
{ positive: 0, negative: 0 },
|
||||
);
|
||||
const scheduleTotals = Object.values(scheduleData || {}).reduce(
|
||||
(sum, value) => ({
|
||||
completed: sum.completed + value.completed,
|
||||
failed: sum.failed + value.failed,
|
||||
skipped: sum.skipped + value.skipped,
|
||||
}),
|
||||
{ completed: 0, failed: 0, skipped: 0 },
|
||||
);
|
||||
const scheduleRunsTotal = scheduleTotals.completed + scheduleTotals.failed;
|
||||
const scheduleSuccessRate =
|
||||
scheduleRunsTotal > 0
|
||||
? `${Math.round((scheduleTotals.completed / scheduleRunsTotal) * 100)}%`
|
||||
: '—';
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
label: t('settings.analytics.stats.messages'),
|
||||
value: totalMessages.toLocaleString(),
|
||||
},
|
||||
{
|
||||
label: t('settings.analytics.stats.tokens'),
|
||||
value: totalTokens.toLocaleString(),
|
||||
},
|
||||
{
|
||||
label: t('settings.analytics.stats.toolCalls'),
|
||||
value: totalToolCalls.toLocaleString(),
|
||||
},
|
||||
{
|
||||
label: t('settings.analytics.stats.runSuccess'),
|
||||
value: scheduleSuccessRate,
|
||||
},
|
||||
{
|
||||
label: t('settings.analytics.stats.feedback'),
|
||||
value: `+${feedbackTotals.positive} / -${feedbackTotals.negative}`,
|
||||
},
|
||||
];
|
||||
|
||||
const tokenSeriesEntries = Object.entries(tokenSeries || {});
|
||||
const tokenLabels = Object.keys(
|
||||
tokenSeriesEntries[0]?.[1] || tokenUsageData || {},
|
||||
).map((item) => formatDate(item));
|
||||
const tokenDatasets = tokenSeriesEntries.map(([key, series], index) => ({
|
||||
label:
|
||||
tokenGroupBy === 'none'
|
||||
? key === 'prompt'
|
||||
? t('settings.analytics.promptTokens')
|
||||
: t('settings.analytics.generatedTokens')
|
||||
: key,
|
||||
data: Object.values(series),
|
||||
backgroundColor: seriesColor(index),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<p className="text-muted-foreground mb-5 text-sm leading-6">
|
||||
{t('settings.analytics.subtitle')}
|
||||
</p>
|
||||
<div className="mb-5 flex flex-row flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-muted-foreground text-sm leading-6">
|
||||
{t('settings.analytics.subtitle')}
|
||||
</p>
|
||||
<Select
|
||||
value={timeFilter.value}
|
||||
onValueChange={(value) => {
|
||||
const opt = filterOptions.find((o) => o.value === value);
|
||||
if (opt) setTimeFilter(opt);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-[125px] rounded-3xl px-5 py-3 text-sm"
|
||||
size="lg"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t('settings.analytics.filterPlaceholder')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filterOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Summary stat cards */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-5">
|
||||
{statCards.map((card) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className="border-border dark:border-border rounded-2xl border px-6 py-5"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">{card.label}</p>
|
||||
<p className="text-foreground dark:text-foreground mt-1 text-2xl font-bold">
|
||||
{card.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Messages Analytics */}
|
||||
<div className="mt-4 flex w-full flex-col gap-3 [@media(min-width:1080px)]:flex-row">
|
||||
<div className="border-border dark:border-border h-[345px] w-full overflow-hidden rounded-2xl border px-6 py-5 [@media(min-width:1080px)]:w-1/2">
|
||||
@@ -213,29 +405,6 @@ export default function Analytics({ agentId }: AnalyticsProps) {
|
||||
<p className="text-foreground dark:text-foreground font-bold">
|
||||
{t('settings.analytics.messages')}
|
||||
</p>
|
||||
<Select
|
||||
value={messagesFilter?.value}
|
||||
onValueChange={(value) => {
|
||||
const opt = filterOptions.find((o) => o.value === value);
|
||||
if (opt) setMessagesFilter(opt);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-[125px] rounded-3xl px-5 py-3 text-sm"
|
||||
size="lg"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t('settings.analytics.filterPlaceholder')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filterOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="relative mt-px h-[245px] w-full">
|
||||
<div
|
||||
@@ -268,33 +437,37 @@ export default function Analytics({ agentId }: AnalyticsProps) {
|
||||
|
||||
{/* Token Usage Analytics */}
|
||||
<div className="border-border dark:border-border h-[345px] w-full overflow-hidden rounded-2xl border px-6 py-5 [@media(min-width:1080px)]:w-1/2">
|
||||
<div className="flex flex-row items-center justify-start gap-3">
|
||||
<div className="flex flex-row flex-wrap items-center justify-start gap-3">
|
||||
<p className="text-foreground dark:text-foreground font-bold">
|
||||
{t('settings.analytics.tokenUsage')}
|
||||
</p>
|
||||
<Select
|
||||
value={tokenUsageFilter?.value}
|
||||
onValueChange={(value) => {
|
||||
const opt = filterOptions.find((o) => o.value === value);
|
||||
if (opt) setTokenUsageFilter(opt);
|
||||
}}
|
||||
value={tokenGroupBy}
|
||||
onValueChange={(value) => setTokenGroupBy(value as TokenGroupBy)}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-[125px] rounded-3xl px-5 py-3 text-sm"
|
||||
className="w-[110px] rounded-3xl px-5 py-3 text-sm"
|
||||
size="lg"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t('settings.analytics.filterPlaceholder')}
|
||||
/>
|
||||
<SelectValue placeholder={t('settings.analytics.groupBy')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filterOptions.map((o) => (
|
||||
{groupByOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="ml-auto flex flex-row items-center gap-2">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.analytics.includeSideChannel')}
|
||||
</p>
|
||||
<Switch
|
||||
checked={includeSideChannel}
|
||||
onCheckedChange={setIncludeSideChannel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-px h-[245px] w-full">
|
||||
<div
|
||||
@@ -306,20 +479,106 @@ export default function Analytics({ agentId }: AnalyticsProps) {
|
||||
) : (
|
||||
<AnalyticsChart
|
||||
data={{
|
||||
labels: Object.keys(tokenUsageData || {}).map((item) =>
|
||||
labels: tokenLabels,
|
||||
datasets: tokenDatasets,
|
||||
}}
|
||||
legendID="legend-container-2"
|
||||
maxTicksLimitInX={8}
|
||||
isStacked={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scheduled runs + tool usage */}
|
||||
<div className="mt-4 flex w-full flex-col gap-3 [@media(min-width:1080px)]:flex-row">
|
||||
<div className="border-border dark:border-border h-[345px] w-full overflow-hidden rounded-2xl border px-6 py-5 [@media(min-width:1080px)]:w-1/2">
|
||||
<div className="flex flex-row items-center justify-start gap-3">
|
||||
<p className="text-foreground dark:text-foreground font-bold">
|
||||
{t('settings.analytics.scheduledRuns')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative mt-px h-[245px] w-full">
|
||||
<div
|
||||
id="legend-container-4"
|
||||
className="flex flex-row items-center justify-end"
|
||||
></div>
|
||||
{loadingSchedules ? (
|
||||
<SkeletonLoader count={1} component={'analysis'} />
|
||||
) : (
|
||||
<AnalyticsChart
|
||||
data={{
|
||||
labels: Object.keys(scheduleData || {}).map((item) =>
|
||||
formatDate(item),
|
||||
),
|
||||
datasets: [
|
||||
{
|
||||
label: t('settings.analytics.tokenUsage'),
|
||||
data: Object.values(tokenUsageData || {}),
|
||||
label: t('settings.analytics.completed'),
|
||||
data: Object.values(scheduleData || {}).map(
|
||||
(item) => item.completed,
|
||||
),
|
||||
backgroundColor: primaryColor,
|
||||
},
|
||||
{
|
||||
label: t('settings.analytics.failed'),
|
||||
data: Object.values(scheduleData || {}).map(
|
||||
(item) => item.failed,
|
||||
),
|
||||
backgroundColor: '#FF6384',
|
||||
},
|
||||
{
|
||||
label: t('settings.analytics.skipped'),
|
||||
data: Object.values(scheduleData || {}).map(
|
||||
(item) => item.skipped,
|
||||
),
|
||||
backgroundColor: '#FFCE56',
|
||||
},
|
||||
],
|
||||
}}
|
||||
legendID="legend-container-2"
|
||||
legendID="legend-container-4"
|
||||
maxTicksLimitInX={8}
|
||||
isStacked={false}
|
||||
isStacked={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border dark:border-border h-[345px] w-full overflow-hidden rounded-2xl border px-6 py-5 [@media(min-width:1080px)]:w-1/2">
|
||||
<div className="flex flex-row items-center justify-start gap-3">
|
||||
<p className="text-foreground dark:text-foreground font-bold">
|
||||
{t('settings.analytics.toolUsage')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative mt-px h-[245px] w-full">
|
||||
<div
|
||||
id="legend-container-5"
|
||||
className="flex flex-row items-center justify-end"
|
||||
></div>
|
||||
{loadingTools ? (
|
||||
<SkeletonLoader count={1} component={'analysis'} />
|
||||
) : (
|
||||
<AnalyticsChart
|
||||
data={{
|
||||
labels: (toolsData || []).map((tool) => tool.tool_name),
|
||||
datasets: [
|
||||
{
|
||||
label: t('settings.analytics.successful'),
|
||||
data: (toolsData || []).map(
|
||||
(tool) => tool.calls - tool.failures,
|
||||
),
|
||||
backgroundColor: primaryColor,
|
||||
},
|
||||
{
|
||||
label: t('settings.analytics.failed'),
|
||||
data: (toolsData || []).map((tool) => tool.failures),
|
||||
backgroundColor: '#FF6384',
|
||||
},
|
||||
],
|
||||
}}
|
||||
legendID="legend-container-5"
|
||||
maxTicksLimitInX={8}
|
||||
isStacked={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -327,35 +586,12 @@ export default function Analytics({ agentId }: AnalyticsProps) {
|
||||
</div>
|
||||
|
||||
{/* Feedback Analytics */}
|
||||
<div className="mt-8 flex w-full flex-col gap-3">
|
||||
<div className="mt-4 flex w-full flex-col gap-3">
|
||||
<div className="border-border dark:border-border h-[345px] w-full overflow-hidden rounded-2xl border px-6 py-5">
|
||||
<div className="flex flex-row items-center justify-start gap-3">
|
||||
<p className="text-foreground dark:text-foreground font-bold">
|
||||
{t('settings.analytics.userFeedback')}
|
||||
</p>
|
||||
<Select
|
||||
value={feedbackFilter?.value}
|
||||
onValueChange={(value) => {
|
||||
const opt = filterOptions.find((o) => o.value === value);
|
||||
if (opt) setFeedbackFilter(opt);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-[125px] rounded-3xl px-5 py-3 text-sm"
|
||||
size="lg"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t('settings.analytics.filterPlaceholder')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filterOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="relative mt-px h-[245px] w-full">
|
||||
<div
|
||||
|
||||
@@ -6,6 +6,14 @@ import userService from '../api/services/userService';
|
||||
import ChevronRight from '../assets/chevron-right.svg';
|
||||
import CopyButton from '../components/CopyButton';
|
||||
import SkeletonLoader from '../components/SkeletonLoader';
|
||||
import { Input } from '../components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../components/ui/select';
|
||||
import { useLoaderState } from '../hooks';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { LogData } from './types';
|
||||
@@ -23,11 +31,40 @@ export default function Logs({ agentId, tableHeader }: LogsProps) {
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingLogs, setLoadingLogs] = useLoaderState(true);
|
||||
|
||||
const [levelFilter, setLevelFilter] = useState('all');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const logs = Object.values(logsByPage).flat();
|
||||
|
||||
// Identifies the filter combination a request was issued under, so a
|
||||
// slow response from a previous combination is discarded instead of
|
||||
// landing in the freshly reset cache.
|
||||
const filterKey = [agentId ?? '', levelFilter, typeFilter, search].join('|');
|
||||
const filterKeyRef = useRef(filterKey);
|
||||
filterKeyRef.current = filterKey;
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setSearch(searchInput.trim()), 400);
|
||||
return () => clearTimeout(handle);
|
||||
}, [searchInput]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
setLogsByPage({});
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
}, [levelFilter, typeFilter, search, agentId]);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
if (logsByPage[page] && logsByPage[page].length > 0) return;
|
||||
|
||||
const issuedKey = filterKey;
|
||||
setLoadingLogs(true);
|
||||
try {
|
||||
const response = await userService.getLogs(
|
||||
@@ -35,11 +72,15 @@ export default function Logs({ agentId, tableHeader }: LogsProps) {
|
||||
page: page,
|
||||
api_key_id: agentId,
|
||||
page_size: 10,
|
||||
level: levelFilter === 'all' ? undefined : levelFilter,
|
||||
event_type: typeFilter === 'all' ? undefined : typeFilter,
|
||||
search: search || undefined,
|
||||
},
|
||||
token,
|
||||
);
|
||||
if (!response.ok) throw new Error('Failed to fetch logs');
|
||||
const data = await response.json();
|
||||
if (issuedKey !== filterKeyRef.current) return;
|
||||
|
||||
setLogsByPage((prev) => ({
|
||||
...prev,
|
||||
@@ -49,18 +90,74 @@ export default function Logs({ agentId, tableHeader }: LogsProps) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoadingLogs(false);
|
||||
if (issuedKey === filterKeyRef.current) setLoadingLogs(false);
|
||||
}
|
||||
};
|
||||
|
||||
// `logsByPage` is a dependency so the fetch re-fires after a filter
|
||||
// change clears the cache; the early-return guard keeps it from looping.
|
||||
useEffect(() => {
|
||||
if (hasMore) fetchLogs();
|
||||
}, [page, agentId]);
|
||||
}, [page, agentId, levelFilter, typeFilter, search, logsByPage]);
|
||||
|
||||
const levelOptions = [
|
||||
{ label: t('settings.logs.levels.all'), value: 'all' },
|
||||
{ label: t('settings.logs.levels.info'), value: 'info' },
|
||||
{ label: t('settings.logs.levels.error'), value: 'error' },
|
||||
{ label: t('settings.logs.levels.warning'), value: 'warning' },
|
||||
];
|
||||
const typeOptions = [
|
||||
{ label: t('settings.logs.types.all'), value: 'all' },
|
||||
{ label: t('settings.logs.types.chat'), value: 'chat' },
|
||||
{ label: t('settings.logs.types.schedule'), value: 'schedule' },
|
||||
{ label: t('settings.logs.types.webhook'), value: 'webhook' },
|
||||
{ label: t('settings.logs.types.workflow'), value: 'workflow' },
|
||||
{ label: t('settings.logs.types.system'), value: 'system' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<p className="text-muted-foreground mb-5 text-sm leading-6">
|
||||
{t('settings.logs.subtitle')}
|
||||
</p>
|
||||
<div className="mb-3 flex flex-row flex-wrap items-center gap-3">
|
||||
<Select value={levelFilter} onValueChange={setLevelFilter}>
|
||||
<SelectTrigger
|
||||
className="w-[125px] rounded-3xl px-5 py-3 text-sm"
|
||||
size="lg"
|
||||
>
|
||||
<SelectValue placeholder={t('settings.logs.levels.all')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{levelOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger
|
||||
className="w-[140px] rounded-3xl px-5 py-3 text-sm"
|
||||
size="lg"
|
||||
>
|
||||
<SelectValue placeholder={t('settings.logs.types.all')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{typeOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder={t('settings.logs.searchPlaceholder')}
|
||||
className="w-56 rounded-3xl"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<LogsTable
|
||||
logs={logs}
|
||||
@@ -124,6 +221,11 @@ function LogsTable({ logs, setPage, loading, tableHeader }: LogsTableProps) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative flex h-[51vh] grow flex-col items-start gap-2 overflow-y-auto overscroll-contain bg-transparent p-4">
|
||||
{!loading && logs.length === 0 && (
|
||||
<p className="text-muted-foreground w-full py-4 text-center text-xs">
|
||||
{t('settings.logs.noLogs')}
|
||||
</p>
|
||||
)}
|
||||
{logs?.map((log, index) => {
|
||||
if (index === logs.length - 1) {
|
||||
return (
|
||||
@@ -150,6 +252,14 @@ function LogsTable({ logs, setPage, loading, tableHeader }: LogsTableProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(start?: string, end?: string): string | null {
|
||||
if (!start || !end) return null;
|
||||
const ms = new Date(end).getTime() - new Date(start).getTime();
|
||||
if (isNaN(ms) || ms < 0) return null;
|
||||
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function Log({
|
||||
log,
|
||||
isOpen,
|
||||
@@ -165,7 +275,107 @@ function Log({
|
||||
error: 'text-red-500',
|
||||
warning: 'text-yellow-500',
|
||||
};
|
||||
const { id, action, timestamp, ...filteredLog } = log;
|
||||
const { id, action, timestamp, event_type, ...filteredLog } = log;
|
||||
|
||||
const detailRows: [string, string][] = [];
|
||||
if (log.event_type === 'chat') {
|
||||
if (log.agent_id)
|
||||
detailRows.push([t('settings.logs.detail.agent'), log.agent_id]);
|
||||
if (log.tool_calls?.length)
|
||||
detailRows.push([
|
||||
t('settings.logs.detail.toolCalls'),
|
||||
String(log.tool_calls.length),
|
||||
]);
|
||||
if (log.sources?.length)
|
||||
detailRows.push([
|
||||
t('settings.logs.detail.sources'),
|
||||
String(log.sources.length),
|
||||
]);
|
||||
} else if (log.event_type === 'schedule') {
|
||||
if (log.status)
|
||||
detailRows.push([t('settings.logs.detail.status'), log.status]);
|
||||
if (log.trigger_source)
|
||||
detailRows.push([t('settings.logs.detail.trigger'), log.trigger_source]);
|
||||
if (log.error_type)
|
||||
detailRows.push([t('settings.logs.detail.errorType'), log.error_type]);
|
||||
if (log.prompt_tokens || log.generated_tokens)
|
||||
detailRows.push([
|
||||
t('settings.logs.detail.tokens'),
|
||||
`${(log.prompt_tokens || 0) + (log.generated_tokens || 0)}`,
|
||||
]);
|
||||
const duration = formatDuration(log.started_at, log.finished_at);
|
||||
if (duration)
|
||||
detailRows.push([t('settings.logs.detail.duration'), duration]);
|
||||
if (log.conversation_id)
|
||||
detailRows.push([
|
||||
t('settings.logs.detail.conversation'),
|
||||
log.conversation_id,
|
||||
]);
|
||||
} else if (log.event_type === 'workflow') {
|
||||
if (log.workflow_name)
|
||||
detailRows.push([t('settings.logs.detail.workflow'), log.workflow_name]);
|
||||
if (log.status)
|
||||
detailRows.push([t('settings.logs.detail.status'), log.status]);
|
||||
const duration = formatDuration(log.started_at, log.finished_at);
|
||||
if (duration)
|
||||
detailRows.push([t('settings.logs.detail.duration'), duration]);
|
||||
} else if (log.event_type === 'system' || log.event_type === 'webhook') {
|
||||
if (log.endpoint)
|
||||
detailRows.push([t('settings.logs.detail.endpoint'), log.endpoint]);
|
||||
}
|
||||
|
||||
const textBlocks: { label: string; text: string; isError?: boolean }[] = [];
|
||||
if (log.event_type === 'schedule' && log.instruction)
|
||||
textBlocks.push({
|
||||
label: t('settings.logs.detail.instruction'),
|
||||
text: log.instruction,
|
||||
});
|
||||
if (log.response)
|
||||
textBlocks.push({
|
||||
label: t('settings.logs.detail.response'),
|
||||
text: log.response,
|
||||
});
|
||||
if (log.output)
|
||||
textBlocks.push({
|
||||
label: t('settings.logs.detail.output'),
|
||||
text: log.output,
|
||||
});
|
||||
if (log.error)
|
||||
textBlocks.push({
|
||||
label: t('settings.logs.detail.error'),
|
||||
text: log.error,
|
||||
isError: true,
|
||||
});
|
||||
|
||||
const jsonBlocks: { label: string; value: unknown }[] = [];
|
||||
if (log.tool_calls?.length)
|
||||
jsonBlocks.push({
|
||||
label: t('settings.logs.detail.toolCalls'),
|
||||
value: log.tool_calls,
|
||||
});
|
||||
if (log.sources?.length)
|
||||
jsonBlocks.push({
|
||||
label: t('settings.logs.detail.sources'),
|
||||
value: log.sources,
|
||||
});
|
||||
if (log.stacks?.length)
|
||||
jsonBlocks.push({
|
||||
label:
|
||||
log.event_type === 'webhook'
|
||||
? t('settings.logs.detail.activity')
|
||||
: t('settings.logs.detail.error'),
|
||||
value: log.stacks,
|
||||
});
|
||||
if (log.steps?.length)
|
||||
jsonBlocks.push({
|
||||
label: t('settings.logs.detail.steps'),
|
||||
value: log.steps,
|
||||
});
|
||||
if (log.event_type === 'workflow' && log.result)
|
||||
jsonBlocks.push({
|
||||
label: t('settings.logs.detail.result'),
|
||||
value: log.result,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="group dark:hover:bg-accent hover:bg-muted w-full rounded-xl bg-transparent">
|
||||
@@ -180,8 +390,13 @@ function Log({
|
||||
alt="Expand log entry"
|
||||
className={`mt-[3px] h-3 w-3 transition duration-300 ${isOpen ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<span className="flex flex-row gap-2">
|
||||
<span className="flex flex-row flex-wrap gap-2">
|
||||
<h2 className="dark:text-foreground text-xs text-black/60">{`${log.timestamp}`}</h2>
|
||||
{log.event_type && (
|
||||
<h2 className="text-muted-foreground text-xs">
|
||||
{t(`settings.logs.types.${log.event_type}`)}
|
||||
</h2>
|
||||
)}
|
||||
<h2 className="text-xs text-[#913400] dark:text-orange-500">{`[${log.action}]`}</h2>
|
||||
<h2
|
||||
className={`max-w-72 text-xs ${logLevelColor[log.level]} wrap-break-word`}
|
||||
@@ -194,11 +409,44 @@ function Log({
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="dark:bg-background rounded-b-xl bg-[#F1F1F1] px-4 py-3">
|
||||
<div className="scrollbar-overlay overflow-y-auto">
|
||||
<pre className="px-2 font-mono text-xs leading-relaxed wrap-break-word whitespace-pre-wrap text-gray-700 dark:text-gray-400">
|
||||
{JSON.stringify(filteredLog, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{detailRows.length > 0 && (
|
||||
<div className="flex flex-col gap-1 px-2 pb-2">
|
||||
{detailRows.map(([label, value]) => (
|
||||
<div key={label} className="flex flex-row gap-2 text-xs">
|
||||
<span className="text-muted-foreground w-28 shrink-0">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-foreground wrap-break-word">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{textBlocks.map((block) => (
|
||||
<div key={block.label} className="px-2 pb-2">
|
||||
<p className="text-muted-foreground text-xs">{block.label}</p>
|
||||
<pre
|
||||
className={`font-mono text-xs leading-relaxed wrap-break-word whitespace-pre-wrap ${
|
||||
block.isError
|
||||
? 'text-red-500'
|
||||
: 'text-gray-700 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{block.text}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
{jsonBlocks.map((block) => (
|
||||
<div key={block.label} className="px-2 pb-2">
|
||||
<p className="text-muted-foreground text-xs">{block.label}</p>
|
||||
<div className="scrollbar-overlay max-h-60 overflow-y-auto">
|
||||
<pre className="font-mono text-xs leading-relaxed wrap-break-word whitespace-pre-wrap text-gray-700 dark:text-gray-400">
|
||||
{JSON.stringify(block.value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="my-px w-fit">
|
||||
<CopyButton
|
||||
textToCopy={JSON.stringify(filteredLog)}
|
||||
|
||||
@@ -15,16 +15,48 @@ export type APIKeyData = {
|
||||
chunks: string;
|
||||
};
|
||||
|
||||
export type LogEventType =
|
||||
| 'chat'
|
||||
| 'schedule'
|
||||
| 'webhook'
|
||||
| 'workflow'
|
||||
| 'system';
|
||||
|
||||
export type LogData = {
|
||||
id: string;
|
||||
event_type?: LogEventType;
|
||||
action: string;
|
||||
level: 'info' | 'error' | 'warning';
|
||||
user: string;
|
||||
question: string;
|
||||
response: string;
|
||||
sources: Record<string, any>[];
|
||||
retriever_params: Record<string, any>;
|
||||
timestamp: string;
|
||||
// chat events (user_logs)
|
||||
response?: string;
|
||||
sources?: Record<string, any>[];
|
||||
tool_calls?: Record<string, any>[];
|
||||
agent_id?: string;
|
||||
attachments?: string[];
|
||||
// system + webhook events (stack_logs)
|
||||
endpoint?: string;
|
||||
stacks?: Record<string, any>[];
|
||||
// workflow events (workflow_runs)
|
||||
workflow_name?: string;
|
||||
result?: Record<string, any>;
|
||||
steps?: Record<string, any>[];
|
||||
// schedule events (schedule_runs)
|
||||
status?: string;
|
||||
trigger_source?: string;
|
||||
schedule_name?: string;
|
||||
instruction?: string;
|
||||
output?: string;
|
||||
error?: string;
|
||||
error_type?: string;
|
||||
prompt_tokens?: number;
|
||||
generated_tokens?: number;
|
||||
conversation_id?: string;
|
||||
scheduled_for?: string;
|
||||
started_at?: string;
|
||||
finished_at?: string;
|
||||
};
|
||||
|
||||
export type ParameterGroupType = {
|
||||
|
||||
@@ -74,33 +74,54 @@ class TestRangeForFilter:
|
||||
assert bucket_unit in {"minute", "hour", "day"}
|
||||
|
||||
|
||||
class TestResolveApiKey:
|
||||
def test_none_when_no_id(self, pg_conn):
|
||||
from application.api.user.analytics.routes import _resolve_api_key
|
||||
class TestResolveAgent:
|
||||
def test_no_agent_when_no_id(self, pg_conn):
|
||||
from application.api.user.analytics.routes import _resolve_agent
|
||||
|
||||
assert _resolve_api_key(pg_conn, None, "u") is None
|
||||
assert _resolve_api_key(pg_conn, "", "u") is None
|
||||
assert _resolve_agent(pg_conn, None, "u") == (None, "", None)
|
||||
assert _resolve_agent(pg_conn, "", "u") == (None, "", None)
|
||||
|
||||
def test_returns_key_for_owned_agent(self, pg_conn):
|
||||
from application.api.user.analytics.routes import _resolve_api_key
|
||||
def test_returns_key_and_id_for_owned_agent(self, pg_conn):
|
||||
from application.api.user.analytics.routes import _resolve_agent
|
||||
from application.storage.db.repositories.agents import AgentsRepository
|
||||
|
||||
agent = AgentsRepository(pg_conn).create(
|
||||
"owner", "test-agent", "published", key="secret-api-key",
|
||||
)
|
||||
assert (
|
||||
_resolve_api_key(pg_conn, str(agent["id"]), "owner")
|
||||
== "secret-api-key"
|
||||
resolved, api_key, agent_pg_id = _resolve_agent(
|
||||
pg_conn, str(agent["id"]), "owner"
|
||||
)
|
||||
assert resolved is not None
|
||||
assert api_key == "secret-api-key"
|
||||
assert agent_pg_id == str(agent["id"])
|
||||
|
||||
def test_none_for_other_users_agent(self, pg_conn):
|
||||
from application.api.user.analytics.routes import _resolve_api_key
|
||||
def test_keyless_agent_gets_no_match_sentinel(self, pg_conn):
|
||||
from application.api.user.analytics.routes import _resolve_agent
|
||||
from application.storage.db.repositories.agents import AgentsRepository
|
||||
|
||||
agent = AgentsRepository(pg_conn).create(
|
||||
"owner", "test-agent", "draft", key="",
|
||||
)
|
||||
resolved, api_key, agent_pg_id = _resolve_agent(
|
||||
pg_conn, str(agent["id"]), "owner"
|
||||
)
|
||||
# The empty-string key matches no rows; agent_id still matches.
|
||||
assert resolved is not None
|
||||
assert api_key == ""
|
||||
assert agent_pg_id == str(agent["id"])
|
||||
|
||||
def test_no_match_for_other_users_agent(self, pg_conn):
|
||||
from application.api.user.analytics.routes import _resolve_agent
|
||||
from application.storage.db.repositories.agents import AgentsRepository
|
||||
|
||||
agent = AgentsRepository(pg_conn).create(
|
||||
"owner", "test-agent", "published", key="secret"
|
||||
)
|
||||
assert _resolve_api_key(pg_conn, str(agent["id"]), "other-user") is None
|
||||
assert _resolve_agent(pg_conn, str(agent["id"]), "other-user") == (
|
||||
None,
|
||||
"",
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
class TestGetMessageAnalytics:
|
||||
|
||||
@@ -47,7 +47,6 @@ interface GetLogsResponse {
|
||||
user: string | null;
|
||||
question: string | null;
|
||||
sources: unknown;
|
||||
retriever_params: unknown;
|
||||
timestamp: string | null;
|
||||
}>;
|
||||
page: number;
|
||||
|
||||
Reference in new issue
Block a user