diff --git a/.env.sample b/.env.sample index 5244d3c41..7c0e45ba0 100644 --- a/.env.sample +++ b/.env.sample @@ -113,3 +113,13 @@ WORKERS=4 # Feature flags AUTH_ENABLED=false SANITIZE_ANSWER=false + +# ============================================================================= +# Logging Configuration +# ============================================================================= +# Basic logging level (DEBUG, INFO, WARNING, ERROR) +AZURE_BASIC_LOGGING_LEVEL=INFO +# Logging level for Azure SDK and third-party packages (DEBUG, INFO, WARNING, ERROR) +AZURE_PACKAGE_LOGGING_LEVEL=WARNING +# Comma-separated list of Python logger names to apply package logging level to +AZURE_LOGGING_PACKAGES= diff --git a/infra/main.bicep b/infra/main.bicep index 5e1776719..b7042edbe 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -351,7 +351,6 @@ module applicationInsights 'br/public:avm/res/insights/component:0.7.1' = if (en disableIpMasking: false flowType: 'Bluefield' workspaceResourceId: logAnalyticsWorkspaceResourceId - diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] } } @@ -887,6 +886,12 @@ module containerInstance 'modules/container-instance.bicep' = { { name: 'AZURE_AI_PROJECT_ENDPOINT', value: aiFoundryAiProjectEndpoint } { name: 'AZURE_AI_MODEL_DEPLOYMENT_NAME', value: gptModelName } { name: 'AZURE_AI_IMAGE_MODEL_DEPLOYMENT', value: imageModelConfig[imageModelChoice].name } + // Logging Settings + { name: 'AZURE_BASIC_LOGGING_LEVEL', value: 'INFO' } + { name: 'AZURE_PACKAGE_LOGGING_LEVEL', value: 'WARNING' } + { name: 'AZURE_LOGGING_PACKAGES', value: '' } + // Application Insights + { name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: enableMonitoring ? applicationInsights!.outputs.connectionString : '' } ] } } diff --git a/infra/main.json b/infra/main.json index 4e25c3002..50c43b3ec 100644 --- a/infra/main.json +++ b/infra/main.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.41.2.15936", - "templateHash": "7762801937040004596" + "templateHash": "14392918201193616164" }, "name": "Intelligent Content Generation Accelerator", "description": "Solution Accelerator for multimodal marketing content generation using Microsoft Agent Framework.\n" @@ -3570,14 +3570,7 @@ "flowType": { "value": "Bluefield" }, - "workspaceResourceId": "[if(variables('useExistingLogAnalytics'), createObject('value', parameters('existingLogAnalyticsWorkspaceId')), if(parameters('enableMonitoring'), createObject('value', reference('logAnalyticsWorkspace').outputs.resourceId.value), createObject('value', '')))]", - "diagnosticSettings": { - "value": [ - { - "workspaceResourceId": "[if(variables('useExistingLogAnalytics'), parameters('existingLogAnalyticsWorkspaceId'), if(parameters('enableMonitoring'), reference('logAnalyticsWorkspace').outputs.resourceId.value, ''))]" - } - ] - } + "workspaceResourceId": "[if(variables('useExistingLogAnalytics'), createObject('value', parameters('existingLogAnalyticsWorkspaceId')), if(parameters('enableMonitoring'), createObject('value', reference('logAnalyticsWorkspace').outputs.resourceId.value), createObject('value', '')))]" }, "template": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", @@ -33165,6 +33158,22 @@ { "name": "AZURE_AI_IMAGE_MODEL_DEPLOYMENT", "value": "[variables('imageModelConfig')[parameters('imageModelChoice')].name]" + }, + { + "name": "AZURE_BASIC_LOGGING_LEVEL", + "value": "INFO" + }, + { + "name": "AZURE_PACKAGE_LOGGING_LEVEL", + "value": "WARNING" + }, + { + "name": "AZURE_LOGGING_PACKAGES", + "value": "" + }, + { + "name": "APPLICATIONINSIGHTS_CONNECTION_STRING", + "value": "[if(parameters('enableMonitoring'), reference('applicationInsights').outputs.connectionString.value, '')]" } ] } @@ -33356,6 +33365,7 @@ }, "dependsOn": [ "aiFoundryAiServicesProject", + "applicationInsights", "userAssignedIdentity", "virtualNetwork" ] diff --git a/src/backend/app.py b/src/backend/app.py index c79100501..550b1e1a3 100644 --- a/src/backend/app.py +++ b/src/backend/app.py @@ -15,6 +15,7 @@ from quart import Quart, request, jsonify, Response from quart_cors import cors +from opentelemetry import trace from settings import app_settings from models import CreativeBrief, Product @@ -24,6 +25,9 @@ from services.title_service import get_title_service from services.routing_service import get_routing_service, Intent, ConversationState from api.admin import admin_bp +from azure.monitor.opentelemetry import configure_azure_monitor +from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware +from event_utils import track_event_if_configured # In-memory task storage for generation tasks # In production, this should be replaced with Redis or similar @@ -31,22 +35,91 @@ _active_regenerations: Dict[str, Dict[str, Any]] = {} -# Configure logging +logging_settings = app_settings.logging +# Configure logging based on environment variables logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" + level=logging_settings.get_basic_log_level(), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + force=True ) -logging.getLogger("azure.core.pipeline.policies.http_logging_policy").setLevel(logging.WARNING) +azure_log_level = logging_settings.get_package_log_level() +for logger_name in logging_settings.logging_packages or []: + logging.getLogger(logger_name).setLevel(azure_log_level) +logging.info( + f"Logging configured - Basic: {logging_settings.basic_logging_level}, " + f"Azure packages: {logging_settings.package_logging_level}, " + f"Packages: {logging_settings.logging_packages}" +) + logger = logging.getLogger(__name__) # Create Quart app app = Quart(__name__) app = cors(app, allow_origin="*") +# Check if the Application Insights connection string is set in the environment variables +appinsights_connection_string = os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING") +if appinsights_connection_string: + # Configure Application Insights if the connection string is found + configure_azure_monitor( + connection_string=appinsights_connection_string, + enable_live_metrics=False, + enable_performance_counters=False, + ) + # Suppress verbose Azure SDK INFO logs from App Insights + # WARNING/ERROR/CRITICAL from these loggers still come through + logging.getLogger("azure.core.pipeline.policies.http_logging_policy").setLevel(logging.WARNING) + logging.getLogger("azure.monitor.opentelemetry.exporter").setLevel(logging.WARNING) + logging.getLogger("azure.identity").setLevel(logging.WARNING) + logging.getLogger("azure.cosmos").setLevel(logging.WARNING) + logging.getLogger("api.admin").setLevel(logging.WARNING) + logging.getLogger("httpx").setLevel(logging.WARNING) + # Apply ASGI middleware for request tracing (Quart is not auto-instrumented by configure_azure_monitor) + # Exclude health probes, post-deploy admin calls, and polling endpoints from telemetry + app.asgi_app = OpenTelemetryMiddleware( + app.asgi_app, + exclude_spans=["receive", "send"], + excluded_urls="health,api/admin,api/generate/status", + ) + logger.info("Application Insights configured with the provided connection string") +else: + # Log a warning if the connection string is not found + logger.warning("No Application Insights connection string found. Skipping configuration") + # Register blueprints app.register_blueprint(admin_bp) +@app.before_request +async def set_conversation_context(): + """Attach conversation_id and user_id to the current OTel span for App Insights.""" + conversation_id = "" + user_id = "" + + # 1. Extract from JSON body (POST requests) + if request.content_type and "json" in request.content_type: + data = await request.get_json(silent=True) + if data and isinstance(data, dict): + conversation_id = data.get("conversation_id", "") + user_id = data.get("user_id", "") + + # 2. Extract from URL path parameters (e.g. /api/conversations/) + if not conversation_id and request.view_args: + conversation_id = request.view_args.get("conversation_id", "") + + # 3. Extract from query parameters (e.g. ?conversation_id=xxx) + if not conversation_id: + conversation_id = request.args.get("conversation_id", "") + + if not user_id: + user_id = request.args.get("user_id", "") or request.headers.get("X-Ms-Client-Principal-Id", "anonymous") + + span = trace.get_current_span() + if span.is_recording(): + span.set_attribute("conversation_id", conversation_id) + span.set_attribute("user_id", user_id) + + # ==================== Authentication Helper ==================== def get_authenticated_user(): @@ -98,6 +171,8 @@ async def handle_chat(): selected_products = data.get("selected_products", []) brief_data = data.get("brief", {}) + track_event_if_configured("Chat_Request_Received", {"conversation_id": conversation_id, "user_id": user_id}) + # Get services routing_service = get_routing_service() orchestrator = get_orchestrator() @@ -111,7 +186,7 @@ async def handle_chat(): if conversation: state = routing_service.derive_state_from_conversation(conversation) except Exception as e: - logger.warning(f"Failed to get conversation state: {e}") + logger.exception(f"Failed to get conversation state: {e}") has_generated_content_flag = data.get("has_generated_content", False) if has_generated_content_flag: @@ -212,6 +287,7 @@ async def handle_chat(): except Exception as e: logger.exception(f"Error handling message: {e}") + track_event_if_configured("Error_Chat_Handler", {"conversation_id": conversation_id, "user_id": user_id, "error": str(e)}) return jsonify({ "action_type": "error", "message": f"An error occurred: {str(e)}", @@ -230,6 +306,8 @@ async def _handle_parse_brief( ) -> Response: """Handle parsing a new brief from user message.""" + track_event_if_configured("Brief_Parse_Request", {"conversation_id": conversation_id, "user_id": user_id}) + generated_title = None # Save user message @@ -255,12 +333,13 @@ async def _handle_parse_brief( generated_title=generated_title ) except Exception as e: - logger.warning(f"Failed to save message to CosmosDB: {e}") + logger.exception(f"Failed to save message to CosmosDB: {e}") # Parse the brief brief, questions, blocked = await orchestrator.parse_brief(message) if blocked: + track_event_if_configured("Error_RAI_Check_Failed", {"conversation_id": conversation_id, "user_id": user_id, "status": "Brief parse blocked by RAI"}) # Content was blocked by RAI - save refusal as assistant response try: cosmos_service = await get_cosmos_service() @@ -275,7 +354,7 @@ async def _handle_parse_brief( } ) except Exception as e: - logger.warning(f"Failed to save RAI response to CosmosDB: {e}") + logger.exception(f"Failed to save RAI response to CosmosDB: {e}") return jsonify({ "action_type": "rai_blocked", @@ -302,7 +381,7 @@ async def _handle_parse_brief( } ) except Exception as e: - logger.warning(f"Failed to save clarification to CosmosDB: {e}") + logger.exception(f"Failed to save clarification to CosmosDB: {e}") # Save partial brief to conversation so it can be confirmed later try: @@ -314,7 +393,7 @@ async def _handle_parse_brief( brief=brief ) except Exception as e: - logger.warning(f"Failed to save partial brief: {e}") + logger.exception(f"Failed to save partial brief: {e}") return jsonify({ "action_type": "clarification_needed", @@ -348,7 +427,7 @@ async def _handle_parse_brief( } ) except Exception as e: - logger.warning(f"Failed to save brief to CosmosDB: {e}") + logger.exception(f"Failed to save brief to CosmosDB: {e}") return jsonify({ "action_type": "brief_parsed", @@ -371,8 +450,11 @@ async def _handle_confirm_brief( try: brief = CreativeBrief(**brief_data) except Exception as e: + track_event_if_configured("Error_Brief_Invalid_Format", {"conversation_id": conversation_id, "user_id": user_id, "error": str(e)}) return jsonify({"error": f"Invalid brief format: {str(e)}"}), 400 + track_event_if_configured("Brief_Confirmed", {"conversation_id": conversation_id, "user_id": user_id}) + try: cosmos_service = await get_cosmos_service() @@ -396,7 +478,7 @@ async def _handle_confirm_brief( metadata={"status": "brief_confirmed", "brief_confirmed": True} ) except Exception as e: - logger.warning(f"Failed to save confirmed brief: {e}") + logger.exception(f"Failed to save confirmed brief: {e}") return jsonify({ "action_type": "brief_confirmed", @@ -417,6 +499,8 @@ async def _handle_refine_brief( ) -> Response: """Handle brief refinement based on user feedback.""" + track_event_if_configured("Brief_Refine_Request", {"conversation_id": conversation_id, "user_id": user_id}) + # Get existing brief if available existing_brief = conversation.get("brief") if conversation else None @@ -433,12 +517,13 @@ async def _handle_refine_brief( } ) except Exception as e: - logger.warning(f"Failed to save refinement message: {e}") + logger.exception(f"Failed to save refinement message: {e}") # Use orchestrator to refine the brief brief, questions, blocked = await orchestrator.parse_brief(message) if blocked: + track_event_if_configured("Error_RAI_Check_Failed", {"conversation_id": conversation_id, "user_id": user_id, "status": "Brief refinement blocked by RAI"}) return jsonify({ "action_type": "rai_blocked", "message": questions, @@ -462,7 +547,7 @@ async def _handle_refine_brief( } ) except Exception as e: - logger.warning(f"Failed to save clarification: {e}") + logger.exception(f"Failed to save clarification: {e}") # Merge partial brief with existing brief for confirmation option merged_brief = brief.model_dump() if brief else {} @@ -483,7 +568,7 @@ async def _handle_refine_brief( brief=CreativeBrief(**merged_brief) if merged_brief else None ) except Exception as e: - logger.warning(f"Failed to save merged brief: {e}") + logger.exception(f"Failed to save merged brief: {e}") return jsonify({ "action_type": "clarification_needed", @@ -526,7 +611,7 @@ async def _handle_refine_brief( } ) except Exception as e: - logger.warning(f"Failed to save refined brief: {e}") + logger.exception(f"Failed to save refined brief: {e}") return jsonify({ "action_type": "brief_parsed", @@ -547,6 +632,8 @@ async def _handle_search_products( ) -> Response: """Handle product search/selection via natural language.""" + track_event_if_configured("Product_Selection_Request", {"conversation_id": conversation_id, "user_id": user_id}) + # Save user message try: cosmos_service = await get_cosmos_service() @@ -560,7 +647,7 @@ async def _handle_search_products( } ) except Exception as e: - logger.warning(f"Failed to save search message: {e}") + logger.exception(f"Failed to save search message: {e}") # Get available products from catalog try: @@ -576,7 +663,7 @@ async def _handle_search_products( filename = original_url.split("/")[-1] if "/" in original_url else original_url p["image_url"] = f"/api/product-images/{filename}" except Exception as e: - logger.warning(f"Failed to get products from CosmosDB: {e}") + logger.exception(f"Failed to get products from CosmosDB: {e}") available_products = [] # Use orchestrator to process the selection request @@ -600,7 +687,7 @@ async def _handle_search_products( } ) except Exception as e: - logger.warning(f"Failed to save search response: {e}") + logger.exception(f"Failed to save search response: {e}") return jsonify({ "action_type": "products_found", @@ -626,6 +713,7 @@ async def _handle_generate_content( try: brief = CreativeBrief(**brief_data) except Exception as e: + track_event_if_configured("Error_Generation_Invalid_Brief", {"conversation_id": conversation_id, "user_id": user_id, "error": str(e)}) return jsonify({ "action_type": "error", "message": f"Invalid brief format: {str(e)}", @@ -645,6 +733,8 @@ async def _handle_generate_content( "error": None } + track_event_if_configured("Generation_Started", {"task_id": task_id, "conversation_id": conversation_id, "user_id": user_id, "generate_images": str(generate_images)}) + # Save user request try: cosmos_service = await get_cosmos_service() @@ -659,7 +749,7 @@ async def _handle_generate_content( } ) except Exception as e: - logger.warning(f"Failed to save generation request: {e}") + logger.exception(f"Failed to save generation request: {e}") # Start background task asyncio.create_task(_run_generation_task( @@ -694,6 +784,9 @@ async def _handle_modify_image( selected_products: list = None ) -> Response: """Handle image modification requests.""" + + track_event_if_configured("Regeneration_Request", {"conversation_id": conversation_id, "user_id": user_id}) + # Get products from frontend (frontend handles product detection) # This matches the original implementation where frontend detected product changes frontend_products = selected_products or [] @@ -708,7 +801,7 @@ async def _handle_modify_image( conversation = fresh_conversation logger.info(f"Fetched fresh conversation data for {conversation_id}") except Exception as e: - logger.warning(f"Failed to fetch fresh conversation, using stale data: {e}") + logger.exception(f"Failed to fetch fresh conversation, using stale data: {e}") # Save user message try: @@ -723,12 +816,13 @@ async def _handle_modify_image( } ) except Exception as e: - logger.warning(f"Failed to save image modification request: {e}") + logger.exception(f"Failed to save image modification request: {e}") # Get existing generated content generated_content = conversation.get("generated_content") if conversation else None if not generated_content: + track_event_if_configured("Error_Regeneration_No_Content", {"conversation_id": conversation_id, "user_id": user_id}) return jsonify({ "action_type": "error", "message": "No generated content found. Please generate content first.", @@ -756,6 +850,7 @@ async def _handle_modify_image( brief = None if not brief: + track_event_if_configured("Error_Regeneration_No_Brief", {"conversation_id": conversation_id, "user_id": user_id}) return jsonify({ "action_type": "error", "message": "No brief found. Please create and confirm a brief first.", @@ -836,6 +931,7 @@ async def _run_regeneration_task( # Check for RAI block if response.get("rai_blocked"): + track_event_if_configured("Error_RAI_Check_Failed", {"conversation_id": conversation_id, "user_id": user_id, "status": "Regeneration blocked by RAI"}) _generation_tasks[task_id]["status"] = "failed" _generation_tasks[task_id]["error"] = response.get("error", "Request blocked by content safety") return @@ -862,7 +958,7 @@ async def _run_regeneration_task( response["image_url"] = f"/api/images/{conversation_id}/{filename}" del response["image_base64"] except Exception as e: - logger.warning(f"Failed to save regenerated image to blob: {e}") + logger.exception(f"Failed to save regenerated image to blob: {e}") # Save assistant response existing_content = {} @@ -945,7 +1041,7 @@ async def _run_regeneration_task( ) logger.info(f"Updated brief visual_guidelines with modification: {modification_request}") except Exception as e: - logger.warning(f"Failed to save regeneration response to CosmosDB: {e}") + logger.exception(f"Failed to save regeneration response to CosmosDB: {e}") # Store result (use updated text_content if we replaced product name) _generation_tasks[task_id]["status"] = "completed" @@ -958,6 +1054,7 @@ async def _run_regeneration_task( "selected_products": products_data, "updated_brief": updated_brief_dict, # Include updated brief for frontend } + track_event_if_configured("Regeneration_Completed", {"task_id": task_id, "conversation_id": conversation_id, "user_id": user_id}) # Clear active regeneration marker (only if it's still our task) active_info = _active_regenerations.get(conversation_id, {}) @@ -969,6 +1066,7 @@ async def _run_regeneration_task( logger.exception(f"Error in regeneration task {task_id}: {e}") _generation_tasks[task_id]["status"] = "failed" _generation_tasks[task_id]["error"] = str(e) + track_event_if_configured("Error_Regeneration_Failed", {"task_id": task_id, "conversation_id": conversation_id, "user_id": user_id, "error": str(e)}) # Clear active regeneration marker on error too active_info = _active_regenerations.get(conversation_id, {}) if active_info.get("task_id") == task_id: @@ -981,6 +1079,8 @@ async def _handle_start_over( ) -> Response: """Handle start over request - clears the current session.""" + track_event_if_configured("Session_Reset", {"conversation_id": conversation_id, "user_id": user_id}) + # For start over, we create a new conversation new_conversation_id = str(uuid.uuid4()) @@ -1002,6 +1102,8 @@ async def _handle_general_chat( ) -> Response: """Handle general chat messages.""" + track_event_if_configured("General_Chat_Request", {"conversation_id": conversation_id, "user_id": user_id}) + # Save user message try: cosmos_service = await get_cosmos_service() @@ -1026,7 +1128,7 @@ async def _handle_general_chat( generated_title=generated_title ) except Exception as e: - logger.warning(f"Failed to save message: {e}") + logger.exception(f"Failed to save message: {e}") # For non-streaming response, collect orchestrator output response_content = "" @@ -1054,7 +1156,7 @@ async def _handle_general_chat( } ) except Exception as e: - logger.warning(f"Failed to save response: {e}") + logger.exception(f"Failed to save response: {e}") return jsonify({ "action_type": "chat_response", @@ -1108,7 +1210,7 @@ async def _run_generation_task(task_id: str, brief: CreativeBrief, products_data response["image_blob_url"] = blob_url # Include the original blob URL del response["image_base64"] except Exception as e: - logger.warning(f"Failed to save image to blob: {e}") + logger.exception(f"Failed to save image to blob: {e}") # Save to CosmosDB try: @@ -1140,11 +1242,12 @@ async def _run_generation_task(task_id: str, brief: CreativeBrief, products_data generated_content=generated_content_to_save ) except Exception as e: - logger.warning(f"Failed to save generated content to CosmosDB: {e}") + logger.exception(f"Failed to save generated content to CosmosDB: {e}") _generation_tasks[task_id]["status"] = "completed" _generation_tasks[task_id]["result"] = response _generation_tasks[task_id]["completed_at"] = datetime.now(timezone.utc).isoformat() + track_event_if_configured("Generation_Completed", {"task_id": task_id, "conversation_id": conversation_id, "user_id": user_id}) logger.info(f"Task {task_id} marked as completed") except Exception as e: @@ -1152,6 +1255,7 @@ async def _run_generation_task(task_id: str, brief: CreativeBrief, products_data _generation_tasks[task_id]["status"] = "failed" _generation_tasks[task_id]["error"] = str(e) _generation_tasks[task_id]["completed_at"] = datetime.now(timezone.utc).isoformat() + track_event_if_configured("Error_Generation_Failed", {"task_id": task_id, "conversation_id": conversation_id, "user_id": user_id, "error": str(e)}) @app.route("/api/generate/start", methods=["POST"]) @@ -1187,6 +1291,7 @@ async def start_generation(): try: brief = CreativeBrief(**brief_data) except Exception as e: + track_event_if_configured("Error_Generation_Invalid_Brief", {"conversation_id": conversation_id, "user_id": user_id, "error": str(e)}) return jsonify({"error": f"Invalid brief format: {str(e)}"}), 400 # Create task ID @@ -1215,7 +1320,7 @@ async def start_generation(): } ) except Exception as e: - logger.warning(f"Failed to save generation request to CosmosDB: {e}") + logger.exception(f"Failed to save generation request to CosmosDB: {e}") # Start background task asyncio.create_task(_run_generation_task( @@ -1229,6 +1334,8 @@ async def start_generation(): logger.info(f"Started generation task {task_id} for conversation {conversation_id}") + track_event_if_configured("Generation_Started", {"task_id": task_id, "conversation_id": conversation_id, "user_id": user_id, "generate_images": str(generate_images)}) + return jsonify({ "task_id": task_id, "status": "pending", @@ -1555,9 +1662,10 @@ async def delete_conversation(conversation_id: str): try: cosmos_service = await get_cosmos_service() await cosmos_service.delete_conversation(conversation_id, user_id) + track_event_if_configured("Conversation_Deleted", {"conversation_id": conversation_id, "user_id": user_id}) return jsonify({"success": True, "message": "Conversation deleted"}) except Exception as e: - logger.warning(f"Failed to delete conversation: {e}") + logger.exception(f"Failed to delete conversation: {e}") return jsonify({"error": "Failed to delete conversation"}), 500 @@ -1586,10 +1694,11 @@ async def update_conversation(conversation_id: str): cosmos_service = await get_cosmos_service() result = await cosmos_service.rename_conversation(conversation_id, user_id, new_title) if result: + track_event_if_configured("Conversation_Renamed", {"conversation_id": conversation_id, "user_id": user_id}) return jsonify({"success": True, "message": "Conversation renamed", "title": new_title}) return jsonify({"error": "Conversation not found"}), 404 except Exception as e: - logger.warning(f"Failed to rename conversation: {e}") + logger.exception(f"Failed to rename conversation: {e}") return jsonify({"error": "Failed to rename conversation"}), 500 @@ -1606,13 +1715,14 @@ async def delete_all_conversations(): try: cosmos_service = await get_cosmos_service() deleted_count = await cosmos_service.delete_all_conversations(user_id) + track_event_if_configured("Conversations_All_Deleted", {"user_id": user_id, "deleted_count": str(deleted_count)}) return jsonify({ "success": True, "message": f"Deleted {deleted_count} conversations", "deleted_count": deleted_count }) except Exception as e: - logger.warning(f"Failed to delete all conversations: {e}") + logger.exception(f"Failed to delete all conversations: {e}") return jsonify({"error": "Failed to delete conversations"}), 500 @@ -1665,13 +1775,13 @@ async def startup(): await get_cosmos_service() logger.info("CosmosDB service initialized") except Exception as e: - logger.warning(f"CosmosDB service initialization failed (may be firewall): {e}") + logger.exception(f"CosmosDB service initialization failed (may be firewall): {e}") try: await get_blob_service() logger.info("Blob storage service initialized") except Exception as e: - logger.warning(f"Blob storage service initialization failed: {e}") + logger.exception(f"Blob storage service initialization failed: {e}") logger.info("Application startup complete") diff --git a/src/backend/event_utils.py b/src/backend/event_utils.py new file mode 100644 index 000000000..6bd09fab0 --- /dev/null +++ b/src/backend/event_utils.py @@ -0,0 +1,11 @@ +import logging +import os +from azure.monitor.events.extension import track_event + + +def track_event_if_configured(event_name: str, event_data: dict): + connection_string = os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING") + if connection_string: + track_event(event_name, event_data) + else: + logging.warning(f"Skipping track_event for {event_name} as Application Insights is not configured") diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index e1471c19e..fda72578f 100644 --- a/src/backend/requirements.txt +++ b/src/backend/requirements.txt @@ -26,6 +26,11 @@ openai>=1.45.0 # HTTP Client (for Foundry direct API calls) httpx>=0.27.0 +# Monitoring / Telemetry +azure-monitor-opentelemetry>=1.6.0 +azure-monitor-events-extension>=0.1.0 +opentelemetry-instrumentation-asgi>=0.48b0 + # Data Validation pydantic>=2.8.0 pydantic-settings>=2.4.0 diff --git a/src/backend/settings.py b/src/backend/settings.py index c28a72e70..e5e4a81ae 100644 --- a/src/backend/settings.py +++ b/src/backend/settings.py @@ -6,10 +6,11 @@ and compliance validation. """ +import logging import os -from typing import List, Optional +from typing import List, Literal, Optional -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from typing_extensions import Self @@ -25,6 +26,32 @@ def parse_comma_separated(value: str) -> List[str]: return [] +class _LoggingSettings(BaseSettings): + """Logging configuration settings.""" + model_config = SettingsConfigDict( + env_prefix="AZURE_", env_file=DOTENV_PATH, extra="ignore", env_ignore_empty=True + ) + + basic_logging_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO" + package_logging_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "WARNING" + logging_packages: Optional[List[str]] = [] + + @field_validator("logging_packages", mode="before") + @classmethod + def split_logging_packages(cls, packages) -> Optional[List[str]]: + if isinstance(packages, str) and len(packages.strip()) > 0: + return [pkg.strip() for pkg in packages.split(",") if pkg.strip()] + return None + + def get_basic_log_level(self) -> int: + """Convert string log level to logging constant.""" + return getattr(logging, self.basic_logging_level.upper()) + + def get_package_log_level(self) -> int: + """Convert string package log level to logging constant.""" + return getattr(logging, self.package_logging_level.upper()) + + class _UiSettings(BaseSettings): """UI configuration settings.""" model_config = SettingsConfigDict( @@ -436,6 +463,7 @@ class _AppSettings(BaseModel): azure_openai: _AzureOpenAISettings = _AzureOpenAISettings() ai_foundry: _AIFoundrySettings = _AIFoundrySettings() brand_guidelines: _BrandGuidelinesSettings = _BrandGuidelinesSettings() + logging: _LoggingSettings = _LoggingSettings() ui: Optional[_UiSettings] = _UiSettings() # Constructed properties