refactor(console): decouple app tracing configuration (#41576)

This commit is contained in:
非法操作 2026-09-07 02:47:00 +00:00 committed by GitHub
parent dde1d500b5
commit 4b0e260ac5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 2064 additions and 921 deletions

View File

@ -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

View File

@ -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."""

View File

@ -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

View File

@ -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,

View File

@ -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)**

View 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,
)

View 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

View 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

View File

@ -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

View File

@ -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"]

View File

@ -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",

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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"}

View 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()

View 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"]

View File

@ -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 = {