diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index faa97ada0d..70aa7cc4c7 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -1,11 +1,12 @@ from uuid import UUID -from flask import request +from flask import abort, request from flask_restx import Resource -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns +from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.app.app import ( AppDetailWithSite, AppListQuery, @@ -27,14 +28,22 @@ from fields.agent_fields import ( AgentConfigSnapshotDetailResponse, AgentConfigSnapshotListResponse, AgentInviteOptionsResponse, + AgentLogListResponse, AgentPublishedReferenceResponse, AgentRosterListResponse, + AgentStatisticSummaryEnvelopeResponse, ) +from libs.datetime_utils import parse_time_range from libs.helper import dump_response from libs.login import login_required from models import Account from models.model import IconType from services.agent.errors import AgentNotFoundError +from services.agent.observability_service import ( + AgentLogQueryParams, + AgentObservabilityService, + AgentStatisticsQueryParams, +) from services.agent.roster_service import AgentRosterService from services.app_service import AppListParams, AppService, CreateAppParams from services.enterprise.enterprise_service import EnterpriseService @@ -63,11 +72,49 @@ class AgentAppUpdatePayload(UpdateAppPayload): role: str | None = Field(default=None, description="Agent role", max_length=255) +class AgentLogsQuery(BaseModel): + page: int = Field(default=1, ge=1, description="Page number") + limit: int = Field(default=20, ge=1, le=100, description="Page size") + keyword: str | None = Field(default=None, description="Search query, answer, or conversation name") + status: str | None = Field(default=None, description="Filter by success, failed, or paused") + source: str | None = Field( + default=None, + description="Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger", + ) + start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)") + end: str | None = Field(default=None, description="End date (YYYY-MM-DD HH:MM)") + + @field_validator("keyword", "status", "source", "start", "end", mode="before") + @classmethod + def empty_string_to_none(cls, value: str | None) -> str | None: + if value == "": + return None + return value + + +class AgentStatisticsQuery(BaseModel): + source: str | None = Field( + default=None, + description="Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger", + ) + start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)") + end: str | None = Field(default=None, description="End date (YYYY-MM-DD HH:MM)") + + @field_validator("source", "start", "end", mode="before") + @classmethod + def empty_string_to_none(cls, value: str | None) -> str | None: + if value == "": + return None + return value + + register_schema_models( console_ns, AgentAppCreatePayload, AgentAppUpdatePayload, AgentInviteOptionsQuery, + AgentLogsQuery, + AgentStatisticsQuery, AgentIdPath, AppListQuery, UpdateAppPayload, @@ -80,8 +127,10 @@ register_response_schema_models( AgentConfigSnapshotDetailResponse, AgentConfigSnapshotListResponse, AgentInviteOptionsResponse, + AgentLogListResponse, AgentPublishedReferenceResponse, AgentRosterListResponse, + AgentStatisticSummaryEnvelopeResponse, ) @@ -136,7 +185,19 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict: def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID): - return _agent_roster_service().get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id)) + return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + + +def _agent_observability_service() -> AgentObservabilityService: + return AgentObservabilityService(db.session) + + +def _parse_observability_time_range(start: str | None, end: str | None, account: Account): + timezone = account.timezone or "UTC" + try: + return parse_time_range(start, end, timezone) + except ValueError as exc: + abort(400, description=str(exc)) @console_ns.route("/agent") @@ -267,6 +328,65 @@ class AgentInviteOptionsApi(Resource): ) +@console_ns.route("/agent//logs") +class AgentLogsApi(Resource): + @console_ns.doc(params=query_params_from_model(AgentLogsQuery)) + @console_ns.response(200, "Agent logs", console_ns.models[AgentLogListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID): + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + query = AgentLogsQuery.model_validate(request.args.to_dict(flat=True)) + start, end = _parse_observability_time_range(query.start, query.end, current_user) + try: + payload = _agent_observability_service().list_logs( + app=app_model, + params=AgentLogQueryParams( + page=query.page, + limit=query.limit, + keyword=query.keyword, + status=query.status, + source=query.source, + start=start, + end=end, + ), + ) + except ValueError as exc: + abort(400, description=str(exc)) + return dump_response(AgentLogListResponse, payload) + + +@console_ns.route("/agent//statistics/summary") +class AgentStatisticsSummaryApi(Resource): + @console_ns.doc(params=query_params_from_model(AgentStatisticsQuery)) + @console_ns.response( + 200, + "Agent monitoring summary and chart data", + console_ns.models[AgentStatisticSummaryEnvelopeResponse.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID): + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + query = AgentStatisticsQuery.model_validate(request.args.to_dict(flat=True)) + timezone = current_user.timezone or "UTC" + start, end = _parse_observability_time_range(query.start, query.end, current_user) + try: + payload = _agent_observability_service().get_statistics_summary( + app=app_model, + params=AgentStatisticsQueryParams(source=query.source, start=start, end=end, timezone=timezone), + ) + except ValueError as exc: + abort(400, description=str(exc)) + return dump_response(AgentStatisticSummaryEnvelopeResponse, payload) + + @console_ns.route("/agent//versions") class AgentRosterVersionsApi(Resource): @console_ns.response(200, "Agent versions", console_ns.models[AgentConfigSnapshotListResponse.__name__]) diff --git a/api/fields/agent_fields.py b/api/fields/agent_fields.py index 36d9623198..724e5ecf7d 100644 --- a/api/fields/agent_fields.py +++ b/api/fields/agent_fields.py @@ -1,8 +1,10 @@ +from datetime import datetime from typing import Annotated, Literal -from pydantic import Field +from pydantic import Field, field_validator from fields.base import ResponseModel +from libs.helper import to_timestamp from models.agent import ( AgentConfigRevisionOperation, AgentIconType, @@ -105,6 +107,114 @@ class AgentInviteOptionsResponse(ResponseModel): has_more: bool +class AgentLogItemResponse(ResponseModel): + id: str + message_id: str + conversation_id: str + conversation_name: str | None = None + query: str + answer: str + status: str + error: str | None = None + source: str | None = None + from_source: str | None = None + from_end_user_id: str | None = None + from_account_id: str | None = None + message_tokens: int + answer_tokens: int + total_tokens: int + total_price: str + currency: str + latency: float + created_at: int | None = None + updated_at: int | None = None + + @field_validator("created_at", "updated_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return to_timestamp(value) + + +class AgentLogListResponse(ResponseModel): + data: list[AgentLogItemResponse] + page: int + limit: int + total: int + has_more: bool + + +class AgentStatisticSummaryResponse(ResponseModel): + total_messages: int + total_conversations: int + total_end_users: int + total_tokens: int + total_price: str + currency: str + average_session_interactions: float + average_response_time: float + tokens_per_second: float + user_satisfaction_rate: float + + +class AgentDailyMessageStatisticResponse(ResponseModel): + date: str + message_count: int + + +class AgentDailyConversationStatisticResponse(ResponseModel): + date: str + conversation_count: int + + +class AgentDailyEndUserStatisticResponse(ResponseModel): + date: str + terminal_count: int + + +class AgentTokenUsageStatisticResponse(ResponseModel): + date: str + token_count: int + total_price: str + currency: str + + +class AgentAverageSessionInteractionStatisticResponse(ResponseModel): + date: str + interactions: float + + +class AgentAverageResponseTimeStatisticResponse(ResponseModel): + date: str + latency: float + + +class AgentTokensPerSecondStatisticResponse(ResponseModel): + date: str + tps: float + + +class AgentUserSatisfactionRateStatisticResponse(ResponseModel): + date: str + rate: float + + +class AgentStatisticChartsResponse(ResponseModel): + daily_messages: list[AgentDailyMessageStatisticResponse] = Field(default_factory=list) + daily_conversations: list[AgentDailyConversationStatisticResponse] = Field(default_factory=list) + daily_end_users: list[AgentDailyEndUserStatisticResponse] = Field(default_factory=list) + token_usage: list[AgentTokenUsageStatisticResponse] = Field(default_factory=list) + average_session_interactions: list[AgentAverageSessionInteractionStatisticResponse] = Field(default_factory=list) + average_response_time: list[AgentAverageResponseTimeStatisticResponse] = Field(default_factory=list) + tokens_per_second: list[AgentTokensPerSecondStatisticResponse] = Field(default_factory=list) + user_satisfaction_rate: list[AgentUserSatisfactionRateStatisticResponse] = Field(default_factory=list) + + +class AgentStatisticSummaryEnvelopeResponse(ResponseModel): + source: str + summary: AgentStatisticSummaryResponse + charts: AgentStatisticChartsResponse + + class AgentConfigRevisionResponse(ResponseModel): id: str previous_snapshot_id: str | None = None diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 2f2e8ae15b..925d4bd84a 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -638,6 +638,26 @@ Commit an uploaded file into the Agent App drive under files/ | ---- | ----------- | ------ | | 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)
| +### [GET] /agent/{agent_id}/logs +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| end | query | End date (YYYY-MM-DD HH:MM) | No | string | +| keyword | query | Search query, answer, or conversation name | No | string | +| limit | query | Page size | No | integer,
**Default:** 20 | +| page | query | Page number | No | integer,
**Default:** 1 | +| source | query | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | string | +| start | query | Start date (YYYY-MM-DD HH:MM) | No | string | +| status | query | Filter by success, failed, or paused | No | string | +| agent_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent logs | **application/json**: [AgentLogListResponse](#agentloglistresponse)
| + ### [GET] /agent/{agent_id}/messages/{message_id} Get Agent App message details by ID @@ -790,6 +810,22 @@ Infer CLI tool + ENV suggestions from a standardized Agent App skill | ---- | ----------- | ------ | | 200 | Inference result (draft suggestions, nothing persisted) | **application/json**: [SkillToolInferenceResult](#skilltoolinferenceresult)
| +### [GET] /agent/{agent_id}/statistics/summary +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| end | query | End date (YYYY-MM-DD HH:MM) | No | string | +| source | query | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | string | +| start | query | Start date (YYYY-MM-DD HH:MM) | No | string | +| agent_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent monitoring summary and chart data | **application/json**: [AgentStatisticSummaryEnvelopeResponse](#agentstatisticsummaryenveloperesponse)
| + ### [GET] /agent/{agent_id}/versions #### Parameters @@ -11318,6 +11354,20 @@ default (the config form sends the full desired feature state on save). | role | string | Agent role | No | | use_icon_as_answer_icon | boolean | Use icon as answer icon | No | +#### AgentAverageResponseTimeStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| latency | number | | Yes | + +#### AgentAverageSessionInteractionStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| interactions | number | | Yes | + #### AgentCliToolAuthorizationStatus Authorization state for Agent-scoped CLI tools. @@ -11558,6 +11608,27 @@ Audit operation recorded for Agent Soul version/revision changes. | version | integer | | Yes | | version_note | string | | No | +#### AgentDailyConversationStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_count | integer | | Yes | +| date | string | | Yes | + +#### AgentDailyEndUserStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| terminal_count | integer | | Yes | + +#### AgentDailyMessageStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| message_count | integer | | Yes | + #### AgentDriveDeleteFileByAgentQuery | Name | Type | Description | Required | @@ -11797,6 +11868,41 @@ the current roster/workflow APIs scoped to Dify Agent. | ---- | ---- | ----------- | -------- | | AgentKnowledgeQueryMode | string | | | +#### AgentLogItemResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| answer | string | | Yes | +| answer_tokens | integer | | Yes | +| conversation_id | string | | Yes | +| conversation_name | string | | No | +| created_at | integer | | No | +| currency | string | | Yes | +| error | string | | No | +| from_account_id | string | | No | +| from_end_user_id | string | | No | +| from_source | string | | No | +| id | string | | Yes | +| latency | number | | Yes | +| message_id | string | | Yes | +| message_tokens | integer | | Yes | +| query | string | | Yes | +| source | string | | No | +| status | string | | Yes | +| total_price | string | | Yes | +| total_tokens | integer | | Yes | +| updated_at | integer | | No | + +#### AgentLogListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AgentLogItemResponse](#agentlogitemresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + #### AgentLogMetaResponse | Name | Type | Description | Required | @@ -11824,6 +11930,18 @@ the current roster/workflow APIs scoped to Dify Agent. | iterations | [ [AgentIterationLogResponse](#agentiterationlogresponse) ] | | Yes | | meta | [AgentLogMetaResponse](#agentlogmetaresponse) | | Yes | +#### AgentLogsQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| end | string | End date (YYYY-MM-DD HH:MM) | No | +| keyword | string | Search query, answer, or conversation name | No | +| limit | integer,
**Default:** 20 | Page size | No | +| page | integer,
**Default:** 1 | Page number | No | +| source | string | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | +| start | string | Start date (YYYY-MM-DD HH:MM) | No | +| status | string | Filter by success, failed, or paused | No | + #### AgentMemoryArtifactConfig | Name | Type | Description | Required | @@ -12197,6 +12315,50 @@ Origin that created or imported the Agent. | ---- | ---- | ----------- | -------- | | AgentSource | string | Origin that created or imported the Agent. | | +#### AgentStatisticChartsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| average_response_time | [ [AgentAverageResponseTimeStatisticResponse](#agentaverageresponsetimestatisticresponse) ] | | No | +| average_session_interactions | [ [AgentAverageSessionInteractionStatisticResponse](#agentaveragesessioninteractionstatisticresponse) ] | | No | +| daily_conversations | [ [AgentDailyConversationStatisticResponse](#agentdailyconversationstatisticresponse) ] | | No | +| daily_end_users | [ [AgentDailyEndUserStatisticResponse](#agentdailyenduserstatisticresponse) ] | | No | +| daily_messages | [ [AgentDailyMessageStatisticResponse](#agentdailymessagestatisticresponse) ] | | No | +| token_usage | [ [AgentTokenUsageStatisticResponse](#agenttokenusagestatisticresponse) ] | | No | +| tokens_per_second | [ [AgentTokensPerSecondStatisticResponse](#agenttokenspersecondstatisticresponse) ] | | No | +| user_satisfaction_rate | [ [AgentUserSatisfactionRateStatisticResponse](#agentusersatisfactionratestatisticresponse) ] | | No | + +#### AgentStatisticSummaryEnvelopeResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| charts | [AgentStatisticChartsResponse](#agentstatisticchartsresponse) | | Yes | +| source | string | | Yes | +| summary | [AgentStatisticSummaryResponse](#agentstatisticsummaryresponse) | | Yes | + +#### AgentStatisticSummaryResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| average_response_time | number | | Yes | +| average_session_interactions | number | | Yes | +| currency | string | | Yes | +| tokens_per_second | number | | Yes | +| total_conversations | integer | | Yes | +| total_end_users | integer | | Yes | +| total_messages | integer | | Yes | +| total_price | string | | Yes | +| total_tokens | integer | | Yes | +| user_satisfaction_rate | number | | Yes | + +#### AgentStatisticsQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| end | string | End date (YYYY-MM-DD HH:MM) | No | +| source | string | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | +| start | string | Start date (YYYY-MM-DD HH:MM) | No | + #### AgentStatus Soft lifecycle state for Agent records. @@ -12239,6 +12401,22 @@ Soft lifecycle state for Agent records. | tool_input | string | | No | | tool_labels | [JSONValue](#jsonvalue) | | Yes | +#### AgentTokenUsageStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| currency | string | | Yes | +| date | string | | Yes | +| token_count | integer | | Yes | +| total_price | string | | Yes | + +#### AgentTokensPerSecondStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| tps | number | | Yes | + #### AgentToolCallResponse | Name | Type | Description | Required | @@ -12253,6 +12431,13 @@ Soft lifecycle state for Agent records. | tool_output | object | | Yes | | tool_parameters | object | | Yes | +#### AgentUserSatisfactionRateStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| rate | number | | Yes | + #### AllowedExtensionsResponse | Name | Type | Description | Required | diff --git a/api/services/agent/observability_service.py b/api/services/agent/observability_service.py new file mode 100644 index 0000000000..a150f70d7f --- /dev/null +++ b/api/services/agent/observability_service.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal +from typing import Any + +import sqlalchemy as sa +from sqlalchemy import func, or_, select + +from core.app.entities.app_invoke_entities import InvokeFrom +from libs.helper import convert_datetime_to_date, escape_like_pattern, to_timestamp +from models.enums import MessageStatus +from models.model import App, Conversation, Message + + +@dataclass(frozen=True) +class AgentLogQueryParams: + page: int = 1 + limit: int = 20 + keyword: str | None = None + status: str | None = None + source: str | None = None + start: datetime | None = None + end: datetime | None = None + + +@dataclass(frozen=True) +class AgentStatisticsQueryParams: + source: str | None = None + start: datetime | None = None + end: datetime | None = None + timezone: str = "UTC" + + +class AgentObservabilityService: + _SOURCE_ALIASES: dict[str, InvokeFrom] = { + "api": InvokeFrom.SERVICE_API, + "service-api": InvokeFrom.SERVICE_API, + "service_api": InvokeFrom.SERVICE_API, + "console": InvokeFrom.EXPLORE, + "explore": InvokeFrom.EXPLORE, + "explore-app": InvokeFrom.EXPLORE, + "explore_app": InvokeFrom.EXPLORE, + "web": InvokeFrom.WEB_APP, + "web-app": InvokeFrom.WEB_APP, + "web_app": InvokeFrom.WEB_APP, + "debugger": InvokeFrom.DEBUGGER, + "dev": InvokeFrom.DEBUGGER, + "openapi": InvokeFrom.OPENAPI, + "trigger": InvokeFrom.TRIGGER, + } + + def __init__(self, session: Any): + self._session = session + + @classmethod + def resolve_source(cls, source: str | None) -> InvokeFrom | None: + if not source or source == "all": + return None + normalized = source.strip().lower() + if not normalized or normalized == "all": + return None + try: + return cls._SOURCE_ALIASES[normalized] + except KeyError as exc: + raise ValueError(f"Unsupported source: {source}") from exc + + @staticmethod + def _message_status(message: Message) -> str: + if message.error or message.status == MessageStatus.ERROR: + return "failed" + if message.status == MessageStatus.PAUSED: + return "paused" + return "success" + + @staticmethod + def _total_tokens(message: Message) -> int: + return int(message.message_tokens or 0) + int(message.answer_tokens or 0) + + @classmethod + def serialize_log_message(cls, message: Message, conversation: Conversation | None = None) -> dict[str, Any]: + invoke_from = message.invoke_from.value if message.invoke_from else None + return { + "id": message.id, + "message_id": message.id, + "conversation_id": message.conversation_id, + "conversation_name": conversation.name if conversation else None, + "query": message.query, + "answer": message.answer, + "status": cls._message_status(message), + "error": message.error, + "source": invoke_from, + "from_source": message.from_source.value if message.from_source else None, + "from_end_user_id": message.from_end_user_id, + "from_account_id": message.from_account_id, + "message_tokens": int(message.message_tokens or 0), + "answer_tokens": int(message.answer_tokens or 0), + "total_tokens": cls._total_tokens(message), + "total_price": str(message.total_price or Decimal(0)), + "currency": message.currency, + "latency": float(message.provider_response_latency or 0), + "created_at": to_timestamp(message.created_at), + "updated_at": to_timestamp(message.updated_at), + } + + def list_logs(self, *, app: App, params: AgentLogQueryParams) -> dict[str, Any]: + source = self.resolve_source(params.source) + stmt = ( + select(Message, Conversation) + .join(Conversation, Conversation.id == Message.conversation_id) + .where(Message.app_id == app.id, Conversation.app_id == app.id) + ) + stmt = self._apply_source_filter(stmt, source) + + if params.start: + stmt = stmt.where(Message.created_at >= params.start) + if params.end: + stmt = stmt.where(Message.created_at < params.end) + if params.keyword: + escaped_keyword = escape_like_pattern(params.keyword) + pattern = f"%{escaped_keyword}%" + stmt = stmt.where( + or_( + Message.query.ilike(pattern, escape="\\"), + Message.answer.ilike(pattern, escape="\\"), + Conversation.name.ilike(pattern, escape="\\"), + ) + ) + if params.status: + stmt = self._apply_status_filter(stmt, params.status) + + total = self._session.scalar(select(func.count()).select_from(stmt.subquery())) or 0 + rows = list( + self._session.execute( + stmt.order_by(Message.created_at.desc(), Message.id.desc()) + .offset((params.page - 1) * params.limit) + .limit(params.limit) + ).all() + ) + data = [] + for message, conversation in rows: + data.append(self.serialize_log_message(message, conversation)) + return { + "data": data, + "page": params.page, + "limit": params.limit, + "total": total, + "has_more": params.page * params.limit < total, + } + + @classmethod + def _apply_source_filter(cls, stmt, source: InvokeFrom | None): + if source is None: + return stmt.where(Message.invoke_from != InvokeFrom.DEBUGGER) + return stmt.where(Message.invoke_from == source) + + @staticmethod + def _apply_status_filter(stmt, status: str): + normalized = status.strip().lower() + if normalized in {"success", "normal"}: + return stmt.where(Message.error.is_(None), Message.status == MessageStatus.NORMAL) + if normalized in {"failed", "error"}: + return stmt.where(or_(Message.error.is_not(None), Message.status == MessageStatus.ERROR)) + if normalized == "paused": + return stmt.where(Message.status == MessageStatus.PAUSED) + raise ValueError(f"Unsupported status: {status}") + + def get_statistics_summary(self, *, app: App, params: AgentStatisticsQueryParams) -> dict[str, Any]: + source = self.resolve_source(params.source) + rows = self._load_daily_statistics(app=app, params=params, source=source) + charts = self._build_charts(rows) + summary = self._build_summary(rows) + return { + "source": source.value if source else "all", + "summary": summary, + "charts": charts, + } + + def _load_daily_statistics( + self, *, app: App, params: AgentStatisticsQueryParams, source: InvokeFrom | None + ) -> list[dict[str, Any]]: + converted_created_at = convert_datetime_to_date("m.created_at") + source_condition = "AND m.invoke_from != :debugger" if source is None else "AND m.invoke_from = :source" + sql_query = f"""SELECT + {converted_created_at} AS date, + COUNT(m.id) AS message_count, + COUNT(DISTINCT m.conversation_id) AS conversation_count, + COUNT(DISTINCT m.from_end_user_id) AS end_user_count, + COALESCE(SUM(COALESCE(m.message_tokens, 0) + COALESCE(m.answer_tokens, 0)), 0) AS token_count, + COALESCE(SUM(COALESCE(m.total_price, 0)), 0) AS total_price, + COALESCE(AVG(m.provider_response_latency), 0) AS avg_latency, + COALESCE(SUM(m.provider_response_latency), 0) AS latency_sum, + COALESCE(SUM(m.answer_tokens), 0) AS answer_tokens, + COUNT(mf.id) AS like_count +FROM messages m +LEFT JOIN message_feedbacks mf + ON mf.message_id = m.id AND mf.rating = 'like' +WHERE + m.app_id = :app_id + {source_condition}""" + args: dict[str, Any] = { + "tz": params.timezone, + "app_id": app.id, + "debugger": InvokeFrom.DEBUGGER, + } + if source is not None: + args["source"] = source + if params.start: + sql_query += " AND m.created_at >= :start" + args["start"] = params.start + if params.end: + sql_query += " AND m.created_at < :end" + args["end"] = params.end + sql_query += " GROUP BY date ORDER BY date" + + return [dict(row._mapping) for row in self._session.execute(sa.text(sql_query), args).all()] + + @staticmethod + def _build_charts(rows: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: + messages = [] + conversations = [] + end_users = [] + token_usage = [] + average_session_interactions = [] + average_response_time = [] + tokens_per_second = [] + user_satisfaction_rate = [] + + for row in rows: + date = str(row["date"]) + message_count = int(row["message_count"] or 0) + conversation_count = int(row["conversation_count"] or 0) + token_count = int(row["token_count"] or 0) + total_price = row["total_price"] or Decimal(0) + avg_latency = float(row["avg_latency"] or 0) + latency_sum = float(row["latency_sum"] or 0) + answer_tokens = int(row["answer_tokens"] or 0) + like_count = int(row["like_count"] or 0) + + messages.append({"date": date, "message_count": message_count}) + conversations.append({"date": date, "conversation_count": conversation_count}) + end_users.append({"date": date, "terminal_count": int(row["end_user_count"] or 0)}) + token_usage.append( + { + "date": date, + "token_count": token_count, + "total_price": str(total_price), + "currency": "USD", + } + ) + average_session_interactions.append( + { + "date": date, + "interactions": round(message_count / conversation_count, 2) if conversation_count else 0, + } + ) + average_response_time.append({"date": date, "latency": round(avg_latency * 1000, 4)}) + tokens_per_second.append({"date": date, "tps": round(answer_tokens / latency_sum, 4) if latency_sum else 0}) + user_satisfaction_rate.append( + {"date": date, "rate": round(like_count * 100 / message_count, 2) if message_count else 0} + ) + + return { + "daily_messages": messages, + "daily_conversations": conversations, + "daily_end_users": end_users, + "token_usage": token_usage, + "average_session_interactions": average_session_interactions, + "average_response_time": average_response_time, + "tokens_per_second": tokens_per_second, + "user_satisfaction_rate": user_satisfaction_rate, + } + + @staticmethod + def _build_summary(rows: list[dict[str, Any]]) -> dict[str, Any]: + total_messages = sum(int(row["message_count"] or 0) for row in rows) + total_conversations = sum(int(row["conversation_count"] or 0) for row in rows) + total_end_users = sum(int(row["end_user_count"] or 0) for row in rows) + total_tokens = sum(int(row["token_count"] or 0) for row in rows) + total_price = sum(Decimal(str(row["total_price"] or 0)) for row in rows) + total_answer_tokens = sum(int(row["answer_tokens"] or 0) for row in rows) + total_latency = sum(float(row["latency_sum"] or 0) for row in rows) + weighted_latency = sum(float(row["avg_latency"] or 0) * int(row["message_count"] or 0) for row in rows) + total_likes = sum(int(row["like_count"] or 0) for row in rows) + + return { + "total_messages": total_messages, + "total_conversations": total_conversations, + "total_end_users": total_end_users, + "total_tokens": total_tokens, + "total_price": str(total_price), + "currency": "USD", + "average_session_interactions": round(total_messages / total_conversations, 2) + if total_conversations + else 0, + "average_response_time": round((weighted_latency / total_messages) * 1000, 4) if total_messages else 0, + "tokens_per_second": round(total_answer_tokens / total_latency, 4) if total_latency else 0, + "user_satisfaction_rate": round(total_likes * 100 / total_messages, 2) if total_messages else 0, + } diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py index a2834dc80a..1226885171 100644 --- a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py @@ -87,7 +87,7 @@ def _add_app_log(session: Session, scope: _TestScope, workflow_run: WorkflowRun) session.commit() -def _add_pause_with_reason(session: Session, scope: _TestScope, workflow_run: WorkflowRun) -> WorkflowPause: +def _add_pause_with_reason(session: Session, _scope: _TestScope, workflow_run: WorkflowRun) -> WorkflowPause: pause = WorkflowPause( workflow_id=workflow_run.workflow_id, workflow_run_id=workflow_run.id, diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index 91b644b1c7..429c3d0e2e 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -23,8 +23,10 @@ from controllers.console.agent.roster import ( AgentAppApi, AgentAppListApi, AgentInviteOptionsApi, + AgentLogsApi, AgentRosterVersionDetailApi, AgentRosterVersionsApi, + AgentStatisticsSummaryApi, ) from controllers.console.app import completion as completion_controller from controllers.console.app import message as message_controller @@ -148,6 +150,8 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None: "/agent//feedbacks", "/agent//chat-messages//suggested-questions", "/agent//messages/", + "/agent//logs", + "/agent//statistics/summary", "/agent/invite-options", ): assert route in paths @@ -371,6 +375,108 @@ def test_agent_versions_call_services(app: Flask, monkeypatch: pytest.MonkeyPatc assert version_detail["agent_id"] == agent_id +def test_agent_observability_routes_resolve_app_from_agent_id( + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +) -> None: + agent_id = "00000000-0000-0000-0000-000000000001" + app_model = SimpleNamespace(id="app-1") + captured: dict[str, object] = {} + + class FakeObservabilityService: + def list_logs(self, *, app, params): + captured["logs"] = {"app": app, "params": params} + return { + "data": [ + { + "id": "message-1", + "message_id": "message-1", + "conversation_id": "conversation-1", + "conversation_name": "Debug", + "query": "hello", + "answer": "hi", + "status": "success", + "error": None, + "source": "explore", + "from_source": "console", + "from_end_user_id": None, + "from_account_id": account_id, + "message_tokens": 1, + "answer_tokens": 2, + "total_tokens": 3, + "total_price": "0", + "currency": "USD", + "latency": 1.2, + "created_at": 1, + "updated_at": 2, + } + ], + "page": 2, + "limit": 5, + "total": 6, + "has_more": False, + } + + def get_statistics_summary(self, *, app, params): + captured["statistics"] = {"app": app, "params": params} + return { + "source": "all", + "summary": { + "total_messages": 1, + "total_conversations": 1, + "total_end_users": 1, + "total_tokens": 3, + "total_price": "0", + "currency": "USD", + "average_session_interactions": 1, + "average_response_time": 1200, + "tokens_per_second": 2, + "user_satisfaction_rate": 100, + }, + "charts": { + "daily_messages": [{"date": "2026-06-17", "message_count": 1}], + "daily_conversations": [{"date": "2026-06-17", "conversation_count": 1}], + "daily_end_users": [{"date": "2026-06-17", "terminal_count": 1}], + "token_usage": [{"date": "2026-06-17", "token_count": 3, "total_price": "0", "currency": "USD"}], + "average_session_interactions": [{"date": "2026-06-17", "interactions": 1}], + "average_response_time": [{"date": "2026-06-17", "latency": 1200}], + "tokens_per_second": [{"date": "2026-06-17", "tps": 2}], + "user_satisfaction_rate": [{"date": "2026-06-17", "rate": 100}], + }, + } + + monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda **kwargs: app_model) + monkeypatch.setattr(roster_controller, "_agent_observability_service", lambda: FakeObservabilityService()) + + account = SimpleNamespace(id=account_id, timezone="UTC") + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/logs" + "?page=2&limit=5&keyword=hello&status=success&source=console" + ): + logs = unwrap(AgentLogsApi.get)(AgentLogsApi(), "tenant-1", account, agent_id) + + assert logs["data"][0]["id"] == "message-1" + logs_call = cast(dict[str, object], captured["logs"]) + assert logs_call["app"] is app_model + logs_params = cast(Any, logs_call["params"]) + assert logs_params.page == 2 + assert logs_params.limit == 5 + assert logs_params.keyword == "hello" + assert logs_params.status == "success" + assert logs_params.source == "console" + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/statistics/summary?source=api" + ): + statistics = unwrap(AgentStatisticsSummaryApi.get)(AgentStatisticsSummaryApi(), "tenant-1", account, agent_id) + + assert statistics["summary"]["total_messages"] == 1 + stats_call = cast(dict[str, object], captured["statistics"]) + assert stats_call["app"] is app_model + stats_params = cast(Any, stats_call["params"]) + assert stats_params.source == "api" + assert stats_params.timezone == "UTC" + + def test_workflow_composer_get_put_validate_candidates_impact_and_save( app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str ) -> None: diff --git a/api/tests/unit_tests/services/agent/test_agent_observability_service.py b/api/tests/unit_tests/services/agent/test_agent_observability_service.py new file mode 100644 index 0000000000..1ce8edad78 --- /dev/null +++ b/api/tests/unit_tests/services/agent/test_agent_observability_service.py @@ -0,0 +1,123 @@ +from datetime import UTC, datetime +from decimal import Decimal +from types import SimpleNamespace + +import pytest + +from core.app.entities.app_invoke_entities import InvokeFrom +from models.enums import ConversationFromSource, MessageStatus +from services.agent.observability_service import AgentObservabilityService + + +def test_resolve_source_accepts_frontend_aliases() -> None: + assert AgentObservabilityService.resolve_source(None) is None + assert AgentObservabilityService.resolve_source("all") is None + assert AgentObservabilityService.resolve_source("console") == InvokeFrom.EXPLORE + assert AgentObservabilityService.resolve_source("api") == InvokeFrom.SERVICE_API + assert AgentObservabilityService.resolve_source("web_app") == InvokeFrom.WEB_APP + + with pytest.raises(ValueError, match="Unsupported source"): + AgentObservabilityService.resolve_source("unknown") + + +def test_serialize_log_message_returns_frontend_log_shape() -> None: + created_at = datetime(2026, 6, 17, 1, 2, 3, tzinfo=UTC) + updated_at = datetime(2026, 6, 17, 1, 3, 3, tzinfo=UTC) + message = SimpleNamespace( + id="message-1", + conversation_id="conversation-1", + query="hello", + answer="hi", + error=None, + status=MessageStatus.NORMAL, + invoke_from=InvokeFrom.EXPLORE, + from_source=ConversationFromSource.CONSOLE, + from_end_user_id=None, + from_account_id="account-1", + message_tokens=3, + answer_tokens=4, + total_price=Decimal("0.0001"), + currency="USD", + provider_response_latency=1.25, + created_at=created_at, + updated_at=updated_at, + ) + conversation = SimpleNamespace(name="Debug conversation") + + payload = AgentObservabilityService.serialize_log_message(message, conversation) # type: ignore[arg-type] + + assert payload == { + "id": "message-1", + "message_id": "message-1", + "conversation_id": "conversation-1", + "conversation_name": "Debug conversation", + "query": "hello", + "answer": "hi", + "status": "success", + "error": None, + "source": "explore", + "from_source": "console", + "from_end_user_id": None, + "from_account_id": "account-1", + "message_tokens": 3, + "answer_tokens": 4, + "total_tokens": 7, + "total_price": "0.0001", + "currency": "USD", + "latency": 1.25, + "created_at": int(created_at.timestamp()), + "updated_at": int(updated_at.timestamp()), + } + + +def test_build_charts_and_summary_match_monitoring_metrics() -> None: + rows = [ + { + "date": "2026-06-16", + "message_count": 2, + "conversation_count": 1, + "end_user_count": 1, + "token_count": 30, + "total_price": Decimal("0.003"), + "avg_latency": 1.5, + "latency_sum": 3, + "answer_tokens": 12, + "like_count": 1, + }, + { + "date": "2026-06-17", + "message_count": 1, + "conversation_count": 1, + "end_user_count": 1, + "token_count": 20, + "total_price": Decimal("0.002"), + "avg_latency": 2, + "latency_sum": 2, + "answer_tokens": 8, + "like_count": 1, + }, + ] + + charts = AgentObservabilityService._build_charts(rows) + summary = AgentObservabilityService._build_summary(rows) + + assert charts["token_usage"] == [ + {"date": "2026-06-16", "token_count": 30, "total_price": "0.003", "currency": "USD"}, + {"date": "2026-06-17", "token_count": 20, "total_price": "0.002", "currency": "USD"}, + ] + assert charts["average_response_time"] == [ + {"date": "2026-06-16", "latency": 1500.0}, + {"date": "2026-06-17", "latency": 2000.0}, + ] + assert summary == { + "total_messages": 3, + "total_conversations": 2, + "total_end_users": 2, + "total_tokens": 50, + "total_price": "0.005", + "currency": "USD", + "average_session_interactions": 1.5, + "average_response_time": 1666.6667, + "tokens_per_second": 4.0, + "user_satisfaction_rate": 66.67, + } diff --git a/packages/contracts/generated/api/console/agent/orpc.gen.ts b/packages/contracts/generated/api/console/agent/orpc.gen.ts index b749f64453..ba01699e2b 100644 --- a/packages/contracts/generated/api/console/agent/orpc.gen.ts +++ b/packages/contracts/generated/api/console/agent/orpc.gen.ts @@ -29,6 +29,9 @@ import { zGetAgentByAgentIdDriveFilesPreviewResponse, zGetAgentByAgentIdDriveFilesQuery, zGetAgentByAgentIdDriveFilesResponse, + zGetAgentByAgentIdLogsPath, + zGetAgentByAgentIdLogsQuery, + zGetAgentByAgentIdLogsResponse, zGetAgentByAgentIdMessagesByMessageIdPath, zGetAgentByAgentIdMessagesByMessageIdResponse, zGetAgentByAgentIdPath, @@ -41,6 +44,9 @@ import { zGetAgentByAgentIdSandboxFilesReadQuery, zGetAgentByAgentIdSandboxFilesReadResponse, zGetAgentByAgentIdSandboxFilesResponse, + zGetAgentByAgentIdStatisticsSummaryPath, + zGetAgentByAgentIdStatisticsSummaryQuery, + zGetAgentByAgentIdStatisticsSummaryResponse, zGetAgentByAgentIdVersionsByVersionIdPath, zGetAgentByAgentIdVersionsByVersionIdResponse, zGetAgentByAgentIdVersionsPath, @@ -391,10 +397,27 @@ export const files2 = { post: post5, } +export const get9 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdLogs', + path: '/agent/{agent_id}/logs', + tags: ['console'], + }) + .input( + z.object({ params: zGetAgentByAgentIdLogsPath, query: zGetAgentByAgentIdLogsQuery.optional() }), + ) + .output(zGetAgentByAgentIdLogsResponse) + +export const logs = { + get: get9, +} + /** * Get Agent App message details by ID */ -export const get9 = oc +export const get10 = oc .route({ description: 'Get Agent App message details by ID', inputStructure: 'detailed', @@ -407,7 +430,7 @@ export const get9 = oc .output(zGetAgentByAgentIdMessagesByMessageIdResponse) export const byMessageId2 = { - get: get9, + get: get10, } export const messages = { @@ -417,7 +440,7 @@ export const messages = { /** * List workflow apps that reference this Agent App's bound Agent (read-only) */ -export const get10 = oc +export const get11 = oc .route({ description: 'List workflow apps that reference this Agent App\'s bound Agent (read-only)', inputStructure: 'detailed', @@ -430,13 +453,13 @@ export const get10 = oc .output(zGetAgentByAgentIdReferencingWorkflowsResponse) export const referencingWorkflows = { - get: get10, + get: get11, } /** * Read a text/binary preview file in an Agent App conversation sandbox */ -export const get11 = oc +export const get12 = oc .route({ description: 'Read a text/binary preview file in an Agent App conversation sandbox', inputStructure: 'detailed', @@ -454,7 +477,7 @@ export const get11 = oc .output(zGetAgentByAgentIdSandboxFilesReadResponse) export const read = { - get: get11, + get: get12, } /** @@ -484,7 +507,7 @@ export const upload = { /** * List a directory in an Agent App conversation sandbox */ -export const get12 = oc +export const get13 = oc .route({ description: 'List a directory in an Agent App conversation sandbox', inputStructure: 'detailed', @@ -502,7 +525,7 @@ export const get12 = oc .output(zGetAgentByAgentIdSandboxFilesResponse) export const files3 = { - get: get12, + get: get13, read, upload, } @@ -596,7 +619,31 @@ export const skills = { bySlug, } -export const get13 = oc +export const get14 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdStatisticsSummary', + path: '/agent/{agent_id}/statistics/summary', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdStatisticsSummaryPath, + query: zGetAgentByAgentIdStatisticsSummaryQuery.optional(), + }), + ) + .output(zGetAgentByAgentIdStatisticsSummaryResponse) + +export const summary = { + get: get14, +} + +export const statistics = { + summary, +} + +export const get15 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -608,10 +655,10 @@ export const get13 = oc .output(zGetAgentByAgentIdVersionsByVersionIdResponse) export const byVersionId = { - get: get13, + get: get15, } -export const get14 = oc +export const get16 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -623,7 +670,7 @@ export const get14 = oc .output(zGetAgentByAgentIdVersionsResponse) export const versions = { - get: get14, + get: get16, byVersionId, } @@ -639,7 +686,7 @@ export const delete3 = oc .input(z.object({ params: zDeleteAgentByAgentIdPath })) .output(zDeleteAgentByAgentIdResponse) -export const get15 = oc +export const get17 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -663,7 +710,7 @@ export const put2 = oc export const byAgentId = { delete: delete3, - get: get15, + get: get17, put: put2, chatMessages, composer, @@ -671,14 +718,16 @@ export const byAgentId = { features, feedbacks, files: files2, + logs, messages, referencingWorkflows, sandbox, skills, + statistics, versions, } -export const get16 = oc +export const get18 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -702,7 +751,7 @@ export const post10 = oc .output(zPostAgentResponse) export const agent = { - get: get16, + get: get18, post: post10, inviteOptions, byAgentId, diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 2f7d2ee0af..5d123216d7 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -169,6 +169,14 @@ export type AgentDriveFileCommitResponse = { file: AgentDriveFileResponse } +export type AgentLogListResponse = { + data: Array + has_more: boolean + limit: number + page: number + total: number +} + export type MessageDetailResponse = { agent_thoughts?: Array annotation?: ConversationAnnotation | null @@ -242,6 +250,12 @@ export type SkillToolInferenceResult = { reason?: string | null } +export type AgentStatisticSummaryEnvelopeResponse = { + charts: AgentStatisticChartsResponse + source: string + summary: AgentStatisticSummaryResponse +} + export type AgentConfigSnapshotListResponse = { data: Array } @@ -530,6 +544,29 @@ export type AgentDriveFileResponse = { size?: number | null } +export type AgentLogItemResponse = { + answer: string + answer_tokens: number + conversation_id: string + conversation_name?: string | null + created_at?: number | null + currency: string + error?: string | null + from_account_id?: string | null + from_end_user_id?: string | null + from_source?: string | null + id: string + latency: number + message_id: string + message_tokens: number + query: string + source?: string | null + status: string + total_price: string + total_tokens: number + updated_at?: number | null +} + export type AgentThought = { chain_id?: string | null created_at?: number | null @@ -644,6 +681,30 @@ export type CliToolSuggestion = { name: string } +export type AgentStatisticChartsResponse = { + average_response_time?: Array + average_session_interactions?: Array + daily_conversations?: Array + daily_end_users?: Array + daily_messages?: Array + token_usage?: Array + tokens_per_second?: Array + user_satisfaction_rate?: Array +} + +export type AgentStatisticSummaryResponse = { + average_response_time: number + average_session_interactions: number + currency: string + tokens_per_second: number + total_conversations: number + total_end_users: number + total_messages: number + total_price: string + total_tokens: number + user_satisfaction_rate: number +} + export type AgentConfigRevisionResponse = { created_at?: number | null created_by?: string | null @@ -953,6 +1014,48 @@ export type EnvSuggestion = { secret_likely?: boolean } +export type AgentAverageResponseTimeStatisticResponse = { + date: string + latency: number +} + +export type AgentAverageSessionInteractionStatisticResponse = { + date: string + interactions: number +} + +export type AgentDailyConversationStatisticResponse = { + conversation_count: number + date: string +} + +export type AgentDailyEndUserStatisticResponse = { + date: string + terminal_count: number +} + +export type AgentDailyMessageStatisticResponse = { + date: string + message_count: number +} + +export type AgentTokenUsageStatisticResponse = { + currency: string + date: string + token_count: number + total_price: string +} + +export type AgentTokensPerSecondStatisticResponse = { + date: string + tps: number +} + +export type AgentUserSatisfactionRateStatisticResponse = { + date: string + rate: number +} + export type AgentConfigRevisionOperation = | 'create_version' | 'save_current_version' @@ -1717,6 +1820,30 @@ export type PostAgentByAgentIdFilesResponses = { export type PostAgentByAgentIdFilesResponse = PostAgentByAgentIdFilesResponses[keyof PostAgentByAgentIdFilesResponses] +export type GetAgentByAgentIdLogsData = { + body?: never + path: { + agent_id: string + } + query?: { + end?: string + keyword?: string + limit?: number + page?: number + source?: string + start?: string + status?: string + } + url: '/agent/{agent_id}/logs' +} + +export type GetAgentByAgentIdLogsResponses = { + 200: AgentLogListResponse +} + +export type GetAgentByAgentIdLogsResponse + = GetAgentByAgentIdLogsResponses[keyof GetAgentByAgentIdLogsResponses] + export type GetAgentByAgentIdMessagesByMessageIdData = { body?: never path: { @@ -1886,6 +2013,26 @@ export type PostAgentByAgentIdSkillsBySlugInferToolsResponses = { export type PostAgentByAgentIdSkillsBySlugInferToolsResponse = PostAgentByAgentIdSkillsBySlugInferToolsResponses[keyof PostAgentByAgentIdSkillsBySlugInferToolsResponses] +export type GetAgentByAgentIdStatisticsSummaryData = { + body?: never + path: { + agent_id: string + } + query?: { + end?: string + source?: string + start?: string + } + url: '/agent/{agent_id}/statistics/summary' +} + +export type GetAgentByAgentIdStatisticsSummaryResponses = { + 200: AgentStatisticSummaryEnvelopeResponse +} + +export type GetAgentByAgentIdStatisticsSummaryResponse + = GetAgentByAgentIdStatisticsSummaryResponses[keyof GetAgentByAgentIdStatisticsSummaryResponses] + export type GetAgentByAgentIdVersionsData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 2e1ffadc4b..5232696ab5 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -321,6 +321,43 @@ export const zAgentDriveFileCommitResponse = z.object({ file: zAgentDriveFileResponse, }) +/** + * AgentLogItemResponse + */ +export const zAgentLogItemResponse = z.object({ + answer: z.string(), + answer_tokens: z.int(), + conversation_id: z.string(), + conversation_name: z.string().nullish(), + created_at: z.int().nullish(), + currency: z.string(), + error: z.string().nullish(), + from_account_id: z.string().nullish(), + from_end_user_id: z.string().nullish(), + from_source: z.string().nullish(), + id: z.string(), + latency: z.number(), + message_id: z.string(), + message_tokens: z.int(), + query: z.string(), + source: z.string().nullish(), + status: z.string(), + total_price: z.string(), + total_tokens: z.int(), + updated_at: z.int().nullish(), +}) + +/** + * AgentLogListResponse + */ +export const zAgentLogListResponse = z.object({ + data: z.array(zAgentLogItemResponse), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + /** * AgentThought */ @@ -458,6 +495,22 @@ export const zAgentSkillUploadResponse = z.object({ skill: zAgentSkillRefConfig, }) +/** + * AgentStatisticSummaryResponse + */ +export const zAgentStatisticSummaryResponse = z.object({ + average_response_time: z.number(), + average_session_interactions: z.number(), + currency: z.string(), + tokens_per_second: z.number(), + total_conversations: z.int(), + total_end_users: z.int(), + total_messages: z.int(), + total_price: z.string(), + total_tokens: z.int(), + user_satisfaction_rate: z.number(), +}) + /** * ModelConfigPartial */ @@ -885,6 +938,97 @@ export const zSkillToolInferenceResult = z.object({ reason: z.string().nullish(), }) +/** + * AgentAverageResponseTimeStatisticResponse + */ +export const zAgentAverageResponseTimeStatisticResponse = z.object({ + date: z.string(), + latency: z.number(), +}) + +/** + * AgentAverageSessionInteractionStatisticResponse + */ +export const zAgentAverageSessionInteractionStatisticResponse = z.object({ + date: z.string(), + interactions: z.number(), +}) + +/** + * AgentDailyConversationStatisticResponse + */ +export const zAgentDailyConversationStatisticResponse = z.object({ + conversation_count: z.int(), + date: z.string(), +}) + +/** + * AgentDailyEndUserStatisticResponse + */ +export const zAgentDailyEndUserStatisticResponse = z.object({ + date: z.string(), + terminal_count: z.int(), +}) + +/** + * AgentDailyMessageStatisticResponse + */ +export const zAgentDailyMessageStatisticResponse = z.object({ + date: z.string(), + message_count: z.int(), +}) + +/** + * AgentTokenUsageStatisticResponse + */ +export const zAgentTokenUsageStatisticResponse = z.object({ + currency: z.string(), + date: z.string(), + token_count: z.int(), + total_price: z.string(), +}) + +/** + * AgentTokensPerSecondStatisticResponse + */ +export const zAgentTokensPerSecondStatisticResponse = z.object({ + date: z.string(), + tps: z.number(), +}) + +/** + * AgentUserSatisfactionRateStatisticResponse + */ +export const zAgentUserSatisfactionRateStatisticResponse = z.object({ + date: z.string(), + rate: z.number(), +}) + +/** + * AgentStatisticChartsResponse + */ +export const zAgentStatisticChartsResponse = z.object({ + average_response_time: z.array(zAgentAverageResponseTimeStatisticResponse).optional(), + average_session_interactions: z + .array(zAgentAverageSessionInteractionStatisticResponse) + .optional(), + daily_conversations: z.array(zAgentDailyConversationStatisticResponse).optional(), + daily_end_users: z.array(zAgentDailyEndUserStatisticResponse).optional(), + daily_messages: z.array(zAgentDailyMessageStatisticResponse).optional(), + token_usage: z.array(zAgentTokenUsageStatisticResponse).optional(), + tokens_per_second: z.array(zAgentTokensPerSecondStatisticResponse).optional(), + user_satisfaction_rate: z.array(zAgentUserSatisfactionRateStatisticResponse).optional(), +}) + +/** + * AgentStatisticSummaryEnvelopeResponse + */ +export const zAgentStatisticSummaryEnvelopeResponse = z.object({ + charts: zAgentStatisticChartsResponse, + source: z.string(), + summary: zAgentStatisticSummaryResponse, +}) + /** * AgentConfigRevisionOperation * @@ -2106,6 +2250,25 @@ export const zPostAgentByAgentIdFilesPath = z.object({ */ export const zPostAgentByAgentIdFilesResponse = zAgentDriveFileCommitResponse +export const zGetAgentByAgentIdLogsPath = z.object({ + agent_id: z.string(), +}) + +export const zGetAgentByAgentIdLogsQuery = z.object({ + end: z.string().optional(), + keyword: z.string().optional(), + limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).optional().default(1), + source: z.string().optional(), + start: z.string().optional(), + status: z.string().optional(), +}) + +/** + * Agent logs + */ +export const zGetAgentByAgentIdLogsResponse = zAgentLogListResponse + export const zGetAgentByAgentIdMessagesByMessageIdPath = z.object({ agent_id: z.string(), message_id: z.string(), @@ -2202,6 +2365,21 @@ export const zPostAgentByAgentIdSkillsBySlugInferToolsPath = z.object({ */ export const zPostAgentByAgentIdSkillsBySlugInferToolsResponse = zSkillToolInferenceResult +export const zGetAgentByAgentIdStatisticsSummaryPath = z.object({ + agent_id: z.string(), +}) + +export const zGetAgentByAgentIdStatisticsSummaryQuery = z.object({ + end: z.string().optional(), + source: z.string().optional(), + start: z.string().optional(), +}) + +/** + * Agent monitoring summary and chart data + */ +export const zGetAgentByAgentIdStatisticsSummaryResponse = zAgentStatisticSummaryEnvelopeResponse + export const zGetAgentByAgentIdVersionsPath = z.object({ agent_id: z.string(), })