mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 02:43:49 +08:00
refactor(console): decouple app tracing configuration (#41576)
This commit is contained in:
parent
dde1d500b5
commit
4b0e260ac5
@ -264,6 +264,21 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:app-tracing-config-service-boundary]
|
||||
name = App tracing configuration service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.app_tracing_config_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:webapp-access-query-service-boundary]
|
||||
name = Web app access query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
|
||||
@ -107,24 +107,42 @@ class DraftWorkflowNotSync(BaseHTTPException):
|
||||
code = 409
|
||||
|
||||
|
||||
class TracingConfigNotExist(BaseHTTPException):
|
||||
error_code = "trace_config_not_exist"
|
||||
description = "Trace config not exist."
|
||||
class TracingConfigNotFoundError(BaseHTTPException):
|
||||
error_code = "trace_config_not_found"
|
||||
description = "Tracing configuration not found."
|
||||
code = 404
|
||||
|
||||
|
||||
class TracingConfigAlreadyExistsError(BaseHTTPException):
|
||||
error_code = "trace_config_already_exists"
|
||||
description = "A tracing configuration already exists for this provider."
|
||||
code = 409
|
||||
|
||||
|
||||
class UnsupportedTracingProviderError(BaseHTTPException):
|
||||
error_code = "unsupported_tracing_provider"
|
||||
description = "The tracing provider is not supported."
|
||||
code = 400
|
||||
|
||||
|
||||
class TracingConfigIsExist(BaseHTTPException):
|
||||
error_code = "trace_config_is_exist"
|
||||
description = "Trace config is exist."
|
||||
class InvalidTracingConfigError(BaseHTTPException):
|
||||
error_code = "invalid_tracing_config"
|
||||
description = "The tracing configuration is invalid."
|
||||
code = 400
|
||||
|
||||
|
||||
class TracingConfigCheckError(BaseHTTPException):
|
||||
error_code = "trace_config_check_error"
|
||||
description = "Invalid Credentials."
|
||||
class TracingConfigVerificationFailedError(BaseHTTPException):
|
||||
error_code = "tracing_config_verification_failed"
|
||||
description = "The tracing configuration could not be verified."
|
||||
code = 400
|
||||
|
||||
|
||||
class TracingConfigProcessingError(BaseHTTPException):
|
||||
error_code = "tracing_config_processing_failed"
|
||||
description = "The tracing configuration could not be processed."
|
||||
code = 500
|
||||
|
||||
|
||||
class InvokeRateLimitError(BaseHTTPException):
|
||||
"""Raised when the Invoke returns rate limit error."""
|
||||
|
||||
|
||||
@ -1,27 +1,49 @@
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
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.app.error import TracingConfigCheckError, TracingConfigIsExist, TracingConfigNotExist
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.app.error import (
|
||||
AppNotFoundError,
|
||||
InvalidTracingConfigError,
|
||||
TracingConfigAlreadyExistsError,
|
||||
TracingConfigNotFoundError,
|
||||
TracingConfigProcessingError,
|
||||
TracingConfigVerificationFailedError,
|
||||
UnsupportedTracingProviderError,
|
||||
)
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from models import App
|
||||
from services.ops_service import OpsService
|
||||
from libs.helper import dump_response
|
||||
from machinery.context import RequestContext
|
||||
from models.account import TenantAccountRole
|
||||
from services.app_tracing_config_service import (
|
||||
AppTracingConfigAlreadyExistsError,
|
||||
AppTracingConfigAppNotFoundError,
|
||||
AppTracingConfigInvalidConfigurationError,
|
||||
AppTracingConfigInvalidProviderError,
|
||||
AppTracingConfigNotFoundError,
|
||||
AppTracingConfigProcessingError,
|
||||
AppTracingConfigRecord,
|
||||
AppTracingConfigVerificationFailedError,
|
||||
)
|
||||
|
||||
_APP_TRACING_CONFIG_EDIT_ROLES = frozenset(
|
||||
{
|
||||
TenantAccountRole.OWNER,
|
||||
TenantAccountRole.ADMIN,
|
||||
TenantAccountRole.EDITOR,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TraceProviderQuery(BaseModel):
|
||||
@ -48,6 +70,18 @@ class TraceAppConfigResponse(ResponseModel):
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: AppTracingConfigRecord) -> "TraceAppConfigResponse":
|
||||
return cls(
|
||||
id=record.id,
|
||||
app_id=record.app_id,
|
||||
tracing_provider=record.tracing_provider,
|
||||
tracing_config=record.tracing_config,
|
||||
is_active=record.is_active,
|
||||
created_at=str(record.created_at),
|
||||
updated_at=str(record.updated_at),
|
||||
)
|
||||
|
||||
|
||||
register_schema_models(console_ns, TraceProviderQuery, TraceConfigPayload)
|
||||
register_response_schema_models(console_ns, TraceAppConfigResponse)
|
||||
@ -68,23 +102,42 @@ class TraceAppConfigApi(Resource):
|
||||
"Tracing configuration retrieved successfully",
|
||||
console_ns.models[TraceAppConfigResponse.__name__],
|
||||
)
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
|
||||
@get_app_model
|
||||
@console_ns.response(400, "Invalid request parameters or unsupported tracing provider")
|
||||
@console_ns.response(404, "Application not found")
|
||||
@console_ns.response(500, "Tracing configuration processing failed")
|
||||
@console_account_admission(
|
||||
rbac_resource_scope=RBACResourceScope.APP,
|
||||
rbac_permission=RBACPermission.APP_TRACING_CONFIG,
|
||||
)
|
||||
@model_validate(TraceProviderQuery)
|
||||
def get(self, req_data: TraceProviderQuery, app_model: App):
|
||||
def get(
|
||||
self,
|
||||
req_data: TraceProviderQuery,
|
||||
request_context: RequestContext,
|
||||
app_id: UUID,
|
||||
):
|
||||
try:
|
||||
trace_config = OpsService.get_tracing_app_config(
|
||||
app_id=app_model.id, tracing_provider=req_data.tracing_provider, session=db.session()
|
||||
trace_config = application_services().app_tracing_configs.get(
|
||||
context=request_context,
|
||||
app_id=str(app_id),
|
||||
tracing_provider=req_data.tracing_provider,
|
||||
)
|
||||
if not trace_config:
|
||||
return {"has_not_configured": True}
|
||||
return trace_config
|
||||
except Exception as e:
|
||||
raise BadRequest(str(e))
|
||||
except AppTracingConfigAppNotFoundError as error:
|
||||
raise AppNotFoundError() from error
|
||||
except AppTracingConfigInvalidProviderError as error:
|
||||
raise UnsupportedTracingProviderError() from error
|
||||
except AppTracingConfigProcessingError as error:
|
||||
raise TracingConfigProcessingError() from error
|
||||
except ValueError as error:
|
||||
raise TracingConfigProcessingError() from error
|
||||
|
||||
if trace_config is None:
|
||||
return dump_response(TraceAppConfigResponse, {"has_not_configured": True}, exclude_none=True)
|
||||
return dump_response(
|
||||
TraceAppConfigResponse,
|
||||
TraceAppConfigResponse.from_record(trace_config),
|
||||
exclude_none=True,
|
||||
)
|
||||
|
||||
@console_ns.doc("create_trace_app_config")
|
||||
@console_ns.doc(description="Create a new tracing configuration for an application")
|
||||
@ -95,31 +148,47 @@ class TraceAppConfigApi(Resource):
|
||||
"Tracing configuration created successfully",
|
||||
console_ns.models[TraceAppConfigResponse.__name__],
|
||||
)
|
||||
@console_ns.response(400, "Invalid request parameters or configuration already exists")
|
||||
@console_ns.response(400, "Invalid request parameters or tracing configuration")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
|
||||
@get_app_model
|
||||
@console_ns.response(404, "Application not found")
|
||||
@console_ns.response(409, "Tracing configuration already exists")
|
||||
@console_ns.response(500, "Tracing configuration processing failed")
|
||||
@console_account_admission(
|
||||
allowed_roles=_APP_TRACING_CONFIG_EDIT_ROLES,
|
||||
rbac_resource_scope=RBACResourceScope.APP,
|
||||
rbac_permission=RBACPermission.APP_TRACING_CONFIG,
|
||||
)
|
||||
@model_validate(TraceConfigPayload)
|
||||
def post(self, req_data: TraceConfigPayload, app_model: App):
|
||||
def post(
|
||||
self,
|
||||
req_data: TraceConfigPayload,
|
||||
request_context: RequestContext,
|
||||
app_id: UUID,
|
||||
):
|
||||
"""Create a new trace app configuration"""
|
||||
try:
|
||||
result = OpsService.create_tracing_app_config(
|
||||
app_id=app_model.id,
|
||||
application_services().app_tracing_configs.create(
|
||||
context=request_context,
|
||||
app_id=str(app_id),
|
||||
tracing_provider=req_data.tracing_provider,
|
||||
tracing_config=req_data.tracing_config,
|
||||
session=db.session(),
|
||||
)
|
||||
if not result:
|
||||
raise TracingConfigIsExist()
|
||||
if result.get("error"):
|
||||
raise TracingConfigCheckError()
|
||||
return result
|
||||
except Exception as e:
|
||||
raise BadRequest(str(e))
|
||||
except AppTracingConfigAppNotFoundError as error:
|
||||
raise AppNotFoundError() from error
|
||||
except AppTracingConfigAlreadyExistsError as error:
|
||||
raise TracingConfigAlreadyExistsError() from error
|
||||
except AppTracingConfigInvalidProviderError as error:
|
||||
raise UnsupportedTracingProviderError() from error
|
||||
except AppTracingConfigInvalidConfigurationError as error:
|
||||
raise InvalidTracingConfigError() from error
|
||||
except AppTracingConfigVerificationFailedError as error:
|
||||
raise TracingConfigVerificationFailedError() from error
|
||||
except AppTracingConfigProcessingError as error:
|
||||
raise TracingConfigProcessingError() from error
|
||||
except ValueError as error:
|
||||
raise TracingConfigProcessingError() from error
|
||||
|
||||
return dump_response(TraceAppConfigResponse, {"result": "success"}, exclude_none=True), 201
|
||||
|
||||
@console_ns.doc("update_trace_app_config")
|
||||
@console_ns.doc(description="Update an existing tracing configuration for an application")
|
||||
@ -130,52 +199,84 @@ class TraceAppConfigApi(Resource):
|
||||
"Tracing configuration updated successfully",
|
||||
console_ns.models[TraceAppConfigResponse.__name__],
|
||||
)
|
||||
@console_ns.response(400, "Invalid request parameters or configuration not found")
|
||||
@console_ns.response(400, "Invalid request parameters or tracing configuration")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
|
||||
@get_app_model
|
||||
@console_ns.response(404, "Application or tracing configuration not found")
|
||||
@console_ns.response(500, "Tracing configuration processing failed")
|
||||
@console_account_admission(
|
||||
allowed_roles=_APP_TRACING_CONFIG_EDIT_ROLES,
|
||||
rbac_resource_scope=RBACResourceScope.APP,
|
||||
rbac_permission=RBACPermission.APP_TRACING_CONFIG,
|
||||
)
|
||||
@model_validate(TraceConfigPayload)
|
||||
def patch(self, req_data: TraceConfigPayload, app_model: App):
|
||||
def patch(
|
||||
self,
|
||||
req_data: TraceConfigPayload,
|
||||
request_context: RequestContext,
|
||||
app_id: UUID,
|
||||
):
|
||||
"""Update an existing trace app configuration"""
|
||||
try:
|
||||
result = OpsService.update_tracing_app_config(
|
||||
app_id=app_model.id,
|
||||
application_services().app_tracing_configs.update(
|
||||
context=request_context,
|
||||
app_id=str(app_id),
|
||||
tracing_provider=req_data.tracing_provider,
|
||||
tracing_config=req_data.tracing_config,
|
||||
session=db.session(),
|
||||
)
|
||||
if not result:
|
||||
raise TracingConfigNotExist()
|
||||
return {"result": "success"}
|
||||
except Exception as e:
|
||||
raise BadRequest(str(e))
|
||||
except AppTracingConfigAppNotFoundError as error:
|
||||
raise AppNotFoundError() from error
|
||||
except AppTracingConfigNotFoundError as error:
|
||||
raise TracingConfigNotFoundError() from error
|
||||
except AppTracingConfigInvalidProviderError as error:
|
||||
raise UnsupportedTracingProviderError() from error
|
||||
except AppTracingConfigInvalidConfigurationError as error:
|
||||
raise InvalidTracingConfigError() from error
|
||||
except AppTracingConfigVerificationFailedError as error:
|
||||
raise TracingConfigVerificationFailedError() from error
|
||||
except AppTracingConfigProcessingError as error:
|
||||
raise TracingConfigProcessingError() from error
|
||||
except ValueError as error:
|
||||
raise TracingConfigProcessingError() from error
|
||||
|
||||
return dump_response(TraceAppConfigResponse, {"result": "success"}, exclude_none=True)
|
||||
|
||||
@console_ns.doc("delete_trace_app_config")
|
||||
@console_ns.doc(description="Delete an existing tracing configuration for an application")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(TraceProviderQuery))
|
||||
@console_ns.response(204, "Tracing configuration deleted successfully")
|
||||
@console_ns.response(400, "Invalid request parameters or configuration not found")
|
||||
@console_ns.response(400, "Invalid request parameters or unsupported tracing provider")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
|
||||
@get_app_model
|
||||
@console_ns.response(404, "Application or tracing configuration not found")
|
||||
@console_ns.response(500, "Tracing configuration processing failed")
|
||||
@console_account_admission(
|
||||
allowed_roles=_APP_TRACING_CONFIG_EDIT_ROLES,
|
||||
rbac_resource_scope=RBACResourceScope.APP,
|
||||
rbac_permission=RBACPermission.APP_TRACING_CONFIG,
|
||||
)
|
||||
@model_validate(TraceProviderQuery)
|
||||
def delete(self, req_data: TraceProviderQuery, app_model: App):
|
||||
def delete(
|
||||
self,
|
||||
req_data: TraceProviderQuery,
|
||||
request_context: RequestContext,
|
||||
app_id: UUID,
|
||||
):
|
||||
"""Delete an existing trace app configuration"""
|
||||
try:
|
||||
result = OpsService.delete_tracing_app_config(
|
||||
app_id=app_model.id, tracing_provider=req_data.tracing_provider, session=db.session()
|
||||
application_services().app_tracing_configs.delete(
|
||||
context=request_context,
|
||||
app_id=str(app_id),
|
||||
tracing_provider=req_data.tracing_provider,
|
||||
)
|
||||
if not result:
|
||||
raise TracingConfigNotExist()
|
||||
return "", 204
|
||||
except Exception as e:
|
||||
raise BadRequest(str(e))
|
||||
except AppTracingConfigAppNotFoundError as error:
|
||||
raise AppNotFoundError() from error
|
||||
except AppTracingConfigNotFoundError as error:
|
||||
raise TracingConfigNotFoundError() from error
|
||||
except AppTracingConfigInvalidProviderError as error:
|
||||
raise UnsupportedTracingProviderError() from error
|
||||
except AppTracingConfigProcessingError as error:
|
||||
raise TracingConfigProcessingError() from error
|
||||
except ValueError as error:
|
||||
raise TracingConfigProcessingError() from error
|
||||
|
||||
return "", 204
|
||||
|
||||
@ -41,6 +41,7 @@ from repositories.account_repository import SQLAlchemyAccountRepository
|
||||
from repositories.app_definition_query_repository import AppDefinitionQueryRepository
|
||||
from repositories.app_site_command_repository import AppSiteCommandRepository
|
||||
from repositories.app_statistic_query_repository import AppStatisticQueryRepository
|
||||
from repositories.app_tracing_config_repository import SQLAlchemyAppTracingConfigRepository
|
||||
from repositories.data_source_api_key_auth_repository import SQLAlchemyDataSourceApiKeyAuthBindingRepository
|
||||
from repositories.data_source_oauth_binding_repository import SQLAlchemyDataSourceOAuthBindingRepository
|
||||
from repositories.explore_banner_query_repository import ExploreBannerQueryRepository
|
||||
@ -134,6 +135,8 @@ from services.account_profile_service import AccountProfileService
|
||||
from services.app_definition_query_service import AppDefinitionQueryService
|
||||
from services.app_site_service import AppSiteService
|
||||
from services.app_statistic_query import AppStatisticQuery
|
||||
from services.app_tracing_config_gateway import OpsTraceManagerGateway
|
||||
from services.app_tracing_config_service import AppTracingConfigService
|
||||
from services.auth.data_source_api_key_auth_gateways import (
|
||||
ProviderApiKeyAuthCredentialValidator,
|
||||
TenantApiKeyAuthCredentialEncryptor,
|
||||
@ -244,6 +247,7 @@ class ApplicationServices:
|
||||
app_definitions: AppDefinitionQueryService
|
||||
app_sites: AppSiteService
|
||||
app_statistics: AppStatisticQuery
|
||||
app_tracing_configs: AppTracingConfigService
|
||||
billing_portal: BillingPortalService
|
||||
compliance_downloads: ComplianceDownloadService
|
||||
data_source_api_key_auth: DataSourceApiKeyAuthService
|
||||
@ -592,6 +596,10 @@ def build_application_services(
|
||||
sites=AppSiteCommandRepository(session_factory=database_client),
|
||||
),
|
||||
app_statistics=AppStatisticQueryRepository(session_factory=database_client),
|
||||
app_tracing_configs=AppTracingConfigService(
|
||||
configs=SQLAlchemyAppTracingConfigRepository(session_factory=database_client),
|
||||
provider=OpsTraceManagerGateway(),
|
||||
),
|
||||
billing_portal=BillingPortalService(
|
||||
accounts=accounts,
|
||||
get_subscription=BillingService.get_subscription,
|
||||
|
||||
@ -3228,8 +3228,10 @@ Delete an existing tracing configuration for an application
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 204 | Tracing configuration deleted successfully |
|
||||
| 400 | Invalid request parameters or configuration not found |
|
||||
| 400 | Invalid request parameters or unsupported tracing provider |
|
||||
| 403 | Insufficient permissions |
|
||||
| 404 | Application or tracing configuration not found |
|
||||
| 500 | Tracing configuration processing failed |
|
||||
|
||||
### [GET] /apps/{app_id}/trace-config
|
||||
Get tracing configuration for an application
|
||||
@ -3246,7 +3248,9 @@ Get tracing configuration for an application
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Tracing configuration retrieved successfully | **application/json**: [TraceAppConfigResponse](#traceappconfigresponse)<br> |
|
||||
| 400 | Invalid request parameters | |
|
||||
| 400 | Invalid request parameters or unsupported tracing provider | |
|
||||
| 404 | Application not found | |
|
||||
| 500 | Tracing configuration processing failed | |
|
||||
|
||||
### [PATCH] /apps/{app_id}/trace-config
|
||||
**Update an existing trace app configuration**
|
||||
@ -3270,8 +3274,10 @@ Update an existing tracing configuration for an application
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Tracing configuration updated successfully | **application/json**: [TraceAppConfigResponse](#traceappconfigresponse)<br> |
|
||||
| 400 | Invalid request parameters or configuration not found | |
|
||||
| 400 | Invalid request parameters or tracing configuration | |
|
||||
| 403 | Insufficient permissions | |
|
||||
| 404 | Application or tracing configuration not found | |
|
||||
| 500 | Tracing configuration processing failed | |
|
||||
|
||||
### [POST] /apps/{app_id}/trace-config
|
||||
**Create a new trace app configuration**
|
||||
@ -3295,8 +3301,11 @@ Create a new tracing configuration for an application
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | Tracing configuration created successfully | **application/json**: [TraceAppConfigResponse](#traceappconfigresponse)<br> |
|
||||
| 400 | Invalid request parameters or configuration already exists | |
|
||||
| 400 | Invalid request parameters or tracing configuration | |
|
||||
| 403 | Insufficient permissions | |
|
||||
| 404 | Application not found | |
|
||||
| 409 | Tracing configuration already exists | |
|
||||
| 500 | Tracing configuration processing failed | |
|
||||
|
||||
### [POST] /apps/{app_id}/trigger-enable
|
||||
**Update app trigger (enable/disable)**
|
||||
|
||||
128
api/repositories/app_tracing_config_repository.py
Normal file
128
api/repositories/app_tracing_config_repository.py
Normal file
@ -0,0 +1,128 @@
|
||||
"""SQLAlchemy persistence adapter for app tracing provider configurations."""
|
||||
|
||||
from typing import Any, override
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.enums import AppStatus
|
||||
from models.model import App, TraceAppConfig
|
||||
from services.app_tracing_config_service import (
|
||||
AppTracingConfigAppNotFoundError,
|
||||
AppTracingConfigRecord,
|
||||
AppTracingConfigStore,
|
||||
)
|
||||
|
||||
|
||||
class SQLAlchemyAppTracingConfigRepository(AppTracingConfigStore):
|
||||
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
@override
|
||||
def get(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
app_id: str,
|
||||
tracing_provider: str,
|
||||
) -> AppTracingConfigRecord | None:
|
||||
with self._session_factory() as session:
|
||||
self._require_app(session, workspace_id, app_id)
|
||||
config = self._get_config(session, app_id, tracing_provider)
|
||||
return self._to_record(config) if config is not None else None
|
||||
|
||||
@override
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
app_id: str,
|
||||
tracing_provider: str,
|
||||
tracing_config: dict[str, Any],
|
||||
) -> bool:
|
||||
with self._session_factory.begin() as session:
|
||||
self._require_app(session, workspace_id, app_id)
|
||||
if self._get_config(session, app_id, tracing_provider) is not None:
|
||||
return False
|
||||
|
||||
session.add(
|
||||
TraceAppConfig(
|
||||
app_id=app_id,
|
||||
tracing_provider=tracing_provider,
|
||||
tracing_config=dict(tracing_config),
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
@override
|
||||
def update(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
app_id: str,
|
||||
tracing_provider: str,
|
||||
tracing_config: dict[str, Any],
|
||||
) -> bool:
|
||||
with self._session_factory.begin() as session:
|
||||
self._require_app(session, workspace_id, app_id)
|
||||
config = self._get_config(session, app_id, tracing_provider)
|
||||
if config is None:
|
||||
return False
|
||||
|
||||
config.tracing_config = dict(tracing_config)
|
||||
return True
|
||||
|
||||
@override
|
||||
def delete(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
app_id: str,
|
||||
tracing_provider: str,
|
||||
) -> bool:
|
||||
with self._session_factory.begin() as session:
|
||||
self._require_app(session, workspace_id, app_id)
|
||||
config = self._get_config(session, app_id, tracing_provider)
|
||||
if config is None:
|
||||
return False
|
||||
|
||||
session.delete(config)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _require_app(session: Session, workspace_id: str, app_id: str) -> None:
|
||||
app_exists = session.scalar(
|
||||
select(App.id)
|
||||
.where(
|
||||
App.id == app_id,
|
||||
App.tenant_id == workspace_id,
|
||||
App.status == AppStatus.NORMAL,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if app_exists is None:
|
||||
raise AppTracingConfigAppNotFoundError
|
||||
|
||||
@staticmethod
|
||||
def _get_config(session: Session, app_id: str, tracing_provider: str) -> TraceAppConfig | None:
|
||||
return session.scalar(
|
||||
select(TraceAppConfig)
|
||||
.where(
|
||||
TraceAppConfig.app_id == app_id,
|
||||
TraceAppConfig.tracing_provider == tracing_provider,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _to_record(config: TraceAppConfig) -> AppTracingConfigRecord:
|
||||
tracing_config = dict(config.tracing_config) if config.tracing_config is not None else None
|
||||
return AppTracingConfigRecord(
|
||||
id=config.id,
|
||||
app_id=config.app_id,
|
||||
tracing_provider=config.tracing_provider,
|
||||
tracing_config=tracing_config,
|
||||
is_active=config.is_active,
|
||||
created_at=config.created_at,
|
||||
updated_at=config.updated_at,
|
||||
)
|
||||
184
api/services/app_tracing_config_gateway.py
Normal file
184
api/services/app_tracing_config_gateway.py
Normal file
@ -0,0 +1,184 @@
|
||||
"""Compatibility gateway for the legacy tracing provider implementations."""
|
||||
|
||||
import logging
|
||||
from typing import Any, override
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from core.ops.entities.config_entity import BaseTracingConfig
|
||||
from core.ops.ops_trace_manager import OpsTraceManager, TracingProviderConfigEntry, provider_config_map
|
||||
from services.app_tracing_config_service import (
|
||||
AppTracingConfigInvalidConfigurationError,
|
||||
AppTracingConfigInvalidProviderError,
|
||||
AppTracingConfigProcessingError,
|
||||
AppTracingConfigVerificationFailedError,
|
||||
TracingConfigProviderGateway,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PROJECT_URL_FALLBACKS = {
|
||||
"arize": "https://app.arize.com/",
|
||||
"phoenix": "https://app.phoenix.arize.com/projects/",
|
||||
"langsmith": "https://smith.langchain.com/",
|
||||
"opik": "https://www.comet.com/opik/",
|
||||
"weave": "https://wandb.ai/",
|
||||
"aliyun": "https://arms.console.aliyun.com/",
|
||||
"tencent": "https://console.cloud.tencent.com/apm",
|
||||
"mlflow": "http://localhost:5000/",
|
||||
"databricks": "https://www.databricks.com/",
|
||||
}
|
||||
|
||||
|
||||
class OpsTraceManagerGateway(TracingConfigProviderGateway):
|
||||
@override
|
||||
def validate_provider(self, tracing_provider: str) -> None:
|
||||
self._provider_config(tracing_provider)
|
||||
|
||||
@override
|
||||
def prepare_new_config(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
tracing_provider: str,
|
||||
tracing_config: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
provider_config = self._provider_config(tracing_provider)
|
||||
normalized_config = self._normalize_config(provider_config, tracing_config)
|
||||
self._verify_config(normalized_config, tracing_provider)
|
||||
|
||||
project_url = self._get_project_url_for_create(normalized_config, tracing_provider)
|
||||
try:
|
||||
encrypted_config = OpsTraceManager.encrypt_tracing_config(
|
||||
workspace_id,
|
||||
tracing_provider,
|
||||
normalized_config,
|
||||
)
|
||||
except Exception as error:
|
||||
raise AppTracingConfigProcessingError from error
|
||||
if project_url:
|
||||
encrypted_config["project_url"] = project_url
|
||||
return encrypted_config
|
||||
|
||||
@override
|
||||
def prepare_updated_config(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
tracing_provider: str,
|
||||
tracing_config: dict[str, Any],
|
||||
current_tracing_config: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
provider_config = self._provider_config(tracing_provider)
|
||||
self._validate_config(provider_config, tracing_config)
|
||||
try:
|
||||
encrypted_config = OpsTraceManager.encrypt_tracing_config(
|
||||
workspace_id,
|
||||
tracing_provider,
|
||||
dict(tracing_config),
|
||||
current_tracing_config,
|
||||
)
|
||||
decrypted_config = OpsTraceManager.decrypt_tracing_config(
|
||||
workspace_id,
|
||||
tracing_provider,
|
||||
encrypted_config,
|
||||
)
|
||||
except Exception as error:
|
||||
raise AppTracingConfigProcessingError from error
|
||||
|
||||
self._verify_config(decrypted_config, tracing_provider)
|
||||
return encrypted_config
|
||||
|
||||
@override
|
||||
def present_config(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
tracing_provider: str,
|
||||
tracing_config: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
if tracing_config is None:
|
||||
raise AppTracingConfigProcessingError
|
||||
|
||||
try:
|
||||
decrypted_config = OpsTraceManager.decrypt_tracing_config(
|
||||
workspace_id,
|
||||
tracing_provider,
|
||||
tracing_config,
|
||||
)
|
||||
presented_config = OpsTraceManager.obfuscated_decrypt_token(tracing_provider, decrypted_config)
|
||||
except Exception as error:
|
||||
raise AppTracingConfigProcessingError from error
|
||||
|
||||
if tracing_provider == "langfuse" and not decrypted_config.get("project_key"):
|
||||
try:
|
||||
project_key = OpsTraceManager.get_trace_config_project_key(decrypted_config, tracing_provider)
|
||||
presented_config["project_url"] = f"{decrypted_config.get('host')}/project/{project_key}"
|
||||
except Exception:
|
||||
presented_config["project_url"] = f"{decrypted_config.get('host')}/"
|
||||
elif tracing_provider in _PROJECT_URL_FALLBACKS and not decrypted_config.get("project_url"):
|
||||
try:
|
||||
presented_config["project_url"] = OpsTraceManager.get_trace_config_project_url(
|
||||
decrypted_config,
|
||||
tracing_provider,
|
||||
)
|
||||
except Exception:
|
||||
presented_config["project_url"] = _PROJECT_URL_FALLBACKS[tracing_provider]
|
||||
|
||||
return presented_config
|
||||
|
||||
@staticmethod
|
||||
def _provider_config(tracing_provider: str) -> TracingProviderConfigEntry:
|
||||
try:
|
||||
return provider_config_map[tracing_provider]
|
||||
except KeyError as error:
|
||||
raise AppTracingConfigInvalidProviderError(tracing_provider) from error
|
||||
|
||||
@staticmethod
|
||||
def _normalize_config(
|
||||
provider_config: TracingProviderConfigEntry,
|
||||
tracing_config: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
normalized_config = dict(tracing_config)
|
||||
default_config = OpsTraceManagerGateway._validate_config(provider_config, normalized_config)
|
||||
|
||||
default_values = default_config.model_dump()
|
||||
for key in provider_config["other_keys"]:
|
||||
if normalized_config.get(key) == "":
|
||||
normalized_config[key] = default_values.get(key)
|
||||
return normalized_config
|
||||
|
||||
@staticmethod
|
||||
def _validate_config(
|
||||
provider_config: TracingProviderConfigEntry,
|
||||
tracing_config: dict[str, Any],
|
||||
) -> BaseTracingConfig:
|
||||
config_class: type[BaseTracingConfig] = provider_config["config_class"]
|
||||
try:
|
||||
return config_class.model_validate(tracing_config)
|
||||
except ValidationError as error:
|
||||
raise AppTracingConfigInvalidConfigurationError from error
|
||||
|
||||
@staticmethod
|
||||
def _verify_config(tracing_config: dict[str, Any], tracing_provider: str) -> None:
|
||||
try:
|
||||
is_effective = OpsTraceManager.check_trace_config_is_effective(tracing_config, tracing_provider)
|
||||
except ValueError as error:
|
||||
logger.warning("Tracing configuration verification failed for provider %s", tracing_provider, exc_info=True)
|
||||
raise AppTracingConfigVerificationFailedError from error
|
||||
if not is_effective:
|
||||
raise AppTracingConfigVerificationFailedError
|
||||
|
||||
@staticmethod
|
||||
def _get_project_url_for_create(tracing_config: dict[str, Any], tracing_provider: str) -> str | None:
|
||||
try:
|
||||
if tracing_provider in ("arize", "phoenix"):
|
||||
return OpsTraceManager.get_trace_config_project_url(tracing_config, tracing_provider)
|
||||
if tracing_provider == "langfuse":
|
||||
project_key = OpsTraceManager.get_trace_config_project_key(tracing_config, tracing_provider)
|
||||
return f"{tracing_config.get('host')}/project/{project_key}"
|
||||
if tracing_provider in ("langsmith", "opik", "mlflow", "databricks", "tencent"):
|
||||
return OpsTraceManager.get_trace_config_project_url(tracing_config, tracing_provider)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
230
api/services/app_tracing_config_service.py
Normal file
230
api/services/app_tracing_config_service.py
Normal file
@ -0,0 +1,230 @@
|
||||
"""Application boundary for app tracing provider configurations."""
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
from machinery.context import RequestContext
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AppTracingConfigRecord:
|
||||
id: str
|
||||
app_id: str
|
||||
tracing_provider: str | None
|
||||
tracing_config: dict[str, Any] | None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AppTracingConfigStore(Protocol):
|
||||
def get(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
app_id: str,
|
||||
tracing_provider: str,
|
||||
) -> AppTracingConfigRecord | None: ...
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
app_id: str,
|
||||
tracing_provider: str,
|
||||
tracing_config: dict[str, Any],
|
||||
) -> bool: ...
|
||||
|
||||
def update(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
app_id: str,
|
||||
tracing_provider: str,
|
||||
tracing_config: dict[str, Any],
|
||||
) -> bool: ...
|
||||
|
||||
def delete(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
app_id: str,
|
||||
tracing_provider: str,
|
||||
) -> bool: ...
|
||||
|
||||
|
||||
class TracingConfigProviderGateway(Protocol):
|
||||
def validate_provider(self, tracing_provider: str) -> None: ...
|
||||
|
||||
def prepare_new_config(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
tracing_provider: str,
|
||||
tracing_config: dict[str, Any],
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
def prepare_updated_config(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
tracing_provider: str,
|
||||
tracing_config: dict[str, Any],
|
||||
current_tracing_config: dict[str, Any] | None,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
def present_config(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
tracing_provider: str,
|
||||
tracing_config: dict[str, Any] | None,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class AppTracingConfigError(Exception):
|
||||
"""Base class for framework-neutral app tracing configuration failures."""
|
||||
|
||||
|
||||
class AppTracingConfigAppNotFoundError(AppTracingConfigError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("App not found")
|
||||
|
||||
|
||||
class AppTracingConfigAlreadyExistsError(AppTracingConfigError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Trace config is exist.")
|
||||
|
||||
|
||||
class AppTracingConfigNotFoundError(AppTracingConfigError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Trace config not exist.")
|
||||
|
||||
|
||||
class AppTracingConfigInvalidProviderError(AppTracingConfigError):
|
||||
def __init__(self, tracing_provider: str) -> None:
|
||||
super().__init__(f"Invalid tracing provider: {tracing_provider}")
|
||||
|
||||
|
||||
class AppTracingConfigInvalidConfigurationError(AppTracingConfigError):
|
||||
"""The submitted provider configuration does not match its schema."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Invalid tracing configuration")
|
||||
|
||||
|
||||
class AppTracingConfigVerificationFailedError(AppTracingConfigError):
|
||||
"""The submitted configuration could not be verified by its provider."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Tracing configuration verification failed")
|
||||
|
||||
|
||||
class AppTracingConfigProcessingError(AppTracingConfigError):
|
||||
"""A validated or stored configuration could not be processed internally."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Tracing configuration processing failed")
|
||||
|
||||
|
||||
class AppTracingConfigService:
|
||||
def __init__(self, *, configs: AppTracingConfigStore, provider: TracingConfigProviderGateway) -> None:
|
||||
self._configs = configs
|
||||
self._provider = provider
|
||||
|
||||
def get(
|
||||
self,
|
||||
context: RequestContext,
|
||||
app_id: str,
|
||||
tracing_provider: str,
|
||||
) -> AppTracingConfigRecord | None:
|
||||
workspace_id = context.active_workspace_id
|
||||
self._provider.validate_provider(tracing_provider)
|
||||
record = self._configs.get(
|
||||
workspace_id=workspace_id,
|
||||
app_id=app_id,
|
||||
tracing_provider=tracing_provider,
|
||||
)
|
||||
if record is None:
|
||||
return None
|
||||
|
||||
tracing_config = self._provider.present_config(
|
||||
workspace_id=workspace_id,
|
||||
tracing_provider=tracing_provider,
|
||||
tracing_config=record.tracing_config,
|
||||
)
|
||||
return replace(record, tracing_config=tracing_config)
|
||||
|
||||
def create(
|
||||
self,
|
||||
context: RequestContext,
|
||||
app_id: str,
|
||||
tracing_provider: str,
|
||||
tracing_config: dict[str, Any],
|
||||
) -> None:
|
||||
workspace_id = context.active_workspace_id
|
||||
current = self._configs.get(
|
||||
workspace_id=workspace_id,
|
||||
app_id=app_id,
|
||||
tracing_provider=tracing_provider,
|
||||
)
|
||||
if current is not None:
|
||||
raise AppTracingConfigAlreadyExistsError
|
||||
|
||||
encrypted_config = self._provider.prepare_new_config(
|
||||
workspace_id=workspace_id,
|
||||
tracing_provider=tracing_provider,
|
||||
tracing_config=tracing_config,
|
||||
)
|
||||
|
||||
created = self._configs.create(
|
||||
workspace_id=workspace_id,
|
||||
app_id=app_id,
|
||||
tracing_provider=tracing_provider,
|
||||
tracing_config=encrypted_config,
|
||||
)
|
||||
if not created:
|
||||
raise AppTracingConfigAlreadyExistsError
|
||||
|
||||
def update(
|
||||
self,
|
||||
context: RequestContext,
|
||||
app_id: str,
|
||||
tracing_provider: str,
|
||||
tracing_config: dict[str, Any],
|
||||
) -> None:
|
||||
workspace_id = context.active_workspace_id
|
||||
current = self._configs.get(
|
||||
workspace_id=workspace_id,
|
||||
app_id=app_id,
|
||||
tracing_provider=tracing_provider,
|
||||
)
|
||||
self._provider.validate_provider(tracing_provider)
|
||||
if current is None:
|
||||
raise AppTracingConfigNotFoundError
|
||||
|
||||
encrypted_config = self._provider.prepare_updated_config(
|
||||
workspace_id=workspace_id,
|
||||
tracing_provider=tracing_provider,
|
||||
tracing_config=tracing_config,
|
||||
current_tracing_config=current.tracing_config,
|
||||
)
|
||||
updated = self._configs.update(
|
||||
workspace_id=workspace_id,
|
||||
app_id=app_id,
|
||||
tracing_provider=tracing_provider,
|
||||
tracing_config=encrypted_config,
|
||||
)
|
||||
if not updated:
|
||||
raise AppTracingConfigNotFoundError
|
||||
|
||||
def delete(self, context: RequestContext, app_id: str, tracing_provider: str) -> None:
|
||||
self._provider.validate_provider(tracing_provider)
|
||||
deleted = self._configs.delete(
|
||||
workspace_id=context.active_workspace_id,
|
||||
app_id=app_id,
|
||||
tracing_provider=tracing_provider,
|
||||
)
|
||||
if not deleted:
|
||||
raise AppTracingConfigNotFoundError
|
||||
@ -1,282 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.ops.entities.config_entity import BaseTracingConfig
|
||||
from core.ops.ops_trace_manager import OpsTraceManager, TracingProviderConfigEntry, provider_config_map
|
||||
from models.model import App, TraceAppConfig
|
||||
|
||||
|
||||
class OpsService:
|
||||
@classmethod
|
||||
def get_tracing_app_config(cls, app_id: str, tracing_provider: str, session: Session):
|
||||
"""
|
||||
Get tracing app config
|
||||
:param app_id: app id
|
||||
:param tracing_provider: tracing provider
|
||||
:return:
|
||||
"""
|
||||
trace_config_data: TraceAppConfig | None = session.scalar(
|
||||
select(TraceAppConfig)
|
||||
.where(TraceAppConfig.app_id == app_id, TraceAppConfig.tracing_provider == tracing_provider)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if not trace_config_data:
|
||||
return None
|
||||
|
||||
# decrypt_token and obfuscated_token
|
||||
app = session.get(App, app_id)
|
||||
if not app:
|
||||
return None
|
||||
tenant_id = app.tenant_id
|
||||
if trace_config_data.tracing_config is None:
|
||||
raise ValueError("Tracing config cannot be None.")
|
||||
decrypt_tracing_config = OpsTraceManager.decrypt_tracing_config(
|
||||
tenant_id, tracing_provider, trace_config_data.tracing_config
|
||||
)
|
||||
new_decrypt_tracing_config = OpsTraceManager.obfuscated_decrypt_token(tracing_provider, decrypt_tracing_config)
|
||||
|
||||
if tracing_provider == "arize" and (
|
||||
"project_url" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_url")
|
||||
):
|
||||
try:
|
||||
project_url = OpsTraceManager.get_trace_config_project_url(decrypt_tracing_config, tracing_provider)
|
||||
new_decrypt_tracing_config.update({"project_url": project_url})
|
||||
except Exception:
|
||||
new_decrypt_tracing_config.update({"project_url": "https://app.arize.com/"})
|
||||
|
||||
if tracing_provider == "phoenix" and (
|
||||
"project_url" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_url")
|
||||
):
|
||||
try:
|
||||
project_url = OpsTraceManager.get_trace_config_project_url(decrypt_tracing_config, tracing_provider)
|
||||
new_decrypt_tracing_config.update({"project_url": project_url})
|
||||
except Exception:
|
||||
new_decrypt_tracing_config.update({"project_url": "https://app.phoenix.arize.com/projects/"})
|
||||
|
||||
if tracing_provider == "langfuse" and (
|
||||
"project_key" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_key")
|
||||
):
|
||||
try:
|
||||
project_key = OpsTraceManager.get_trace_config_project_key(decrypt_tracing_config, tracing_provider)
|
||||
new_decrypt_tracing_config.update(
|
||||
{
|
||||
"project_url": "{host}/project/{key}".format(
|
||||
host=decrypt_tracing_config.get("host"), key=project_key
|
||||
)
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
new_decrypt_tracing_config.update({"project_url": f"{decrypt_tracing_config.get('host')}/"})
|
||||
|
||||
if tracing_provider == "langsmith" and (
|
||||
"project_url" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_url")
|
||||
):
|
||||
try:
|
||||
project_url = OpsTraceManager.get_trace_config_project_url(decrypt_tracing_config, tracing_provider)
|
||||
new_decrypt_tracing_config.update({"project_url": project_url})
|
||||
except Exception:
|
||||
new_decrypt_tracing_config.update({"project_url": "https://smith.langchain.com/"})
|
||||
|
||||
if tracing_provider == "opik" and (
|
||||
"project_url" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_url")
|
||||
):
|
||||
try:
|
||||
project_url = OpsTraceManager.get_trace_config_project_url(decrypt_tracing_config, tracing_provider)
|
||||
new_decrypt_tracing_config.update({"project_url": project_url})
|
||||
except Exception:
|
||||
new_decrypt_tracing_config.update({"project_url": "https://www.comet.com/opik/"})
|
||||
if tracing_provider == "weave" and (
|
||||
"project_url" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_url")
|
||||
):
|
||||
try:
|
||||
project_url = OpsTraceManager.get_trace_config_project_url(decrypt_tracing_config, tracing_provider)
|
||||
new_decrypt_tracing_config.update({"project_url": project_url})
|
||||
except Exception:
|
||||
new_decrypt_tracing_config.update({"project_url": "https://wandb.ai/"})
|
||||
|
||||
if tracing_provider == "aliyun" and (
|
||||
"project_url" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_url")
|
||||
):
|
||||
try:
|
||||
project_url = OpsTraceManager.get_trace_config_project_url(decrypt_tracing_config, tracing_provider)
|
||||
new_decrypt_tracing_config.update({"project_url": project_url})
|
||||
except Exception:
|
||||
new_decrypt_tracing_config.update({"project_url": "https://arms.console.aliyun.com/"})
|
||||
|
||||
if tracing_provider == "tencent" and (
|
||||
"project_url" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_url")
|
||||
):
|
||||
try:
|
||||
project_url = OpsTraceManager.get_trace_config_project_url(decrypt_tracing_config, tracing_provider)
|
||||
new_decrypt_tracing_config.update({"project_url": project_url})
|
||||
except Exception:
|
||||
new_decrypt_tracing_config.update({"project_url": "https://console.cloud.tencent.com/apm"})
|
||||
|
||||
if tracing_provider == "mlflow" and (
|
||||
"project_url" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_url")
|
||||
):
|
||||
try:
|
||||
project_url = OpsTraceManager.get_trace_config_project_url(decrypt_tracing_config, tracing_provider)
|
||||
new_decrypt_tracing_config.update({"project_url": project_url})
|
||||
except Exception:
|
||||
new_decrypt_tracing_config.update({"project_url": "http://localhost:5000/"})
|
||||
|
||||
if tracing_provider == "databricks" and (
|
||||
"project_url" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_url")
|
||||
):
|
||||
try:
|
||||
project_url = OpsTraceManager.get_trace_config_project_url(decrypt_tracing_config, tracing_provider)
|
||||
new_decrypt_tracing_config.update({"project_url": project_url})
|
||||
except Exception:
|
||||
new_decrypt_tracing_config.update({"project_url": "https://www.databricks.com/"})
|
||||
|
||||
trace_config_data.tracing_config = new_decrypt_tracing_config
|
||||
return trace_config_data.to_dict()
|
||||
|
||||
@classmethod
|
||||
def create_tracing_app_config(
|
||||
cls, app_id: str, tracing_provider: str, tracing_config: dict[str, Any], session: Session
|
||||
):
|
||||
"""
|
||||
Create tracing app config
|
||||
:param app_id: app id
|
||||
:param tracing_provider: tracing provider
|
||||
:param tracing_config: tracing config
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
provider_config_map[tracing_provider]
|
||||
except KeyError:
|
||||
return {"error": f"Invalid tracing provider: {tracing_provider}"}
|
||||
|
||||
provider_config: TracingProviderConfigEntry = provider_config_map[tracing_provider]
|
||||
config_class: type[BaseTracingConfig] = provider_config["config_class"]
|
||||
other_keys: list[str] = provider_config["other_keys"]
|
||||
|
||||
default_config_instance = config_class.model_validate(tracing_config)
|
||||
for key in other_keys:
|
||||
if key in tracing_config and tracing_config[key] == "":
|
||||
tracing_config[key] = getattr(default_config_instance, key, None)
|
||||
|
||||
# api check
|
||||
if not OpsTraceManager.check_trace_config_is_effective(tracing_config, tracing_provider):
|
||||
return {"error": "Invalid Credentials"}
|
||||
|
||||
# get project url
|
||||
if tracing_provider in ("arize", "phoenix"):
|
||||
try:
|
||||
project_url = OpsTraceManager.get_trace_config_project_url(tracing_config, tracing_provider)
|
||||
except Exception:
|
||||
project_url = None
|
||||
elif tracing_provider == "langfuse":
|
||||
try:
|
||||
project_key = OpsTraceManager.get_trace_config_project_key(tracing_config, tracing_provider)
|
||||
project_url = f"{tracing_config.get('host')}/project/{project_key}"
|
||||
except Exception:
|
||||
project_url = None
|
||||
elif tracing_provider in ("langsmith", "opik", "mlflow", "databricks", "tencent"):
|
||||
try:
|
||||
project_url = OpsTraceManager.get_trace_config_project_url(tracing_config, tracing_provider)
|
||||
except Exception:
|
||||
project_url = None
|
||||
else:
|
||||
project_url = None
|
||||
|
||||
# check if trace config already exists
|
||||
trace_config_data: TraceAppConfig | None = session.scalar(
|
||||
select(TraceAppConfig)
|
||||
.where(TraceAppConfig.app_id == app_id, TraceAppConfig.tracing_provider == tracing_provider)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if trace_config_data:
|
||||
return None
|
||||
|
||||
# get tenant id
|
||||
app = session.get(App, app_id)
|
||||
if not app:
|
||||
return None
|
||||
tenant_id = app.tenant_id
|
||||
tracing_config = OpsTraceManager.encrypt_tracing_config(tenant_id, tracing_provider, tracing_config)
|
||||
if project_url:
|
||||
tracing_config["project_url"] = project_url
|
||||
trace_config_data = TraceAppConfig(
|
||||
app_id=app_id,
|
||||
tracing_provider=tracing_provider,
|
||||
tracing_config=tracing_config,
|
||||
)
|
||||
session.add(trace_config_data)
|
||||
session.commit()
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
@classmethod
|
||||
def update_tracing_app_config(
|
||||
cls, app_id: str, tracing_provider: str, tracing_config: dict[str, Any], session: Session
|
||||
):
|
||||
"""
|
||||
Update tracing app config
|
||||
:param app_id: app id
|
||||
:param tracing_provider: tracing provider
|
||||
:param tracing_config: tracing config
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
provider_config_map[tracing_provider]
|
||||
except KeyError:
|
||||
raise ValueError(f"Invalid tracing provider: {tracing_provider}")
|
||||
|
||||
# check if trace config already exists
|
||||
current_trace_config = session.scalar(
|
||||
select(TraceAppConfig)
|
||||
.where(TraceAppConfig.app_id == app_id, TraceAppConfig.tracing_provider == tracing_provider)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if not current_trace_config:
|
||||
return None
|
||||
|
||||
# get tenant id
|
||||
app = session.get(App, app_id)
|
||||
if not app:
|
||||
return None
|
||||
tenant_id = app.tenant_id
|
||||
tracing_config = OpsTraceManager.encrypt_tracing_config(
|
||||
tenant_id, tracing_provider, tracing_config, current_trace_config.tracing_config
|
||||
)
|
||||
|
||||
# api check
|
||||
# decrypt_token
|
||||
decrypt_tracing_config = OpsTraceManager.decrypt_tracing_config(tenant_id, tracing_provider, tracing_config)
|
||||
if not OpsTraceManager.check_trace_config_is_effective(decrypt_tracing_config, tracing_provider):
|
||||
raise ValueError("Invalid Credentials")
|
||||
|
||||
current_trace_config.tracing_config = tracing_config
|
||||
session.commit()
|
||||
|
||||
return current_trace_config.to_dict()
|
||||
|
||||
@classmethod
|
||||
def delete_tracing_app_config(cls, app_id: str, tracing_provider: str, session: Session):
|
||||
"""
|
||||
Delete tracing app config
|
||||
:param app_id: app id
|
||||
:param tracing_provider: tracing provider
|
||||
:return:
|
||||
"""
|
||||
trace_config = session.scalar(
|
||||
select(TraceAppConfig)
|
||||
.where(TraceAppConfig.app_id == app_id, TraceAppConfig.tracing_provider == tracing_provider)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if not trace_config:
|
||||
return None
|
||||
|
||||
session.delete(trace_config)
|
||||
session.commit()
|
||||
|
||||
return True
|
||||
@ -41,7 +41,6 @@ extend-select = ["ANN401", "ARG"]
|
||||
"services/test_metadata_service.py" = ["ARG002"]
|
||||
"services/test_model_load_balancing_service.py" = ["ARG002"]
|
||||
"services/test_model_provider_service.py" = ["ARG002"]
|
||||
"services/test_ops_service.py" = ["ARG002"]
|
||||
"services/test_webapp_auth_service.py" = ["ARG002"]
|
||||
"services/test_webhook_service.py" = ["ARG002"]
|
||||
"services/test_workflow_draft_variable_service.py" = ["ARG002"]
|
||||
|
||||
@ -80,7 +80,6 @@ project-excludes = [
|
||||
"services/test_messages_clean_service.py",
|
||||
"services/test_model_load_balancing_service.py",
|
||||
"services/test_model_provider_service.py",
|
||||
"services/test_ops_service.py",
|
||||
"services/test_restore_archived_workflow_run.py",
|
||||
"services/test_saved_message_service.py",
|
||||
"services/test_schedule_service.py",
|
||||
|
||||
@ -1,385 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.ops.entities.config_entity import TracingProviderEnum
|
||||
from models.model import TraceAppConfig
|
||||
from services.account_service import AccountService, TenantService
|
||||
from services.app_service import AppService, CreateAppParams
|
||||
from services.ops_service import OpsService
|
||||
from tests.test_containers_integration_tests.helpers import generate_valid_password
|
||||
|
||||
|
||||
class TestOpsService:
|
||||
@pytest.fixture
|
||||
def mock_external_service_dependencies(self):
|
||||
with (
|
||||
patch("services.app_service.SystemFeatureService") as mock_feature_service,
|
||||
patch("services.app_service.EnterpriseService") as mock_enterprise_service,
|
||||
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
|
||||
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
|
||||
):
|
||||
mock_feature_service.is_webapp_auth_enabled.return_value = False
|
||||
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
|
||||
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
|
||||
mock_account_feature_service.is_registration_allowed.return_value = True
|
||||
mock_model_instance = mock_model_manager.return_value
|
||||
mock_model_instance.get_default_model_instance.return_value = None
|
||||
mock_model_instance.get_default_provider_model_name.return_value = ("openai", "gpt-3.5-turbo")
|
||||
yield {
|
||||
"feature_service": mock_feature_service,
|
||||
"enterprise_service": mock_enterprise_service,
|
||||
"model_manager": mock_model_manager,
|
||||
"account_feature_service": mock_account_feature_service,
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ops_trace_manager(self):
|
||||
with patch("services.ops_service.OpsTraceManager") as mock:
|
||||
yield mock
|
||||
|
||||
def _create_app(self, db_session_with_containers: Session, mock_external_service_dependencies):
|
||||
fake = Faker()
|
||||
account = AccountService.create_account(
|
||||
email=fake.email(),
|
||||
name=fake.name(),
|
||||
interface_language="en-US",
|
||||
password=generate_valid_password(fake),
|
||||
session=db_session_with_containers,
|
||||
)
|
||||
TenantService.create_owner_tenant_if_not_exist(account, name=fake.company(), session=db_session_with_containers)
|
||||
tenant = account.current_tenant
|
||||
app_service = AppService()
|
||||
app = app_service.create_app(
|
||||
tenant.id,
|
||||
CreateAppParams(
|
||||
name=fake.company(),
|
||||
description=fake.text(max_nb_chars=100),
|
||||
mode="chat",
|
||||
icon_type="emoji",
|
||||
icon="🤖",
|
||||
icon_background="#FF6B6B",
|
||||
),
|
||||
account,
|
||||
session=db_session_with_containers,
|
||||
)
|
||||
return app, account
|
||||
|
||||
_SENTINEL = object()
|
||||
|
||||
def _insert_trace_config(
|
||||
self,
|
||||
db_session: Session,
|
||||
app_id: str,
|
||||
provider: str,
|
||||
tracing_config: dict | None | object = _SENTINEL,
|
||||
) -> TraceAppConfig:
|
||||
trace_config = TraceAppConfig(
|
||||
app_id=app_id,
|
||||
tracing_provider=provider,
|
||||
tracing_config=tracing_config if tracing_config is not self._SENTINEL else {"some": "config"},
|
||||
)
|
||||
db_session.add(trace_config)
|
||||
db_session.commit()
|
||||
return trace_config
|
||||
|
||||
# ── get_tracing_app_config ─────────────────────────────────────────
|
||||
|
||||
def test_get_tracing_app_config_no_config(self, db_session_with_containers: Session, mock_ops_trace_manager):
|
||||
result = OpsService.get_tracing_app_config(str(uuid.uuid4()), "arize", db_session_with_containers)
|
||||
assert result is None
|
||||
|
||||
def test_get_tracing_app_config_no_app(self, db_session_with_containers: Session, mock_ops_trace_manager):
|
||||
fake_app_id = str(uuid.uuid4())
|
||||
self._insert_trace_config(db_session_with_containers, fake_app_id, "arize")
|
||||
result = OpsService.get_tracing_app_config(fake_app_id, "arize", db_session_with_containers)
|
||||
assert result is None
|
||||
|
||||
def test_get_tracing_app_config_none_config(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies, mock_ops_trace_manager
|
||||
):
|
||||
app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies)
|
||||
self._insert_trace_config(db_session_with_containers, app.id, "arize", tracing_config=None)
|
||||
|
||||
with pytest.raises(ValueError, match="Tracing config cannot be None."):
|
||||
OpsService.get_tracing_app_config(app.id, "arize", db_session_with_containers)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider", "default_url"),
|
||||
[
|
||||
("arize", "https://app.arize.com/"),
|
||||
("phoenix", "https://app.phoenix.arize.com/projects/"),
|
||||
("langsmith", "https://smith.langchain.com/"),
|
||||
("opik", "https://www.comet.com/opik/"),
|
||||
("weave", "https://wandb.ai/"),
|
||||
("aliyun", "https://arms.console.aliyun.com/"),
|
||||
("tencent", "https://console.cloud.tencent.com/apm"),
|
||||
("mlflow", "http://localhost:5000/"),
|
||||
("databricks", "https://www.databricks.com/"),
|
||||
],
|
||||
)
|
||||
def test_get_tracing_app_config_providers_exception(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies, provider, default_url
|
||||
):
|
||||
with patch("services.ops_service.OpsTraceManager") as mock_otm:
|
||||
mock_otm.decrypt_tracing_config.return_value = {}
|
||||
mock_otm.obfuscated_decrypt_token.return_value = {}
|
||||
mock_otm.get_trace_config_project_url.side_effect = Exception("error")
|
||||
mock_otm.get_trace_config_project_key.side_effect = Exception("error")
|
||||
|
||||
app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies)
|
||||
self._insert_trace_config(db_session_with_containers, app.id, provider)
|
||||
|
||||
result = OpsService.get_tracing_app_config(app.id, provider, db_session_with_containers)
|
||||
|
||||
assert result is not None
|
||||
assert result["tracing_config"]["project_url"] == default_url
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider",
|
||||
["arize", "phoenix", "langsmith", "opik", "weave", "aliyun", "tencent", "mlflow", "databricks"],
|
||||
)
|
||||
def test_get_tracing_app_config_providers_success(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies, provider
|
||||
):
|
||||
with patch("services.ops_service.OpsTraceManager") as mock_otm:
|
||||
mock_otm.decrypt_tracing_config.return_value = {}
|
||||
mock_otm.obfuscated_decrypt_token.return_value = {"project_url": "success_url"}
|
||||
mock_otm.get_trace_config_project_url.return_value = "success_url"
|
||||
|
||||
app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies)
|
||||
self._insert_trace_config(db_session_with_containers, app.id, provider)
|
||||
|
||||
result = OpsService.get_tracing_app_config(app.id, provider, db_session_with_containers)
|
||||
|
||||
assert result is not None
|
||||
assert result["tracing_config"]["project_url"] == "success_url"
|
||||
|
||||
def test_get_tracing_app_config_langfuse_success(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
with patch("services.ops_service.OpsTraceManager") as mock_otm:
|
||||
mock_otm.decrypt_tracing_config.return_value = {"host": "https://api.langfuse.com"}
|
||||
mock_otm.obfuscated_decrypt_token.return_value = {"host": "https://api.langfuse.com"}
|
||||
mock_otm.get_trace_config_project_key.return_value = "key"
|
||||
|
||||
app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies)
|
||||
self._insert_trace_config(db_session_with_containers, app.id, "langfuse")
|
||||
|
||||
result = OpsService.get_tracing_app_config(app.id, "langfuse", db_session_with_containers)
|
||||
|
||||
assert result is not None
|
||||
assert result["tracing_config"]["project_url"] == "https://api.langfuse.com/project/key"
|
||||
|
||||
def test_get_tracing_app_config_langfuse_exception(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
with patch("services.ops_service.OpsTraceManager") as mock_otm:
|
||||
mock_otm.decrypt_tracing_config.return_value = {"host": "https://api.langfuse.com"}
|
||||
mock_otm.obfuscated_decrypt_token.return_value = {"host": "https://api.langfuse.com"}
|
||||
mock_otm.get_trace_config_project_key.side_effect = Exception("error")
|
||||
|
||||
app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies)
|
||||
self._insert_trace_config(db_session_with_containers, app.id, "langfuse")
|
||||
|
||||
result = OpsService.get_tracing_app_config(app.id, "langfuse", db_session_with_containers)
|
||||
|
||||
assert result is not None
|
||||
assert result["tracing_config"]["project_url"] == "https://api.langfuse.com/"
|
||||
|
||||
# ── create_tracing_app_config ──────────────────────────────────────
|
||||
|
||||
def test_create_tracing_app_config_invalid_provider(self, db_session_with_containers: Session):
|
||||
result = OpsService.create_tracing_app_config(
|
||||
str(uuid.uuid4()), "invalid_provider", {}, db_session_with_containers
|
||||
)
|
||||
assert result == {"error": "Invalid tracing provider: invalid_provider"}
|
||||
|
||||
def test_create_tracing_app_config_invalid_credentials(
|
||||
self, db_session_with_containers: Session, mock_ops_trace_manager
|
||||
):
|
||||
mock_ops_trace_manager.check_trace_config_is_effective.return_value = False
|
||||
result = OpsService.create_tracing_app_config(
|
||||
str(uuid.uuid4()),
|
||||
TracingProviderEnum.LANGFUSE,
|
||||
{"public_key": "p", "secret_key": "s"},
|
||||
db_session_with_containers,
|
||||
)
|
||||
assert result == {"error": "Invalid Credentials"}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider", "config"),
|
||||
[
|
||||
(TracingProviderEnum.ARIZE, {}),
|
||||
(TracingProviderEnum.LANGFUSE, {"public_key": "p", "secret_key": "s"}),
|
||||
(TracingProviderEnum.LANGSMITH, {"api_key": "k", "project": "p"}),
|
||||
(TracingProviderEnum.ALIYUN, {"license_key": "k", "endpoint": "https://aliyun.com"}),
|
||||
],
|
||||
)
|
||||
def test_create_tracing_app_config_project_url_exception(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies, provider, config
|
||||
):
|
||||
# Existing config causes the service to return None before reaching the DB insert
|
||||
with patch("services.ops_service.OpsTraceManager") as mock_otm:
|
||||
mock_otm.check_trace_config_is_effective.return_value = True
|
||||
mock_otm.get_trace_config_project_url.side_effect = Exception("error")
|
||||
mock_otm.get_trace_config_project_key.side_effect = Exception("error")
|
||||
|
||||
app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies)
|
||||
self._insert_trace_config(db_session_with_containers, app.id, str(provider))
|
||||
|
||||
result = OpsService.create_tracing_app_config(app.id, provider, config, db_session_with_containers)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_create_tracing_app_config_langfuse_success(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
with patch("services.ops_service.OpsTraceManager") as mock_otm:
|
||||
mock_otm.check_trace_config_is_effective.return_value = True
|
||||
mock_otm.get_trace_config_project_key.return_value = "key"
|
||||
mock_otm.encrypt_tracing_config.return_value = {}
|
||||
|
||||
app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies)
|
||||
result = OpsService.create_tracing_app_config(
|
||||
app.id,
|
||||
TracingProviderEnum.LANGFUSE,
|
||||
{"public_key": "p", "secret_key": "s", "host": "https://api.langfuse.com"},
|
||||
db_session_with_containers,
|
||||
)
|
||||
|
||||
assert result == {"result": "success"}
|
||||
|
||||
def test_create_tracing_app_config_already_exists(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
with patch("services.ops_service.OpsTraceManager") as mock_otm:
|
||||
mock_otm.check_trace_config_is_effective.return_value = True
|
||||
|
||||
app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies)
|
||||
self._insert_trace_config(db_session_with_containers, app.id, str(TracingProviderEnum.ARIZE))
|
||||
|
||||
result = OpsService.create_tracing_app_config(
|
||||
app.id, TracingProviderEnum.ARIZE, {}, db_session_with_containers
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_create_tracing_app_config_no_app(self, db_session_with_containers: Session, mock_ops_trace_manager):
|
||||
mock_ops_trace_manager.check_trace_config_is_effective.return_value = True
|
||||
result = OpsService.create_tracing_app_config(
|
||||
str(uuid.uuid4()), TracingProviderEnum.ARIZE, {}, db_session_with_containers
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_create_tracing_app_config_with_empty_other_keys(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
# "project" is in other_keys for Arize; providing "" triggers default substitution
|
||||
with patch("services.ops_service.OpsTraceManager") as mock_otm:
|
||||
mock_otm.check_trace_config_is_effective.return_value = True
|
||||
mock_otm.get_trace_config_project_url.side_effect = Exception("no url")
|
||||
mock_otm.encrypt_tracing_config.return_value = {}
|
||||
|
||||
app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies)
|
||||
result = OpsService.create_tracing_app_config(
|
||||
app.id, TracingProviderEnum.ARIZE, {"project": ""}, db_session_with_containers
|
||||
)
|
||||
|
||||
assert result == {"result": "success"}
|
||||
|
||||
def test_create_tracing_app_config_success(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
with patch("services.ops_service.OpsTraceManager") as mock_otm:
|
||||
mock_otm.check_trace_config_is_effective.return_value = True
|
||||
mock_otm.get_trace_config_project_url.return_value = "http://project_url"
|
||||
mock_otm.encrypt_tracing_config.return_value = {"encrypted": "config"}
|
||||
|
||||
app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies)
|
||||
result = OpsService.create_tracing_app_config(
|
||||
app.id, TracingProviderEnum.ARIZE, {}, db_session_with_containers
|
||||
)
|
||||
|
||||
assert result == {"result": "success"}
|
||||
|
||||
# ── update_tracing_app_config ──────────────────────────────────────
|
||||
|
||||
def test_update_tracing_app_config_invalid_provider(self, db_session_with_containers: Session):
|
||||
with pytest.raises(ValueError, match="Invalid tracing provider: invalid_provider"):
|
||||
OpsService.update_tracing_app_config(str(uuid.uuid4()), "invalid_provider", {}, db_session_with_containers)
|
||||
|
||||
def test_update_tracing_app_config_no_config(self, db_session_with_containers: Session, mock_ops_trace_manager):
|
||||
result = OpsService.update_tracing_app_config(
|
||||
str(uuid.uuid4()), TracingProviderEnum.ARIZE, {}, db_session_with_containers
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_update_tracing_app_config_no_app(self, db_session_with_containers: Session, mock_ops_trace_manager):
|
||||
fake_app_id = str(uuid.uuid4())
|
||||
self._insert_trace_config(db_session_with_containers, fake_app_id, str(TracingProviderEnum.ARIZE))
|
||||
mock_ops_trace_manager.encrypt_tracing_config.return_value = {}
|
||||
result = OpsService.update_tracing_app_config(
|
||||
fake_app_id, TracingProviderEnum.ARIZE, {}, db_session_with_containers
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_update_tracing_app_config_invalid_credentials(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
with patch("services.ops_service.OpsTraceManager") as mock_otm:
|
||||
mock_otm.encrypt_tracing_config.return_value = {}
|
||||
mock_otm.decrypt_tracing_config.return_value = {}
|
||||
mock_otm.check_trace_config_is_effective.return_value = False
|
||||
|
||||
app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies)
|
||||
self._insert_trace_config(db_session_with_containers, app.id, str(TracingProviderEnum.ARIZE))
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid Credentials"):
|
||||
OpsService.update_tracing_app_config(app.id, TracingProviderEnum.ARIZE, {}, db_session_with_containers)
|
||||
|
||||
def test_update_tracing_app_config_success(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
with patch("services.ops_service.OpsTraceManager") as mock_otm:
|
||||
mock_otm.encrypt_tracing_config.return_value = {"updated": "config"}
|
||||
mock_otm.decrypt_tracing_config.return_value = {}
|
||||
mock_otm.check_trace_config_is_effective.return_value = True
|
||||
|
||||
app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies)
|
||||
self._insert_trace_config(db_session_with_containers, app.id, str(TracingProviderEnum.ARIZE))
|
||||
|
||||
result = OpsService.update_tracing_app_config(
|
||||
app.id, TracingProviderEnum.ARIZE, {}, db_session_with_containers
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["app_id"] == app.id
|
||||
|
||||
# ── delete_tracing_app_config ──────────────────────────────────────
|
||||
|
||||
def test_delete_tracing_app_config_no_config(self, db_session_with_containers: Session):
|
||||
result = OpsService.delete_tracing_app_config(str(uuid.uuid4()), "arize", db_session_with_containers)
|
||||
assert result is None
|
||||
|
||||
def test_delete_tracing_app_config_success(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies)
|
||||
self._insert_trace_config(db_session_with_containers, app.id, "arize")
|
||||
|
||||
result = OpsService.delete_tracing_app_config(app.id, "arize", db_session_with_containers)
|
||||
|
||||
assert result is True
|
||||
remaining = db_session_with_containers.scalar(
|
||||
select(TraceAppConfig)
|
||||
.where(TraceAppConfig.app_id == app.id, TraceAppConfig.tracing_provider == "arize")
|
||||
.limit(1)
|
||||
)
|
||||
assert remaining is None
|
||||
@ -53,7 +53,11 @@ from controllers.console.app import (
|
||||
wraps as wraps_module,
|
||||
)
|
||||
from controllers.console.app.completion import ChatMessagePayload, CompletionMessagePayload
|
||||
from controllers.console.app.error import AppNotFoundError
|
||||
from controllers.console.app.error import (
|
||||
AppNotFoundError,
|
||||
TracingConfigNotFoundError,
|
||||
TracingConfigVerificationFailedError,
|
||||
)
|
||||
from controllers.console.app.mcp_server import MCPServerCreatePayload, MCPServerUpdatePayload
|
||||
from controllers.console.app.ops_trace import TraceConfigPayload, TraceProviderQuery
|
||||
from controllers.console.app.site import AppSiteUpdatePayload
|
||||
@ -77,6 +81,10 @@ from services.app_site_service import (
|
||||
AppSiteCommandResult,
|
||||
AppSiteNotFoundError,
|
||||
)
|
||||
from services.app_tracing_config_service import (
|
||||
AppTracingConfigNotFoundError,
|
||||
AppTracingConfigVerificationFailedError,
|
||||
)
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
APP_ID = "11111111-1111-1111-1111-111111111111"
|
||||
@ -387,52 +395,71 @@ class TestOpsTraceEndpoints:
|
||||
def test_trace_app_config_get_empty(self, app: Flask, monkeypatch: pytest.MonkeyPatch):
|
||||
api = ops_trace_module.TraceAppConfigApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
tracing_configs = MagicMock()
|
||||
tracing_configs.get.return_value = None
|
||||
monkeypatch.setattr(
|
||||
ops_trace_module.OpsService,
|
||||
"get_tracing_app_config",
|
||||
lambda **_kwargs: None,
|
||||
ops_trace_module,
|
||||
"application_services",
|
||||
lambda: SimpleNamespace(app_tracing_configs=tracing_configs),
|
||||
)
|
||||
|
||||
with app.test_request_context("/?tracing_provider=langfuse"):
|
||||
result = method(api, TraceProviderQuery(tracing_provider="langfuse"), _make_app())
|
||||
result = method(
|
||||
api,
|
||||
TraceProviderQuery(tracing_provider="langfuse"),
|
||||
_make_request_context(),
|
||||
uuid.UUID(APP_ID),
|
||||
)
|
||||
|
||||
assert result == {"has_not_configured": True}
|
||||
tracing_configs.get.assert_called_once_with(
|
||||
context=_make_request_context(),
|
||||
app_id=APP_ID,
|
||||
tracing_provider="langfuse",
|
||||
)
|
||||
|
||||
def test_trace_app_config_post_invalid(self, app: Flask, monkeypatch: pytest.MonkeyPatch):
|
||||
api = ops_trace_module.TraceAppConfigApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
tracing_configs = MagicMock()
|
||||
tracing_configs.create.side_effect = AppTracingConfigVerificationFailedError()
|
||||
monkeypatch.setattr(
|
||||
ops_trace_module.OpsService,
|
||||
"create_tracing_app_config",
|
||||
lambda **_kwargs: {"error": True},
|
||||
ops_trace_module,
|
||||
"application_services",
|
||||
lambda: SimpleNamespace(app_tracing_configs=tracing_configs),
|
||||
)
|
||||
|
||||
with app.test_request_context(
|
||||
"/",
|
||||
json={"tracing_provider": "langfuse", "tracing_config": {"api_key": "k"}},
|
||||
):
|
||||
with pytest.raises(BadRequest):
|
||||
with pytest.raises(TracingConfigVerificationFailedError):
|
||||
method(
|
||||
api,
|
||||
TraceConfigPayload(tracing_provider="langfuse", tracing_config={"api_key": "k"}),
|
||||
_make_app(),
|
||||
_make_request_context(),
|
||||
uuid.UUID(APP_ID),
|
||||
)
|
||||
|
||||
def test_trace_app_config_delete_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch):
|
||||
api = ops_trace_module.TraceAppConfigApi()
|
||||
method = unwrap(api.delete)
|
||||
|
||||
tracing_configs = MagicMock()
|
||||
tracing_configs.delete.side_effect = AppTracingConfigNotFoundError()
|
||||
monkeypatch.setattr(
|
||||
ops_trace_module.OpsService,
|
||||
"delete_tracing_app_config",
|
||||
lambda **_kwargs: False,
|
||||
ops_trace_module,
|
||||
"application_services",
|
||||
lambda: SimpleNamespace(app_tracing_configs=tracing_configs),
|
||||
)
|
||||
|
||||
with app.test_request_context("/?tracing_provider=langfuse"):
|
||||
with pytest.raises(BadRequest):
|
||||
method(api, TraceProviderQuery(tracing_provider="langfuse"), _make_app())
|
||||
with pytest.raises(TracingConfigNotFoundError):
|
||||
method(
|
||||
api,
|
||||
TraceProviderQuery(tracing_provider="langfuse"),
|
||||
_make_request_context(),
|
||||
uuid.UUID(APP_ID),
|
||||
)
|
||||
|
||||
|
||||
class TestSiteEndpoints:
|
||||
|
||||
@ -1,197 +1,527 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import nullcontext
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from controllers.common import wraps as common_wraps
|
||||
from controllers.console import console_ns
|
||||
from controllers.console import wraps as console_wraps
|
||||
from controllers.console import flask_admission
|
||||
from controllers.console.app import ops_trace as ops_trace_module
|
||||
from controllers.console.app import wraps as app_wraps
|
||||
from enums import DeploymentEdition
|
||||
from libs import login as login_lib
|
||||
from models import Tenant
|
||||
from controllers.console.app.error import (
|
||||
AppNotFoundError,
|
||||
InvalidTracingConfigError,
|
||||
TracingConfigAlreadyExistsError,
|
||||
TracingConfigNotFoundError,
|
||||
TracingConfigProcessingError,
|
||||
TracingConfigVerificationFailedError,
|
||||
UnsupportedTracingProviderError,
|
||||
)
|
||||
from libs.exception import BaseHTTPException
|
||||
from libs.login import AccountWithTenant
|
||||
from machinery.context import RequestContext
|
||||
from models.account import Account, AccountStatus, TenantAccountRole
|
||||
from models.model import App, AppMode, IconType
|
||||
from services.app_tracing_config_service import (
|
||||
AppTracingConfigAlreadyExistsError,
|
||||
AppTracingConfigAppNotFoundError,
|
||||
AppTracingConfigInvalidConfigurationError,
|
||||
AppTracingConfigInvalidProviderError,
|
||||
AppTracingConfigNotFoundError,
|
||||
AppTracingConfigProcessingError,
|
||||
AppTracingConfigRecord,
|
||||
AppTracingConfigVerificationFailedError,
|
||||
)
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
APP_ID = "11111111-1111-1111-1111-111111111111"
|
||||
WORKSPACE_ID = "22222222-2222-2222-2222-222222222222"
|
||||
ACCOUNT_ID = "33333333-3333-3333-3333-333333333333"
|
||||
PROVIDER = "langfuse"
|
||||
_MUTATION_METHODS = (
|
||||
ops_trace_module.TraceAppConfigApi.post,
|
||||
ops_trace_module.TraceAppConfigApi.patch,
|
||||
ops_trace_module.TraceAppConfigApi.delete,
|
||||
)
|
||||
_CONTROLLER_METHODS: dict[str, Callable[..., object]] = {
|
||||
"get": ops_trace_module.TraceAppConfigApi.get,
|
||||
"post": ops_trace_module.TraceAppConfigApi.post,
|
||||
"patch": ops_trace_module.TraceAppConfigApi.patch,
|
||||
"delete": ops_trace_module.TraceAppConfigApi.delete,
|
||||
}
|
||||
|
||||
def _make_account(role: TenantAccountRole) -> Account:
|
||||
account = Account(name="tester", email="tester@example.com")
|
||||
account.id = "account-123" # type: ignore[assignment]
|
||||
account.status = AccountStatus.ACTIVE
|
||||
|
||||
def _service_method(tracing_configs: MagicMock, method_name: str) -> MagicMock:
|
||||
return {
|
||||
"get": tracing_configs.get,
|
||||
"post": tracing_configs.create,
|
||||
"patch": tracing_configs.update,
|
||||
"delete": tracing_configs.delete,
|
||||
}[method_name]
|
||||
|
||||
|
||||
def _account(role: TenantAccountRole) -> Account:
|
||||
account = Account(
|
||||
name="Trace User",
|
||||
email=f"{role.value}@example.com",
|
||||
status=AccountStatus.ACTIVE,
|
||||
)
|
||||
account.id = ACCOUNT_ID
|
||||
account.role = role
|
||||
tenant = Tenant(name="Test tenant")
|
||||
tenant.id = "tenant-123"
|
||||
account._current_tenant = tenant
|
||||
account._get_current_object = lambda: account # type: ignore[attr-defined]
|
||||
return account
|
||||
|
||||
|
||||
def _make_app() -> App:
|
||||
return App(
|
||||
id="app-123",
|
||||
tenant_id="tenant-123",
|
||||
name="Trace app",
|
||||
description="",
|
||||
mode=AppMode.CHAT,
|
||||
icon_type=IconType.EMOJI,
|
||||
icon="robot",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
max_active_requests=None,
|
||||
def _request_context() -> RequestContext:
|
||||
return RequestContext(
|
||||
request_id="request-1",
|
||||
trace_id="trace-1",
|
||||
account_id=ACCOUNT_ID,
|
||||
active_workspace_id=WORKSPACE_ID,
|
||||
)
|
||||
|
||||
|
||||
def _patch_console_guards(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
account: Account,
|
||||
app_model: App,
|
||||
*,
|
||||
rbac_enabled: bool = False,
|
||||
) -> None:
|
||||
apply_config_overrides(
|
||||
monkeypatch,
|
||||
LOGIN_DISABLED=True,
|
||||
RBAC_ENABLED=rbac_enabled,
|
||||
DEPLOYMENT_EDITION=DeploymentEdition.CLOUD,
|
||||
def _original(method: Callable[..., object]) -> Callable[..., object]:
|
||||
return inspect.unwrap(method)
|
||||
|
||||
|
||||
def _admission_injector(method: Callable[..., object]) -> Callable[..., object]:
|
||||
return inspect.unwrap(
|
||||
method,
|
||||
stop=lambda candidate: "allowed_roles" in inspect.getclosurevars(candidate).nonlocals,
|
||||
)
|
||||
monkeypatch.setattr(login_lib, "current_user", account)
|
||||
monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(common_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(app_wraps, "_load_app_model_from_scoped_session", lambda _app_id: app_model)
|
||||
|
||||
|
||||
def _patch_payload(payload: dict[str, object] | None):
|
||||
if payload is None:
|
||||
return nullcontext()
|
||||
return patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload)
|
||||
@pytest.fixture
|
||||
def tracing_configs(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
|
||||
service = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
ops_trace_module,
|
||||
"application_services",
|
||||
lambda: SimpleNamespace(app_tracing_configs=service),
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", _MUTATION_METHODS)
|
||||
def test_trace_config_mutations_reject_read_only_member_when_rbac_is_disabled(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
method: Callable[..., object],
|
||||
) -> None:
|
||||
account = _account(TenantAccountRole.NORMAL)
|
||||
apply_config_overrides(monkeypatch, RBAC_ENABLED=False)
|
||||
monkeypatch.setattr(
|
||||
flask_admission,
|
||||
"current_account_with_tenant",
|
||||
lambda: AccountWithTenant(account=account, tenant_id=WORKSPACE_ID),
|
||||
)
|
||||
|
||||
with app.test_request_context(), pytest.raises(Forbidden):
|
||||
_admission_injector(method)(None, app_id=UUID(APP_ID))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", _MUTATION_METHODS)
|
||||
def test_trace_config_mutations_require_app_tracing_permission_when_rbac_is_enabled(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
method: Callable[..., object],
|
||||
) -> None:
|
||||
account = _account(TenantAccountRole.NORMAL)
|
||||
apply_config_overrides(monkeypatch, RBAC_ENABLED=True)
|
||||
monkeypatch.setattr(
|
||||
flask_admission,
|
||||
"current_account_with_tenant",
|
||||
lambda: AccountWithTenant(account=account, tenant_id=WORKSPACE_ID),
|
||||
)
|
||||
denied = MagicMock(side_effect=Forbidden())
|
||||
monkeypatch.setattr(flask_admission, "enforce_rbac_access", denied)
|
||||
|
||||
with app.test_request_context(), pytest.raises(Forbidden):
|
||||
_admission_injector(method)(None, app_id=UUID(APP_ID))
|
||||
|
||||
denied.assert_called_once_with(
|
||||
tenant_id=WORKSPACE_ID,
|
||||
account_id=ACCOUNT_ID,
|
||||
resource_type=ops_trace_module.RBACResourceScope.APP,
|
||||
scene=ops_trace_module.RBACPermission.APP_TRACING_CONFIG,
|
||||
resource_required=True,
|
||||
path_args={"app_id": UUID(APP_ID)},
|
||||
)
|
||||
|
||||
|
||||
def test_trace_config_get_preserves_read_access_for_normal_member() -> None:
|
||||
admission = _admission_injector(ops_trace_module.TraceAppConfigApi.get)
|
||||
|
||||
assert inspect.getclosurevars(admission).nonlocals["allowed_roles"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", _MUTATION_METHODS)
|
||||
def test_trace_config_mutations_preserve_legacy_edit_roles(method: Callable[..., object]) -> None:
|
||||
admission = _admission_injector(method)
|
||||
|
||||
assert inspect.getclosurevars(admission).nonlocals["allowed_roles"] == frozenset(
|
||||
{
|
||||
TenantAccountRole.OWNER,
|
||||
TenantAccountRole.ADMIN,
|
||||
TenantAccountRole.EDITOR,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_trace_app_config_get_empty_returns_exact_legacy_body(
|
||||
app: Flask,
|
||||
tracing_configs: MagicMock,
|
||||
) -> None:
|
||||
tracing_configs.get.return_value = None
|
||||
|
||||
with app.test_request_context("/?tracing_provider=langfuse"):
|
||||
result = _original(ops_trace_module.TraceAppConfigApi.get)(
|
||||
ops_trace_module.TraceAppConfigApi(),
|
||||
ops_trace_module.TraceProviderQuery(tracing_provider=PROVIDER),
|
||||
_request_context(),
|
||||
UUID(APP_ID),
|
||||
)
|
||||
|
||||
assert result == {"has_not_configured": True}
|
||||
tracing_configs.get.assert_called_once_with(
|
||||
context=_request_context(),
|
||||
app_id=APP_ID,
|
||||
tracing_provider=PROVIDER,
|
||||
)
|
||||
|
||||
|
||||
def test_trace_app_config_get_configured_returns_exact_legacy_body(
|
||||
app: Flask,
|
||||
tracing_configs: MagicMock,
|
||||
) -> None:
|
||||
tracing_configs.get.return_value = AppTracingConfigRecord(
|
||||
id="trace-config-1",
|
||||
app_id=APP_ID,
|
||||
tracing_provider=PROVIDER,
|
||||
tracing_config={"public_key": "pk", "secret_key": "******"},
|
||||
is_active=True,
|
||||
created_at=datetime(2026, 1, 2, 3, 4, 5),
|
||||
updated_at=datetime(2026, 1, 3, 4, 5, 6),
|
||||
)
|
||||
|
||||
with app.test_request_context("/?tracing_provider=langfuse"):
|
||||
result = _original(ops_trace_module.TraceAppConfigApi.get)(
|
||||
ops_trace_module.TraceAppConfigApi(),
|
||||
ops_trace_module.TraceProviderQuery(tracing_provider=PROVIDER),
|
||||
_request_context(),
|
||||
UUID(APP_ID),
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"id": "trace-config-1",
|
||||
"app_id": APP_ID,
|
||||
"tracing_provider": PROVIDER,
|
||||
"tracing_config": {"public_key": "pk", "secret_key": "******"},
|
||||
"is_active": True,
|
||||
"created_at": "2026-01-02 03:04:05",
|
||||
"updated_at": "2026-01-03 04:05:06",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method_name", "path", "payload", "service_method_name", "service_result"),
|
||||
("method_name", "expected_result"),
|
||||
[
|
||||
(
|
||||
"post",
|
||||
"/console/api/apps/app-123/trace-config",
|
||||
{"tracing_provider": "mlflow", "tracing_config": {"endpoint": "https://trace.example.com"}},
|
||||
"create_tracing_app_config",
|
||||
{"id": "trace-config-1"},
|
||||
),
|
||||
(
|
||||
"patch",
|
||||
"/console/api/apps/app-123/trace-config",
|
||||
{"tracing_provider": "mlflow", "tracing_config": {"endpoint": "https://trace.example.com"}},
|
||||
"update_tracing_app_config",
|
||||
True,
|
||||
),
|
||||
(
|
||||
"delete",
|
||||
"/console/api/apps/app-123/trace-config?tracing_provider=mlflow",
|
||||
None,
|
||||
"delete_tracing_app_config",
|
||||
True,
|
||||
),
|
||||
pytest.param("post", ({"result": "success"}, 201), id="create"),
|
||||
pytest.param("patch", {"result": "success"}, id="update"),
|
||||
],
|
||||
)
|
||||
def test_trace_config_mutations_require_edit_permission(
|
||||
def test_trace_app_config_write_returns_expected_response(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tracing_configs: MagicMock,
|
||||
method_name: str,
|
||||
path: str,
|
||||
payload: dict[str, object] | None,
|
||||
service_method_name: str,
|
||||
service_result: object,
|
||||
expected_result: object,
|
||||
) -> None:
|
||||
app.config.setdefault("RESTX_MASK_HEADER", "X-Fields")
|
||||
account = _make_account(TenantAccountRole.NORMAL)
|
||||
_patch_console_guards(monkeypatch, account, _make_app())
|
||||
service_mock = MagicMock(return_value=service_result)
|
||||
monkeypatch.setattr(ops_trace_module.OpsService, service_method_name, service_mock)
|
||||
payload = ops_trace_module.TraceConfigPayload(
|
||||
tracing_provider=PROVIDER,
|
||||
tracing_config={"public_key": "pk", "secret_key": "sk"},
|
||||
)
|
||||
|
||||
with app.test_request_context(path, method=method_name.upper(), json=payload):
|
||||
with _patch_payload(payload):
|
||||
with pytest.raises(Forbidden):
|
||||
getattr(ops_trace_module.TraceAppConfigApi(), method_name)(app_id="app-123")
|
||||
with app.test_request_context("/", method=method_name.upper()):
|
||||
result = _original(_CONTROLLER_METHODS[method_name])(
|
||||
ops_trace_module.TraceAppConfigApi(),
|
||||
payload,
|
||||
_request_context(),
|
||||
UUID(APP_ID),
|
||||
)
|
||||
|
||||
service_mock.assert_not_called()
|
||||
assert result == expected_result
|
||||
_service_method(tracing_configs, method_name).assert_called_once_with(
|
||||
context=_request_context(),
|
||||
app_id=APP_ID,
|
||||
tracing_provider=PROVIDER,
|
||||
tracing_config={"public_key": "pk", "secret_key": "sk"},
|
||||
)
|
||||
|
||||
|
||||
def test_trace_app_config_delete_returns_exact_204_response(
|
||||
app: Flask,
|
||||
tracing_configs: MagicMock,
|
||||
) -> None:
|
||||
with app.test_request_context("/?tracing_provider=langfuse", method="DELETE"):
|
||||
result = _original(ops_trace_module.TraceAppConfigApi.delete)(
|
||||
ops_trace_module.TraceAppConfigApi(),
|
||||
ops_trace_module.TraceProviderQuery(tracing_provider=PROVIDER),
|
||||
_request_context(),
|
||||
UUID(APP_ID),
|
||||
)
|
||||
|
||||
assert result == ("", 204)
|
||||
tracing_configs.delete.assert_called_once_with(
|
||||
context=_request_context(),
|
||||
app_id=APP_ID,
|
||||
tracing_provider=PROVIDER,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method_name", ["get", "post", "patch", "delete"])
|
||||
def test_trace_app_config_maps_missing_app_to_404(
|
||||
app: Flask,
|
||||
tracing_configs: MagicMock,
|
||||
method_name: str,
|
||||
) -> None:
|
||||
service_method = _service_method(tracing_configs, method_name)
|
||||
service_method.side_effect = AppTracingConfigAppNotFoundError()
|
||||
query_or_payload = (
|
||||
ops_trace_module.TraceProviderQuery(tracing_provider=PROVIDER)
|
||||
if method_name in {"get", "delete"}
|
||||
else ops_trace_module.TraceConfigPayload(tracing_provider=PROVIDER, tracing_config={})
|
||||
)
|
||||
|
||||
with app.test_request_context("/"):
|
||||
with pytest.raises(AppNotFoundError) as exc_info:
|
||||
_original(_CONTROLLER_METHODS[method_name])(
|
||||
ops_trace_module.TraceAppConfigApi(),
|
||||
query_or_payload,
|
||||
_request_context(),
|
||||
UUID(APP_ID),
|
||||
)
|
||||
|
||||
assert exc_info.value.code == 404
|
||||
assert exc_info.value.error_code == "app_not_found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method_name", "path", "payload", "service_method_name", "service_result"),
|
||||
("method_name", "service_error", "expected_http_error", "expected_status", "expected_code"),
|
||||
[
|
||||
(
|
||||
pytest.param(
|
||||
"post",
|
||||
"/console/api/apps/app-123/trace-config",
|
||||
{"tracing_provider": "mlflow", "tracing_config": {"endpoint": "https://trace.example.com"}},
|
||||
"create_tracing_app_config",
|
||||
{"id": "trace-config-1"},
|
||||
AppTracingConfigAlreadyExistsError(),
|
||||
TracingConfigAlreadyExistsError,
|
||||
409,
|
||||
"trace_config_already_exists",
|
||||
id="already-exists",
|
||||
),
|
||||
(
|
||||
pytest.param(
|
||||
"patch",
|
||||
"/console/api/apps/app-123/trace-config",
|
||||
{"tracing_provider": "mlflow", "tracing_config": {"endpoint": "https://trace.example.com"}},
|
||||
"update_tracing_app_config",
|
||||
True,
|
||||
AppTracingConfigNotFoundError(),
|
||||
TracingConfigNotFoundError,
|
||||
404,
|
||||
"trace_config_not_found",
|
||||
id="patch-not-found",
|
||||
),
|
||||
(
|
||||
pytest.param(
|
||||
"delete",
|
||||
"/console/api/apps/app-123/trace-config?tracing_provider=mlflow",
|
||||
None,
|
||||
"delete_tracing_app_config",
|
||||
True,
|
||||
AppTracingConfigNotFoundError(),
|
||||
TracingConfigNotFoundError,
|
||||
404,
|
||||
"trace_config_not_found",
|
||||
id="delete-not-found",
|
||||
),
|
||||
pytest.param(
|
||||
"get",
|
||||
AppTracingConfigInvalidProviderError("unknown"),
|
||||
UnsupportedTracingProviderError,
|
||||
400,
|
||||
"unsupported_tracing_provider",
|
||||
id="get-unsupported-provider",
|
||||
),
|
||||
pytest.param(
|
||||
"post",
|
||||
AppTracingConfigInvalidProviderError("unknown"),
|
||||
UnsupportedTracingProviderError,
|
||||
400,
|
||||
"unsupported_tracing_provider",
|
||||
id="post-unsupported-provider",
|
||||
),
|
||||
pytest.param(
|
||||
"patch",
|
||||
AppTracingConfigInvalidProviderError("unknown"),
|
||||
UnsupportedTracingProviderError,
|
||||
400,
|
||||
"unsupported_tracing_provider",
|
||||
id="patch-unsupported-provider",
|
||||
),
|
||||
pytest.param(
|
||||
"delete",
|
||||
AppTracingConfigInvalidProviderError("unknown"),
|
||||
UnsupportedTracingProviderError,
|
||||
400,
|
||||
"unsupported_tracing_provider",
|
||||
id="delete-unsupported-provider",
|
||||
),
|
||||
pytest.param(
|
||||
"post",
|
||||
AppTracingConfigInvalidConfigurationError(),
|
||||
InvalidTracingConfigError,
|
||||
400,
|
||||
"invalid_tracing_config",
|
||||
id="post-invalid-config",
|
||||
),
|
||||
pytest.param(
|
||||
"patch",
|
||||
AppTracingConfigInvalidConfigurationError(),
|
||||
InvalidTracingConfigError,
|
||||
400,
|
||||
"invalid_tracing_config",
|
||||
id="patch-invalid-config",
|
||||
),
|
||||
pytest.param(
|
||||
"post",
|
||||
AppTracingConfigVerificationFailedError(),
|
||||
TracingConfigVerificationFailedError,
|
||||
400,
|
||||
"tracing_config_verification_failed",
|
||||
id="post-verification-failed",
|
||||
),
|
||||
pytest.param(
|
||||
"patch",
|
||||
AppTracingConfigVerificationFailedError(),
|
||||
TracingConfigVerificationFailedError,
|
||||
400,
|
||||
"tracing_config_verification_failed",
|
||||
id="patch-verification-failed",
|
||||
),
|
||||
pytest.param(
|
||||
"get",
|
||||
AppTracingConfigProcessingError(),
|
||||
TracingConfigProcessingError,
|
||||
500,
|
||||
"tracing_config_processing_failed",
|
||||
id="get-processing-failed",
|
||||
),
|
||||
pytest.param(
|
||||
"post",
|
||||
AppTracingConfigProcessingError(),
|
||||
TracingConfigProcessingError,
|
||||
500,
|
||||
"tracing_config_processing_failed",
|
||||
id="post-processing-failed",
|
||||
),
|
||||
pytest.param(
|
||||
"patch",
|
||||
AppTracingConfigProcessingError(),
|
||||
TracingConfigProcessingError,
|
||||
500,
|
||||
"tracing_config_processing_failed",
|
||||
id="patch-processing-failed",
|
||||
),
|
||||
pytest.param(
|
||||
"delete",
|
||||
AppTracingConfigProcessingError(),
|
||||
TracingConfigProcessingError,
|
||||
500,
|
||||
"tracing_config_processing_failed",
|
||||
id="delete-processing-failed",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_trace_config_mutations_require_rbac_permission(
|
||||
def test_trace_app_config_maps_application_errors_at_the_controller_boundary(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tracing_configs: MagicMock,
|
||||
method_name: str,
|
||||
path: str,
|
||||
payload: dict[str, object] | None,
|
||||
service_method_name: str,
|
||||
service_result: object,
|
||||
sqlite_session: Session,
|
||||
service_error: Exception,
|
||||
expected_http_error: type[BaseHTTPException],
|
||||
expected_status: int,
|
||||
expected_code: str,
|
||||
) -> None:
|
||||
app.config.setdefault("RESTX_MASK_HEADER", "X-Fields")
|
||||
account = _make_account(TenantAccountRole.NORMAL)
|
||||
_patch_console_guards(monkeypatch, account, _make_app(), rbac_enabled=True)
|
||||
owned_app = App()
|
||||
owned_app.id = "app-123"
|
||||
owned_app.tenant_id = "tenant-123"
|
||||
owned_app.name = "Trace app"
|
||||
owned_app.description = ""
|
||||
owned_app.mode = AppMode.CHAT
|
||||
owned_app.icon_type = IconType.EMOJI
|
||||
owned_app.icon = "robot"
|
||||
owned_app.icon_background = "#ffffff"
|
||||
owned_app.enable_site = False
|
||||
owned_app.enable_api = False
|
||||
owned_app.api_rpm = 0
|
||||
owned_app.api_rph = 0
|
||||
owned_app.is_demo = False
|
||||
owned_app.is_public = False
|
||||
owned_app.is_universal = False
|
||||
owned_app.max_active_requests = None
|
||||
owned_app.maintainer = "other-account"
|
||||
owned_app.use_icon_as_answer_icon = False
|
||||
sqlite_session.add(owned_app)
|
||||
sqlite_session.commit()
|
||||
monkeypatch.setattr(common_wraps.db, "session", sqlite_session)
|
||||
monkeypatch.setattr(common_wraps.RBACService.CheckAccess, "check", MagicMock(return_value=False))
|
||||
service_mock = MagicMock(return_value=service_result)
|
||||
monkeypatch.setattr(ops_trace_module.OpsService, service_method_name, service_mock)
|
||||
service_method = _service_method(tracing_configs, method_name)
|
||||
service_method.side_effect = service_error
|
||||
query_or_payload = (
|
||||
ops_trace_module.TraceProviderQuery(tracing_provider=PROVIDER)
|
||||
if method_name in {"get", "delete"}
|
||||
else ops_trace_module.TraceConfigPayload(tracing_provider=PROVIDER, tracing_config={})
|
||||
)
|
||||
|
||||
with app.test_request_context(path, method=method_name.upper(), json=payload):
|
||||
with _patch_payload(payload):
|
||||
with pytest.raises(Forbidden):
|
||||
getattr(ops_trace_module.TraceAppConfigApi(), method_name)(app_id="app-123")
|
||||
with app.test_request_context("/"):
|
||||
with pytest.raises(expected_http_error) as exc_info:
|
||||
_original(_CONTROLLER_METHODS[method_name])(
|
||||
ops_trace_module.TraceAppConfigApi(),
|
||||
query_or_payload,
|
||||
_request_context(),
|
||||
UUID(APP_ID),
|
||||
)
|
||||
|
||||
service_mock.assert_not_called()
|
||||
assert exc_info.value.code == expected_status
|
||||
assert exc_info.value.error_code == expected_code
|
||||
assert exc_info.value.data == {
|
||||
"code": expected_code,
|
||||
"message": exc_info.value.description,
|
||||
"status": expected_status,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method_name", ["get", "post", "patch", "delete"])
|
||||
def test_trace_app_config_maps_untyped_value_errors_to_internal_error(
|
||||
app: Flask,
|
||||
tracing_configs: MagicMock,
|
||||
method_name: str,
|
||||
) -> None:
|
||||
service_method = _service_method(tracing_configs, method_name)
|
||||
service_method.side_effect = ValueError("internal detail")
|
||||
query_or_payload = (
|
||||
ops_trace_module.TraceProviderQuery(tracing_provider=PROVIDER)
|
||||
if method_name in {"get", "delete"}
|
||||
else ops_trace_module.TraceConfigPayload(tracing_provider=PROVIDER, tracing_config={})
|
||||
)
|
||||
|
||||
with app.test_request_context("/"), pytest.raises(TracingConfigProcessingError) as exc_info:
|
||||
_original(_CONTROLLER_METHODS[method_name])(
|
||||
ops_trace_module.TraceAppConfigApi(),
|
||||
query_or_payload,
|
||||
_request_context(),
|
||||
UUID(APP_ID),
|
||||
)
|
||||
|
||||
assert exc_info.value.code == 500
|
||||
assert exc_info.value.error_code == "tracing_config_processing_failed"
|
||||
assert exc_info.value.description == "The tracing configuration could not be processed."
|
||||
assert exc_info.value.data == {
|
||||
"code": "tracing_config_processing_failed",
|
||||
"message": "The tracing configuration could not be processed.",
|
||||
"status": 500,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method_name", ["get", "post", "patch", "delete"])
|
||||
def test_trace_app_config_does_not_mask_unexpected_errors(
|
||||
app: Flask,
|
||||
tracing_configs: MagicMock,
|
||||
method_name: str,
|
||||
) -> None:
|
||||
service_method = _service_method(tracing_configs, method_name)
|
||||
unexpected_error = RuntimeError("unexpected")
|
||||
service_method.side_effect = unexpected_error
|
||||
query_or_payload = (
|
||||
ops_trace_module.TraceProviderQuery(tracing_provider=PROVIDER)
|
||||
if method_name in {"get", "delete"}
|
||||
else ops_trace_module.TraceConfigPayload(tracing_provider=PROVIDER, tracing_config={})
|
||||
)
|
||||
|
||||
with app.test_request_context("/"), pytest.raises(RuntimeError) as exc_info:
|
||||
_original(_CONTROLLER_METHODS[method_name])(
|
||||
ops_trace_module.TraceAppConfigApi(),
|
||||
query_or_payload,
|
||||
_request_context(),
|
||||
UUID(APP_ID),
|
||||
)
|
||||
|
||||
assert exc_info.value is unexpected_error
|
||||
|
||||
@ -30,6 +30,7 @@ from repositories.account_oauth_repository import (
|
||||
from repositories.account_repository import SQLAlchemyAccountRepository
|
||||
from repositories.app_site_command_repository import AppSiteCommandRepository
|
||||
from repositories.app_statistic_query_repository import AppStatisticQueryRepository
|
||||
from repositories.app_tracing_config_repository import SQLAlchemyAppTracingConfigRepository
|
||||
from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository
|
||||
from repositories.workflow_app_log_query_repository import WorkflowAppLogQueryRepository
|
||||
from repositories.workflow_run_archive_repository import WorkflowRunArchiveBundleQueryRepository
|
||||
@ -58,6 +59,8 @@ from services.account_oauth_adapters import (
|
||||
RedisOAuthAccountClaimLock,
|
||||
)
|
||||
from services.app_site_service import AppSiteService
|
||||
from services.app_tracing_config_gateway import OpsTraceManagerGateway
|
||||
from services.app_tracing_config_service import AppTracingConfigService
|
||||
from services.auth.data_source_api_key_auth_service import DataSourceApiKeyAuthService
|
||||
from services.billing_portal_service import BillingPortalService
|
||||
from services.billing_service import BillingService
|
||||
@ -266,6 +269,22 @@ def test_build_application_services_wires_app_site_boundary(
|
||||
assert services.app_sites._sites._session_factory is sqlite_session_factory
|
||||
|
||||
|
||||
def test_build_application_services_wires_app_tracing_config_boundary(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
services = ext_application_services.build_application_services(
|
||||
database_client=sqlite_session_factory,
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
initialization_password="",
|
||||
redis=MagicMock(spec=RedisClientWrapper),
|
||||
)
|
||||
|
||||
assert isinstance(services.app_tracing_configs, AppTracingConfigService)
|
||||
assert isinstance(services.app_tracing_configs._configs, SQLAlchemyAppTracingConfigRepository)
|
||||
assert services.app_tracing_configs._configs._session_factory is sqlite_session_factory
|
||||
assert isinstance(services.app_tracing_configs._provider, OpsTraceManagerGateway)
|
||||
|
||||
|
||||
def test_build_application_services_wires_workflow_app_log_boundary(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
|
||||
@ -0,0 +1,145 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.model import App, AppMode, TraceAppConfig
|
||||
from repositories.app_tracing_config_repository import SQLAlchemyAppTracingConfigRepository
|
||||
from services.app_tracing_config_service import AppTracingConfigAppNotFoundError, AppTracingConfigRecord
|
||||
|
||||
_APP_ID = "11111111-1111-1111-1111-111111111111"
|
||||
_WORKSPACE_ID = "22222222-2222-2222-2222-222222222222"
|
||||
_OTHER_WORKSPACE_ID = "33333333-3333-3333-3333-333333333333"
|
||||
_PROVIDER = "langfuse"
|
||||
|
||||
|
||||
def _persist_app(session: Session) -> None:
|
||||
session.add(
|
||||
App(
|
||||
id=_APP_ID,
|
||||
tenant_id=_WORKSPACE_ID,
|
||||
name="Tracing App",
|
||||
description="",
|
||||
mode=AppMode.CHAT,
|
||||
icon_type=None,
|
||||
icon=None,
|
||||
icon_background=None,
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _repository(session_factory: sessionmaker[Session]) -> SQLAlchemyAppTracingConfigRepository:
|
||||
return SQLAlchemyAppTracingConfigRepository(session_factory=session_factory)
|
||||
|
||||
|
||||
def test_config_lifecycle_is_persisted_by_owned_transactions(
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
_persist_app(sqlite_session)
|
||||
repository = _repository(sqlite_session_factory)
|
||||
|
||||
assert repository.get(workspace_id=_WORKSPACE_ID, app_id=_APP_ID, tracing_provider=_PROVIDER) is None
|
||||
assert repository.create(
|
||||
workspace_id=_WORKSPACE_ID,
|
||||
app_id=_APP_ID,
|
||||
tracing_provider=_PROVIDER,
|
||||
tracing_config={"public_key": "original"},
|
||||
)
|
||||
assert not repository.create(
|
||||
workspace_id=_WORKSPACE_ID,
|
||||
app_id=_APP_ID,
|
||||
tracing_provider=_PROVIDER,
|
||||
tracing_config={"public_key": "duplicate"},
|
||||
)
|
||||
|
||||
record = repository.get(workspace_id=_WORKSPACE_ID, app_id=_APP_ID, tracing_provider=_PROVIDER)
|
||||
assert isinstance(record, AppTracingConfigRecord)
|
||||
assert record.app_id == _APP_ID
|
||||
assert record.tracing_provider == _PROVIDER
|
||||
assert record.tracing_config == {"public_key": "original"}
|
||||
|
||||
assert repository.update(
|
||||
workspace_id=_WORKSPACE_ID,
|
||||
app_id=_APP_ID,
|
||||
tracing_provider=_PROVIDER,
|
||||
tracing_config={"public_key": "updated"},
|
||||
)
|
||||
with sqlite_session_factory() as session:
|
||||
config = session.scalar(select(TraceAppConfig).where(TraceAppConfig.app_id == _APP_ID))
|
||||
assert config is not None
|
||||
assert config.tracing_config == {"public_key": "updated"}
|
||||
|
||||
assert repository.delete(workspace_id=_WORKSPACE_ID, app_id=_APP_ID, tracing_provider=_PROVIDER)
|
||||
assert not repository.update(
|
||||
workspace_id=_WORKSPACE_ID,
|
||||
app_id=_APP_ID,
|
||||
tracing_provider=_PROVIDER,
|
||||
tracing_config={"public_key": "missing"},
|
||||
)
|
||||
assert not repository.delete(workspace_id=_WORKSPACE_ID, app_id=_APP_ID, tracing_provider=_PROVIDER)
|
||||
with sqlite_session_factory() as session:
|
||||
assert session.scalar(select(TraceAppConfig).where(TraceAppConfig.app_id == _APP_ID)) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("app_state", ["other-workspace", "non-normal"])
|
||||
def test_all_operations_reject_apps_outside_the_active_workspace_scope(
|
||||
app_state: str,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
_persist_app(sqlite_session)
|
||||
workspace_id = _OTHER_WORKSPACE_ID
|
||||
if app_state == "non-normal":
|
||||
sqlite_session.execute(text("UPDATE apps SET status = 'disabled' WHERE id = :app_id"), {"app_id": _APP_ID})
|
||||
sqlite_session.commit()
|
||||
workspace_id = _WORKSPACE_ID
|
||||
|
||||
repository = _repository(sqlite_session_factory)
|
||||
operations: tuple[Callable[[], object], ...] = (
|
||||
lambda: repository.get(workspace_id=workspace_id, app_id=_APP_ID, tracing_provider=_PROVIDER),
|
||||
lambda: repository.create(
|
||||
workspace_id=workspace_id,
|
||||
app_id=_APP_ID,
|
||||
tracing_provider=_PROVIDER,
|
||||
tracing_config={},
|
||||
),
|
||||
lambda: repository.update(
|
||||
workspace_id=workspace_id,
|
||||
app_id=_APP_ID,
|
||||
tracing_provider=_PROVIDER,
|
||||
tracing_config={},
|
||||
),
|
||||
lambda: repository.delete(workspace_id=workspace_id, app_id=_APP_ID, tracing_provider=_PROVIDER),
|
||||
)
|
||||
|
||||
for operation in operations:
|
||||
with pytest.raises(AppTracingConfigAppNotFoundError):
|
||||
operation()
|
||||
|
||||
|
||||
def test_record_mapping_does_not_expose_the_orm_model_or_its_config_dict(
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
_persist_app(sqlite_session)
|
||||
config = TraceAppConfig(
|
||||
app_id=_APP_ID,
|
||||
tracing_provider=_PROVIDER,
|
||||
tracing_config={"public_key": "original"},
|
||||
)
|
||||
sqlite_session.add(config)
|
||||
sqlite_session.commit()
|
||||
|
||||
record = SQLAlchemyAppTracingConfigRepository._to_record(config)
|
||||
|
||||
assert isinstance(record, AppTracingConfigRecord)
|
||||
assert record is not config
|
||||
assert record.tracing_config == config.tracing_config
|
||||
assert record.tracing_config is not config.tracing_config
|
||||
assert record.tracing_config is not None
|
||||
record.tracing_config["public_key"] = "changed"
|
||||
assert config.tracing_config == {"public_key": "original"}
|
||||
360
api/tests/unit_tests/services/test_app_tracing_config_gateway.py
Normal file
360
api/tests/unit_tests/services/test_app_tracing_config_gateway.py
Normal file
@ -0,0 +1,360 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationInfo, field_validator
|
||||
|
||||
from core.ops.entities.config_entity import BaseTracingConfig
|
||||
from core.ops.ops_trace_manager import TracingProviderConfigEntry
|
||||
from services import app_tracing_config_gateway as gateway_module
|
||||
from services.app_tracing_config_gateway import OpsTraceManagerGateway
|
||||
from services.app_tracing_config_service import (
|
||||
AppTracingConfigInvalidConfigurationError,
|
||||
AppTracingConfigInvalidProviderError,
|
||||
AppTracingConfigProcessingError,
|
||||
AppTracingConfigVerificationFailedError,
|
||||
)
|
||||
|
||||
|
||||
class _ProviderConfig(BaseTracingConfig):
|
||||
endpoint: str = "https://default.example.com"
|
||||
project: str = "default-project"
|
||||
|
||||
@field_validator("endpoint", "project", mode="before")
|
||||
@classmethod
|
||||
def replace_empty_with_default(cls, value: object, info: ValidationInfo) -> object:
|
||||
if value != "":
|
||||
return value
|
||||
if info.field_name == "endpoint":
|
||||
return "https://default.example.com"
|
||||
return "default-project"
|
||||
|
||||
|
||||
def _provider_entry(*, other_keys: list[str] | None = None) -> TracingProviderConfigEntry:
|
||||
return {
|
||||
"config_class": _ProviderConfig,
|
||||
"secret_keys": ["api_key"],
|
||||
"other_keys": other_keys or [],
|
||||
"trace_instance": object,
|
||||
}
|
||||
|
||||
|
||||
def test_validate_provider_rejects_unknown_provider(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(gateway_module, "provider_config_map", {})
|
||||
|
||||
with pytest.raises(AppTracingConfigInvalidProviderError, match="Invalid tracing provider: unknown"):
|
||||
OpsTraceManagerGateway().validate_provider("unknown")
|
||||
|
||||
|
||||
def test_prepare_new_config_applies_defaults_validates_and_encrypts(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
gateway_module,
|
||||
"provider_config_map",
|
||||
{"arize": _provider_entry(other_keys=["endpoint", "project"])},
|
||||
)
|
||||
submitted = {"api_key": "plain", "endpoint": "", "project": ""}
|
||||
|
||||
with patch.object(gateway_module, "OpsTraceManager") as manager:
|
||||
manager.check_trace_config_is_effective.return_value = True
|
||||
manager.get_trace_config_project_url.return_value = "https://project.example.com"
|
||||
manager.encrypt_tracing_config.return_value = {"api_key": "encrypted"}
|
||||
|
||||
result = OpsTraceManagerGateway().prepare_new_config(
|
||||
workspace_id="workspace-1",
|
||||
tracing_provider="arize",
|
||||
tracing_config=submitted,
|
||||
)
|
||||
|
||||
normalized = {
|
||||
"api_key": "plain",
|
||||
"endpoint": "https://default.example.com",
|
||||
"project": "default-project",
|
||||
}
|
||||
assert submitted == {"api_key": "plain", "endpoint": "", "project": ""}
|
||||
assert result == {"api_key": "encrypted", "project_url": "https://project.example.com"}
|
||||
manager.check_trace_config_is_effective.assert_called_once_with(normalized, "arize")
|
||||
manager.encrypt_tracing_config.assert_called_once_with("workspace-1", "arize", normalized)
|
||||
|
||||
|
||||
def test_prepare_new_config_reports_failed_verification_before_encryption(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(gateway_module, "provider_config_map", {"arize": _provider_entry()})
|
||||
|
||||
with patch.object(gateway_module, "OpsTraceManager") as manager:
|
||||
manager.check_trace_config_is_effective.return_value = False
|
||||
|
||||
with pytest.raises(AppTracingConfigVerificationFailedError):
|
||||
OpsTraceManagerGateway().prepare_new_config(
|
||||
workspace_id="workspace-1",
|
||||
tracing_provider="arize",
|
||||
tracing_config={"api_key": "plain"},
|
||||
)
|
||||
|
||||
manager.encrypt_tracing_config.assert_not_called()
|
||||
|
||||
|
||||
def test_prepare_new_config_keeps_success_when_project_url_lookup_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(gateway_module, "provider_config_map", {"arize": _provider_entry()})
|
||||
|
||||
with patch.object(gateway_module, "OpsTraceManager") as manager:
|
||||
manager.check_trace_config_is_effective.return_value = True
|
||||
manager.get_trace_config_project_url.side_effect = RuntimeError("provider unavailable")
|
||||
manager.encrypt_tracing_config.return_value = {"api_key": "encrypted"}
|
||||
|
||||
result = OpsTraceManagerGateway().prepare_new_config(
|
||||
workspace_id="workspace-1",
|
||||
tracing_provider="arize",
|
||||
tracing_config={"api_key": "plain"},
|
||||
)
|
||||
|
||||
assert result == {"api_key": "encrypted"}
|
||||
|
||||
|
||||
def test_prepare_new_langfuse_config_builds_project_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(gateway_module, "provider_config_map", {"langfuse": _provider_entry()})
|
||||
|
||||
with patch.object(gateway_module, "OpsTraceManager") as manager:
|
||||
manager.check_trace_config_is_effective.return_value = True
|
||||
manager.get_trace_config_project_key.return_value = "project-key"
|
||||
manager.encrypt_tracing_config.return_value = {"secret_key": "encrypted"}
|
||||
|
||||
result = OpsTraceManagerGateway().prepare_new_config(
|
||||
workspace_id="workspace-1",
|
||||
tracing_provider="langfuse",
|
||||
tracing_config={"host": "https://langfuse.example.com"},
|
||||
)
|
||||
|
||||
assert result["project_url"] == "https://langfuse.example.com/project/project-key"
|
||||
|
||||
|
||||
def test_prepare_new_config_reports_provider_check_exception_as_failed_verification(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(gateway_module, "provider_config_map", {"arize": _provider_entry()})
|
||||
|
||||
with patch.object(gateway_module, "OpsTraceManager") as manager:
|
||||
manager.check_trace_config_is_effective.side_effect = ValueError("verification failed")
|
||||
|
||||
with pytest.raises(AppTracingConfigVerificationFailedError) as caught:
|
||||
OpsTraceManagerGateway().prepare_new_config(
|
||||
workspace_id="workspace-1",
|
||||
tracing_provider="arize",
|
||||
tracing_config={},
|
||||
)
|
||||
|
||||
assert isinstance(caught.value.__cause__, ValueError)
|
||||
|
||||
|
||||
def test_prepare_new_config_propagates_unexpected_verification_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(gateway_module, "provider_config_map", {"arize": _provider_entry()})
|
||||
failure = RuntimeError("unexpected provider bug")
|
||||
|
||||
with patch.object(gateway_module, "OpsTraceManager") as manager:
|
||||
manager.check_trace_config_is_effective.side_effect = failure
|
||||
|
||||
with pytest.raises(RuntimeError) as caught:
|
||||
OpsTraceManagerGateway().prepare_new_config(
|
||||
workspace_id="workspace-1",
|
||||
tracing_provider="arize",
|
||||
tracing_config={},
|
||||
)
|
||||
|
||||
assert caught.value is failure
|
||||
manager.encrypt_tracing_config.assert_not_called()
|
||||
|
||||
|
||||
def test_prepare_new_config_rejects_invalid_schema_before_verification(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(gateway_module, "provider_config_map", {"arize": _provider_entry()})
|
||||
|
||||
with patch.object(gateway_module, "OpsTraceManager") as manager:
|
||||
with pytest.raises(AppTracingConfigInvalidConfigurationError):
|
||||
OpsTraceManagerGateway().prepare_new_config(
|
||||
workspace_id="workspace-1",
|
||||
tracing_provider="arize",
|
||||
tracing_config={"endpoint": {"invalid": "value"}},
|
||||
)
|
||||
|
||||
manager.check_trace_config_is_effective.assert_not_called()
|
||||
manager.encrypt_tracing_config.assert_not_called()
|
||||
|
||||
|
||||
def test_prepare_new_config_reports_encryption_failure_as_processing_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(gateway_module, "provider_config_map", {"arize": _provider_entry()})
|
||||
failure = RuntimeError("key provider unavailable")
|
||||
|
||||
with patch.object(gateway_module, "OpsTraceManager") as manager:
|
||||
manager.check_trace_config_is_effective.return_value = True
|
||||
manager.get_trace_config_project_url.return_value = None
|
||||
manager.encrypt_tracing_config.side_effect = failure
|
||||
|
||||
with pytest.raises(AppTracingConfigProcessingError) as caught:
|
||||
OpsTraceManagerGateway().prepare_new_config(
|
||||
workspace_id="workspace-1",
|
||||
tracing_provider="arize",
|
||||
tracing_config={},
|
||||
)
|
||||
|
||||
assert caught.value.__cause__ is failure
|
||||
|
||||
|
||||
def test_prepare_updated_config_preserves_masked_secret_from_current_config(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(gateway_module, "provider_config_map", {"arize": _provider_entry()})
|
||||
submitted = {"api_key": "******", "project": "new-project"}
|
||||
current = {"api_key": "old-encrypted", "project": "old-project"}
|
||||
encrypted = {"api_key": "old-encrypted", "project": "new-project"}
|
||||
decrypted = {"api_key": "old-plain", "project": "new-project"}
|
||||
|
||||
with patch.object(gateway_module, "OpsTraceManager") as manager:
|
||||
manager.encrypt_tracing_config.return_value = encrypted
|
||||
manager.decrypt_tracing_config.return_value = decrypted
|
||||
manager.check_trace_config_is_effective.return_value = True
|
||||
|
||||
result = OpsTraceManagerGateway().prepare_updated_config(
|
||||
workspace_id="workspace-1",
|
||||
tracing_provider="arize",
|
||||
tracing_config=submitted,
|
||||
current_tracing_config=current,
|
||||
)
|
||||
|
||||
assert result == encrypted
|
||||
assert submitted == {"api_key": "******", "project": "new-project"}
|
||||
manager.encrypt_tracing_config.assert_called_once_with("workspace-1", "arize", submitted, current)
|
||||
manager.decrypt_tracing_config.assert_called_once_with("workspace-1", "arize", encrypted)
|
||||
manager.check_trace_config_is_effective.assert_called_once_with(decrypted, "arize")
|
||||
|
||||
|
||||
def test_prepare_updated_config_validates_schema_before_encryption(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(gateway_module, "provider_config_map", {"arize": _provider_entry()})
|
||||
|
||||
with patch.object(gateway_module, "OpsTraceManager") as manager:
|
||||
with pytest.raises(AppTracingConfigInvalidConfigurationError):
|
||||
OpsTraceManagerGateway().prepare_updated_config(
|
||||
workspace_id="workspace-1",
|
||||
tracing_provider="arize",
|
||||
tracing_config={"endpoint": {"invalid": "value"}},
|
||||
current_tracing_config={"api_key": "old-encrypted"},
|
||||
)
|
||||
|
||||
manager.encrypt_tracing_config.assert_not_called()
|
||||
manager.decrypt_tracing_config.assert_not_called()
|
||||
manager.check_trace_config_is_effective.assert_not_called()
|
||||
|
||||
|
||||
def test_prepare_updated_config_reports_decryption_failure_as_processing_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(gateway_module, "provider_config_map", {"arize": _provider_entry()})
|
||||
failure = RuntimeError("stored credential cannot be decrypted")
|
||||
|
||||
with patch.object(gateway_module, "OpsTraceManager") as manager:
|
||||
manager.encrypt_tracing_config.return_value = {"api_key": "encrypted"}
|
||||
manager.decrypt_tracing_config.side_effect = failure
|
||||
|
||||
with pytest.raises(AppTracingConfigProcessingError) as caught:
|
||||
OpsTraceManagerGateway().prepare_updated_config(
|
||||
workspace_id="workspace-1",
|
||||
tracing_provider="arize",
|
||||
tracing_config={"project": "new-project"},
|
||||
current_tracing_config={"api_key": "old-encrypted"},
|
||||
)
|
||||
|
||||
assert caught.value.__cause__ is failure
|
||||
manager.check_trace_config_is_effective.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider", "fallback_url"),
|
||||
[
|
||||
("arize", "https://app.arize.com/"),
|
||||
("phoenix", "https://app.phoenix.arize.com/projects/"),
|
||||
("langsmith", "https://smith.langchain.com/"),
|
||||
("opik", "https://www.comet.com/opik/"),
|
||||
("weave", "https://wandb.ai/"),
|
||||
("aliyun", "https://arms.console.aliyun.com/"),
|
||||
("tencent", "https://console.cloud.tencent.com/apm"),
|
||||
("mlflow", "http://localhost:5000/"),
|
||||
("databricks", "https://www.databricks.com/"),
|
||||
],
|
||||
)
|
||||
def test_present_config_uses_provider_fallback_when_project_lookup_fails(
|
||||
provider: str,
|
||||
fallback_url: str,
|
||||
) -> None:
|
||||
with patch.object(gateway_module, "OpsTraceManager") as manager:
|
||||
decrypted_config: dict[str, object] = {}
|
||||
presented_config: dict[str, object] = {}
|
||||
manager.decrypt_tracing_config.return_value = decrypted_config
|
||||
manager.obfuscated_decrypt_token.return_value = presented_config
|
||||
manager.get_trace_config_project_url.side_effect = RuntimeError("provider unavailable")
|
||||
|
||||
result = OpsTraceManagerGateway().present_config(
|
||||
workspace_id="workspace-1",
|
||||
tracing_provider=provider,
|
||||
tracing_config={"encrypted": "config"},
|
||||
)
|
||||
|
||||
assert result == {"project_url": fallback_url}
|
||||
|
||||
|
||||
def test_present_langfuse_config_builds_project_url() -> None:
|
||||
with patch.object(gateway_module, "OpsTraceManager") as manager:
|
||||
manager.decrypt_tracing_config.return_value = {"host": "https://langfuse.example.com"}
|
||||
manager.obfuscated_decrypt_token.return_value = {"host": "https://langfuse.example.com"}
|
||||
manager.get_trace_config_project_key.return_value = "project-key"
|
||||
|
||||
result = OpsTraceManagerGateway().present_config(
|
||||
workspace_id="workspace-1",
|
||||
tracing_provider="langfuse",
|
||||
tracing_config={"encrypted": "config"},
|
||||
)
|
||||
|
||||
assert result["project_url"] == "https://langfuse.example.com/project/project-key"
|
||||
|
||||
|
||||
def test_present_langfuse_config_falls_back_to_host() -> None:
|
||||
with patch.object(gateway_module, "OpsTraceManager") as manager:
|
||||
manager.decrypt_tracing_config.return_value = {"host": "https://langfuse.example.com"}
|
||||
manager.obfuscated_decrypt_token.return_value = {"host": "https://langfuse.example.com"}
|
||||
manager.get_trace_config_project_key.side_effect = RuntimeError("provider unavailable")
|
||||
|
||||
result = OpsTraceManagerGateway().present_config(
|
||||
workspace_id="workspace-1",
|
||||
tracing_provider="langfuse",
|
||||
tracing_config={"encrypted": "config"},
|
||||
)
|
||||
|
||||
assert result["project_url"] == "https://langfuse.example.com/"
|
||||
|
||||
|
||||
def test_present_config_rejects_missing_stored_config() -> None:
|
||||
with pytest.raises(AppTracingConfigProcessingError, match="processing failed"):
|
||||
OpsTraceManagerGateway().present_config(
|
||||
workspace_id="workspace-1",
|
||||
tracing_provider="arize",
|
||||
tracing_config=None,
|
||||
)
|
||||
|
||||
|
||||
def test_present_config_reports_decryption_failure_as_processing_error() -> None:
|
||||
failure = RuntimeError("stored credential cannot be decrypted")
|
||||
|
||||
with patch.object(gateway_module, "OpsTraceManager") as manager:
|
||||
manager.decrypt_tracing_config.side_effect = failure
|
||||
|
||||
with pytest.raises(AppTracingConfigProcessingError) as caught:
|
||||
OpsTraceManagerGateway().present_config(
|
||||
workspace_id="workspace-1",
|
||||
tracing_provider="arize",
|
||||
tracing_config={"encrypted": "config"},
|
||||
)
|
||||
|
||||
assert caught.value.__cause__ is failure
|
||||
manager.obfuscated_decrypt_token.assert_not_called()
|
||||
229
api/tests/unit_tests/services/test_app_tracing_config_service.py
Normal file
229
api/tests/unit_tests/services/test_app_tracing_config_service.py
Normal file
@ -0,0 +1,229 @@
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from machinery.context import RequestContext
|
||||
from services.app_tracing_config_service import (
|
||||
AppTracingConfigAlreadyExistsError,
|
||||
AppTracingConfigNotFoundError,
|
||||
AppTracingConfigRecord,
|
||||
AppTracingConfigService,
|
||||
)
|
||||
|
||||
|
||||
def _context() -> RequestContext:
|
||||
return RequestContext("request-1", None, "account-1", "workspace-1")
|
||||
|
||||
|
||||
def _record(*, tracing_config: dict[str, object] | None = None) -> AppTracingConfigRecord:
|
||||
timestamp = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
return AppTracingConfigRecord(
|
||||
id="config-1",
|
||||
app_id="app-1",
|
||||
tracing_provider="arize",
|
||||
tracing_config=tracing_config,
|
||||
is_active=True,
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def test_get_scopes_lookup_to_workspace_and_presents_config() -> None:
|
||||
stored = _record(tracing_config={"api_key": "encrypted"})
|
||||
configs = MagicMock()
|
||||
configs.get.return_value = stored
|
||||
provider = MagicMock()
|
||||
provider.present_config.return_value = {"api_key": "******", "project_url": "https://example.com"}
|
||||
service = AppTracingConfigService(configs=configs, provider=provider)
|
||||
|
||||
result = service.get(_context(), "app-1", "arize")
|
||||
|
||||
assert result == _record(tracing_config={"api_key": "******", "project_url": "https://example.com"})
|
||||
assert stored.tracing_config == {"api_key": "encrypted"}
|
||||
provider.validate_provider.assert_called_once_with("arize")
|
||||
configs.get.assert_called_once_with(
|
||||
workspace_id="workspace-1",
|
||||
app_id="app-1",
|
||||
tracing_provider="arize",
|
||||
)
|
||||
provider.present_config.assert_called_once_with(
|
||||
workspace_id="workspace-1",
|
||||
tracing_provider="arize",
|
||||
tracing_config={"api_key": "encrypted"},
|
||||
)
|
||||
|
||||
|
||||
def test_get_returns_none_without_calling_provider_for_missing_config() -> None:
|
||||
configs = MagicMock()
|
||||
configs.get.return_value = None
|
||||
provider = MagicMock()
|
||||
service = AppTracingConfigService(configs=configs, provider=provider)
|
||||
|
||||
assert service.get(_context(), "app-1", "arize") is None
|
||||
|
||||
provider.validate_provider.assert_called_once_with("arize")
|
||||
provider.present_config.assert_not_called()
|
||||
|
||||
|
||||
def test_get_validates_provider_before_store_lookup() -> None:
|
||||
events: list[str] = []
|
||||
configs = MagicMock()
|
||||
configs.get.side_effect = lambda **_: events.append("store.get")
|
||||
provider = MagicMock()
|
||||
provider.validate_provider.side_effect = lambda *_: events.append("provider.validate")
|
||||
service = AppTracingConfigService(configs=configs, provider=provider)
|
||||
|
||||
assert service.get(_context(), "app-1", "arize") is None
|
||||
|
||||
assert events == ["provider.validate", "store.get"]
|
||||
|
||||
|
||||
def test_create_calls_provider_between_separate_store_operations() -> None:
|
||||
events: list[str] = []
|
||||
configs = MagicMock()
|
||||
|
||||
def get_config(**_: object) -> None:
|
||||
events.append("store.get:closed")
|
||||
|
||||
def create_config(**_: object) -> bool:
|
||||
events.append("store.create:closed")
|
||||
return True
|
||||
|
||||
configs.get.side_effect = get_config
|
||||
configs.create.side_effect = create_config
|
||||
provider = MagicMock()
|
||||
|
||||
def prepare_config(**_: object) -> dict[str, str]:
|
||||
events.append("provider.prepare")
|
||||
return {"api_key": "encrypted"}
|
||||
|
||||
provider.prepare_new_config.side_effect = prepare_config
|
||||
service = AppTracingConfigService(configs=configs, provider=provider)
|
||||
|
||||
service.create(_context(), "app-1", "arize", {"api_key": "plain"})
|
||||
|
||||
assert events == ["store.get:closed", "provider.prepare", "store.create:closed"]
|
||||
provider.prepare_new_config.assert_called_once_with(
|
||||
workspace_id="workspace-1",
|
||||
tracing_provider="arize",
|
||||
tracing_config={"api_key": "plain"},
|
||||
)
|
||||
configs.create.assert_called_once_with(
|
||||
workspace_id="workspace-1",
|
||||
app_id="app-1",
|
||||
tracing_provider="arize",
|
||||
tracing_config={"api_key": "encrypted"},
|
||||
)
|
||||
|
||||
|
||||
def test_create_reports_duplicate_before_provider_preparation() -> None:
|
||||
configs = MagicMock()
|
||||
configs.get.return_value = _record(tracing_config={"api_key": "encrypted"})
|
||||
provider = MagicMock()
|
||||
provider.prepare_new_config.return_value = {"api_key": "new-encrypted"}
|
||||
service = AppTracingConfigService(configs=configs, provider=provider)
|
||||
|
||||
with pytest.raises(AppTracingConfigAlreadyExistsError, match="Trace config is exist"):
|
||||
service.create(_context(), "app-1", "arize", {"api_key": "plain"})
|
||||
|
||||
provider.prepare_new_config.assert_not_called()
|
||||
configs.create.assert_not_called()
|
||||
|
||||
|
||||
def test_create_reports_duplicate_detected_during_write() -> None:
|
||||
configs = MagicMock()
|
||||
configs.get.return_value = None
|
||||
configs.create.return_value = False
|
||||
provider = MagicMock()
|
||||
provider.prepare_new_config.return_value = {"api_key": "encrypted"}
|
||||
service = AppTracingConfigService(configs=configs, provider=provider)
|
||||
|
||||
with pytest.raises(AppTracingConfigAlreadyExistsError):
|
||||
service.create(_context(), "app-1", "arize", {"api_key": "plain"})
|
||||
|
||||
|
||||
def test_update_validates_reads_prepares_and_writes_in_order() -> None:
|
||||
events: list[str] = []
|
||||
current = _record(tracing_config={"api_key": "old-encrypted"})
|
||||
configs = MagicMock()
|
||||
configs.get.side_effect = lambda **_: events.append("store.get:closed") or current
|
||||
configs.update.side_effect = lambda **_: events.append("store.update:closed") or True
|
||||
provider = MagicMock()
|
||||
provider.validate_provider.side_effect = lambda *_: events.append("provider.validate")
|
||||
provider.prepare_updated_config.side_effect = lambda **_: (
|
||||
events.append("provider.prepare") or {"api_key": "new-encrypted"}
|
||||
)
|
||||
service = AppTracingConfigService(configs=configs, provider=provider)
|
||||
|
||||
service.update(_context(), "app-1", "arize", {"api_key": "******"})
|
||||
|
||||
assert events == ["store.get:closed", "provider.validate", "provider.prepare", "store.update:closed"]
|
||||
provider.prepare_updated_config.assert_called_once_with(
|
||||
workspace_id="workspace-1",
|
||||
tracing_provider="arize",
|
||||
tracing_config={"api_key": "******"},
|
||||
current_tracing_config={"api_key": "old-encrypted"},
|
||||
)
|
||||
configs.update.assert_called_once_with(
|
||||
workspace_id="workspace-1",
|
||||
app_id="app-1",
|
||||
tracing_provider="arize",
|
||||
tracing_config={"api_key": "new-encrypted"},
|
||||
)
|
||||
|
||||
|
||||
def test_update_reports_config_missing_before_provider_preparation() -> None:
|
||||
configs = MagicMock()
|
||||
configs.get.return_value = None
|
||||
provider = MagicMock()
|
||||
service = AppTracingConfigService(configs=configs, provider=provider)
|
||||
|
||||
with pytest.raises(AppTracingConfigNotFoundError, match="Trace config not exist"):
|
||||
service.update(_context(), "app-1", "arize", {})
|
||||
|
||||
provider.validate_provider.assert_called_once_with("arize")
|
||||
provider.prepare_updated_config.assert_not_called()
|
||||
configs.update.assert_not_called()
|
||||
|
||||
|
||||
def test_update_reports_config_removed_before_write() -> None:
|
||||
configs = MagicMock()
|
||||
configs.get.return_value = _record(tracing_config={"api_key": "old-encrypted"})
|
||||
configs.update.return_value = False
|
||||
provider = MagicMock()
|
||||
provider.prepare_updated_config.return_value = {"api_key": "new-encrypted"}
|
||||
service = AppTracingConfigService(configs=configs, provider=provider)
|
||||
|
||||
with pytest.raises(AppTracingConfigNotFoundError):
|
||||
service.update(_context(), "app-1", "arize", {"api_key": "******"})
|
||||
|
||||
|
||||
def test_delete_scopes_to_workspace_and_reports_missing_config() -> None:
|
||||
configs = MagicMock()
|
||||
configs.delete.return_value = False
|
||||
provider = MagicMock()
|
||||
service = AppTracingConfigService(configs=configs, provider=provider)
|
||||
|
||||
with pytest.raises(AppTracingConfigNotFoundError):
|
||||
service.delete(_context(), "app-1", "arize")
|
||||
|
||||
configs.delete.assert_called_once_with(
|
||||
workspace_id="workspace-1",
|
||||
app_id="app-1",
|
||||
tracing_provider="arize",
|
||||
)
|
||||
provider.validate_provider.assert_called_once_with("arize")
|
||||
|
||||
|
||||
def test_delete_validates_provider_before_store_delete() -> None:
|
||||
events: list[str] = []
|
||||
configs = MagicMock()
|
||||
configs.delete.side_effect = lambda **_: events.append("store.delete") or True
|
||||
provider = MagicMock()
|
||||
provider.validate_provider.side_effect = lambda *_: events.append("provider.validate")
|
||||
service = AppTracingConfigService(configs=configs, provider=provider)
|
||||
|
||||
service.delete(_context(), "app-1", "arize")
|
||||
|
||||
assert events == ["provider.validate", "store.delete"]
|
||||
@ -5003,6 +5003,8 @@ export type DeleteAppsByAppIdTraceConfigData = {
|
||||
export type DeleteAppsByAppIdTraceConfigErrors = {
|
||||
400: unknown
|
||||
403: unknown
|
||||
404: unknown
|
||||
500: unknown
|
||||
}
|
||||
|
||||
export type DeleteAppsByAppIdTraceConfigResponses = {
|
||||
@ -5025,6 +5027,8 @@ export type GetAppsByAppIdTraceConfigData = {
|
||||
|
||||
export type GetAppsByAppIdTraceConfigErrors = {
|
||||
400: unknown
|
||||
404: unknown
|
||||
500: unknown
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdTraceConfigResponses = {
|
||||
@ -5046,6 +5050,8 @@ export type PatchAppsByAppIdTraceConfigData = {
|
||||
export type PatchAppsByAppIdTraceConfigErrors = {
|
||||
400: unknown
|
||||
403: unknown
|
||||
404: unknown
|
||||
500: unknown
|
||||
}
|
||||
|
||||
export type PatchAppsByAppIdTraceConfigResponses = {
|
||||
@ -5067,6 +5073,9 @@ export type PostAppsByAppIdTraceConfigData = {
|
||||
export type PostAppsByAppIdTraceConfigErrors = {
|
||||
400: unknown
|
||||
403: unknown
|
||||
404: unknown
|
||||
409: unknown
|
||||
500: unknown
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdTraceConfigResponses = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user