Skip to content

Commit ebe3d4e

Browse files
feat: improve application insights logging and telemetry handling (#2140)
1 parent 51b0c77 commit ebe3d4e

10 files changed

Lines changed: 208 additions & 2 deletions

File tree

code/app.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
azure_package_log_level = getattr(
2525
logging, PACKAGE_LOGGING_LEVEL.upper(), logging.WARNING
2626
)
27+
2728
for logger_name in AZURE_LOGGING_PACKAGES:
2829
logging.getLogger(logger_name).setLevel(azure_package_log_level)
2930

@@ -33,6 +34,24 @@
3334
configure_azure_monitor()
3435
HTTPXClientInstrumentor().instrument() # httpx is used by openai
3536

37+
# Register ConversationSpanProcessor to propagate conversation_id/user_id to all child spans
38+
from opentelemetry import trace as otel_trace
39+
from create_app import ConversationSpanProcessor
40+
41+
provider = otel_trace.get_tracer_provider()
42+
if hasattr(provider, "add_span_processor"):
43+
provider.add_span_processor(ConversationSpanProcessor())
44+
45+
# Suppress noisy Azure SDK loggers AFTER configure_azure_monitor()
46+
# to prevent it from overriding our levels
47+
_NOISY_AZURE_LOGGERS = [
48+
"azure.core.pipeline.policies.http_logging_policy",
49+
"azure.monitor.opentelemetry.exporter",
50+
"azure.identity",
51+
]
52+
for logger_name in _NOISY_AZURE_LOGGERS:
53+
logging.getLogger(logger_name).setLevel(logging.WARNING)
54+
3655
# pylint: disable=wrong-import-position
3756
from create_app import create_app # noqa: E402
3857

code/backend/Admin.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
azure_package_log_level = getattr(
2828
logging, PACKAGE_LOGGING_LEVEL.upper(), logging.WARNING
2929
)
30+
3031
for logger_name in AZURE_LOGGING_PACKAGES:
3132
logging.getLogger(logger_name).setLevel(azure_package_log_level)
3233

@@ -35,6 +36,16 @@
3536
if os.getenv("APPLICATIONINSIGHTS_ENABLED", "false").lower() == "true":
3637
configure_azure_monitor()
3738

39+
# Suppress noisy Azure SDK loggers AFTER configure_azure_monitor()
40+
# to prevent it from overriding our levels
41+
_NOISY_AZURE_LOGGERS = [
42+
"azure.core.pipeline.policies.http_logging_policy",
43+
"azure.monitor.opentelemetry.exporter",
44+
"azure.identity",
45+
]
46+
for logger_name in _NOISY_AZURE_LOGGERS:
47+
logging.getLogger(logger_name).setLevel(logging.WARNING)
48+
3849
logger = logging.getLogger(__name__)
3950
logger.debug("Starting admin app")
4051

code/backend/api/chat_history.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from backend.batch.utilities.helpers.config.config_helper import ConfigHelper
1111
from backend.batch.utilities.helpers.env_helper import EnvHelper
1212
from backend.batch.utilities.chat_history.database_factory import DatabaseFactory
13+
from backend.batch.utilities.loggers.event_utils import track_event_if_configured
1314

1415
load_dotenv()
1516
bp_chat_history_response = Blueprint("chat_history", __name__)
@@ -110,6 +111,11 @@ async def rename_conversation():
110111
if not title or title.strip() == "":
111112
return jsonify({"error": "A non-empty title is required"}), 400
112113

114+
track_event_if_configured("HistoryRenameRequested", {
115+
"conversation_id": conversation_id,
116+
"user_id": user_id,
117+
})
118+
113119
# Initialize and connect to the database client
114120
conversation_client = init_database_client()
115121
if not conversation_client:
@@ -167,6 +173,11 @@ async def get_conversation():
167173
if not conversation_id:
168174
return jsonify({"error": "conversation_id is required"}), 400
169175

176+
track_event_if_configured("HistoryReadRequested", {
177+
"conversation_id": conversation_id,
178+
"user_id": user_id,
179+
})
180+
170181
# Initialize and connect to the database client
171182
conversation_client = init_database_client()
172183
if not conversation_client:
@@ -246,6 +257,11 @@ async def delete_conversation():
246257
400,
247258
)
248259

260+
track_event_if_configured("HistoryDeleteRequested", {
261+
"conversation_id": conversation_id,
262+
"user_id": user_id,
263+
})
264+
249265
# Initialize and connect to the database client
250266
conversation_client = init_database_client()
251267
if not conversation_client:
@@ -369,6 +385,11 @@ async def update_conversation():
369385
if not conversation_id:
370386
return jsonify({"error": "conversation_id is required"}), 400
371387

388+
track_event_if_configured("HistoryUpdateRequested", {
389+
"conversation_id": conversation_id,
390+
"user_id": user_id,
391+
})
392+
372393
messages = request_json["messages"]
373394
if not messages or len(messages) == 0:
374395
return jsonify({"error": "Messages are required"}), 400

code/backend/batch/function_app.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,23 @@
2525
azure_package_log_level = getattr(
2626
logging, PACKAGE_LOGGING_LEVEL.upper(), logging.WARNING
2727
)
28+
2829
for logger_name in AZURE_LOGGING_PACKAGES:
2930
logging.getLogger(logger_name).setLevel(azure_package_log_level)
3031

3132
if os.getenv("APPLICATIONINSIGHTS_ENABLED", "false").lower() == "true":
3233
configure_azure_monitor()
3334

35+
# Suppress noisy Azure SDK loggers AFTER configure_azure_monitor()
36+
# to prevent it from overriding our levels
37+
_NOISY_AZURE_LOGGERS = [
38+
"azure.core.pipeline.policies.http_logging_policy",
39+
"azure.monitor.opentelemetry.exporter",
40+
"azure.identity",
41+
]
42+
for logger_name in _NOISY_AZURE_LOGGERS:
43+
logging.getLogger(logger_name).setLevel(logging.WARNING)
44+
3445
app = func.FunctionApp(
3546
http_auth_level=func.AuthLevel.FUNCTION
3647
) # change to ANONYMOUS for local debugging
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""
2+
Utility for tracking custom events to Application Insights.
3+
"""
4+
5+
import os
6+
import logging
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
def track_event_if_configured(event_name: str, event_data: dict):
12+
"""Track custom event to Application Insights if configured.
13+
14+
Args:
15+
event_name: Name of the event to track
16+
event_data: Dictionary of event properties
17+
"""
18+
if os.getenv("APPLICATIONINSIGHTS_ENABLED", "false").lower() == "true":
19+
try:
20+
from azure.monitor.events.extension import track_event
21+
22+
track_event(event_name, event_data)
23+
except ImportError:
24+
logger.warning(
25+
"azure-monitor-events-extension not installed. Skipping track_event for %s",
26+
event_name,
27+
)
28+
else:
29+
logger.debug(
30+
"Skipping track_event for %s: Application Insights is not enabled",
31+
event_name,
32+
)

code/create_app.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
This module creates a Flask app that serves the web interface for the chatbot.
33
"""
44

5+
import contextvars
56
import functools
67
import json
78
import logging
@@ -29,6 +30,26 @@
2930
from backend.batch.utilities.helpers.azure_blob_storage_client import (
3031
AzureBlobStorageClient,
3132
)
33+
from backend.batch.utilities.loggers.event_utils import track_event_if_configured
34+
from backend.batch.utilities.chat_history.auth_utils import get_authenticated_user_details
35+
from opentelemetry import trace
36+
from opentelemetry.sdk.trace import SpanProcessor
37+
38+
_conversation_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("conversation_id", default="")
39+
_user_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("user_id", default="")
40+
41+
42+
class ConversationSpanProcessor(SpanProcessor):
43+
"""Attaches conversation_id and user_id to every span created during a request."""
44+
45+
def on_start(self, span, parent_context=None):
46+
conversation_id = _conversation_id_var.get()
47+
user_id = _user_id_var.get()
48+
if conversation_id:
49+
span.set_attribute("conversation_id", conversation_id)
50+
if user_id:
51+
span.set_attribute("user_id", user_id)
52+
3253

3354
ERROR_429_MESSAGE = "We're currently experiencing a high number of requests for the service you're trying to access. Please wait a moment and try again."
3455
ERROR_GENERIC_MESSAGE = "An error occurred. Please try again. If the problem persists, please contact the site administrator."
@@ -413,6 +434,32 @@ def create_app():
413434

414435
logger.debug("Starting web app")
415436

437+
@app.before_request
438+
def set_span_attributes():
439+
"""Middleware to attach conversation_id and user_id to the current OpenTelemetry span and context vars."""
440+
if request.method == "POST" and request.is_json:
441+
try:
442+
body = request.get_json(silent=True) or {}
443+
conversation_id = body.get("conversation_id", "")
444+
authenticated_user = get_authenticated_user_details(request_headers=request.headers)
445+
user_id = authenticated_user.get("user_principal_id", "")
446+
_conversation_id_var.set(conversation_id)
447+
_user_id_var.set(user_id)
448+
span = trace.get_current_span()
449+
if span:
450+
if conversation_id:
451+
span.set_attribute("conversation_id", conversation_id)
452+
if user_id:
453+
span.set_attribute("user_id", user_id)
454+
except Exception:
455+
pass # Don't let telemetry middleware break requests
456+
457+
@app.teardown_request
458+
def clear_span_context(exc=None):
459+
"""Clear conversation context vars after each request."""
460+
_conversation_id_var.set("")
461+
_user_id_var.set("")
462+
416463
@app.route("/", defaults={"path": "index.html"})
417464
@app.route("/<path:path>")
418465
def static_file(path):
@@ -558,13 +605,28 @@ def get_file(filename):
558605
def conversation_azure_byod():
559606
logger.info("Method conversation_azure_byod started")
560607
try:
608+
authenticated_user = get_authenticated_user_details(request_headers=request.headers)
609+
user_id = authenticated_user.get("user_principal_id", "")
610+
conversation_id = request.json.get("conversation_id", "")
611+
612+
track_event_if_configured("ConversationBYODRequestReceived", {
613+
"conversation_id": conversation_id,
614+
"user_id": user_id,
615+
})
616+
561617
if should_use_data(env_helper, azure_search_helper):
562618
return conversation_with_data(request, env_helper)
563619
else:
564620
return conversation_without_data(request, env_helper)
565621
except APIStatusError as e:
566622
error_message = str(e)
567623
logger.exception("Exception in /api/conversation | %s", error_message)
624+
track_event_if_configured("ConversationBYODError", {
625+
"conversation_id": locals().get("conversation_id", ""),
626+
"user_id": locals().get("user_id", ""),
627+
"error": error_message,
628+
"error_type": type(e).__name__,
629+
})
568630
response_json = e.response.json()
569631
response_message = response_json.get("error", {}).get("message", "")
570632
response_code = response_json.get("error", {}).get("code", "")
@@ -574,6 +636,12 @@ def conversation_azure_byod():
574636
except Exception as e:
575637
error_message = str(e)
576638
logger.exception("Exception in /api/conversation | %s", error_message)
639+
track_event_if_configured("ConversationBYODError", {
640+
"conversation_id": locals().get("conversation_id", ""),
641+
"user_id": locals().get("user_id", ""),
642+
"error": error_message,
643+
"error_type": type(e).__name__,
644+
})
577645
return jsonify({"error": ERROR_GENERIC_MESSAGE}), 500
578646
finally:
579647
logger.info("Method conversation_azure_byod ended")
@@ -583,8 +651,16 @@ async def conversation_custom():
583651

584652
try:
585653
logger.info("Method conversation_custom started")
654+
authenticated_user = get_authenticated_user_details(request_headers=request.headers)
655+
user_id = authenticated_user.get("user_principal_id", "")
586656
user_message = request.json["messages"][-1]["content"]
587657
conversation_id = request.json["conversation_id"]
658+
659+
track_event_if_configured("ConversationCustomRequestReceived", {
660+
"conversation_id": conversation_id,
661+
"user_id": user_id,
662+
})
663+
588664
user_assistant_messages = list(
589665
filter(
590666
lambda x: x["role"] in ("user", "assistant"),
@@ -599,6 +675,11 @@ async def conversation_custom():
599675
orchestrator=get_orchestrator_config(),
600676
)
601677

678+
track_event_if_configured("ConversationCustomSuccess", {
679+
"conversation_id": conversation_id,
680+
"user_id": user_id,
681+
})
682+
602683
response_obj = {
603684
"id": "response.id",
604685
"model": env_helper.AZURE_OPENAI_MODEL,
@@ -612,6 +693,12 @@ async def conversation_custom():
612693
except APIStatusError as e:
613694
error_message = str(e)
614695
logger.exception("Exception in /api/conversation | %s", error_message)
696+
track_event_if_configured("ConversationCustomError", {
697+
"conversation_id": locals().get("conversation_id", ""),
698+
"user_id": locals().get("user_id", ""),
699+
"error": error_message,
700+
"error_type": type(e).__name__,
701+
})
615702
response_json = e.response.json()
616703
response_message = response_json.get("error", {}).get("message", "")
617704
response_code = response_json.get("error", {}).get("code", "")
@@ -621,6 +708,12 @@ async def conversation_custom():
621708
except Exception as e:
622709
error_message = str(e)
623710
logger.exception("Exception in /api/conversation | %s", error_message)
711+
track_event_if_configured("ConversationCustomError", {
712+
"conversation_id": locals().get("conversation_id", ""),
713+
"user_id": locals().get("user_id", ""),
714+
"error": error_message,
715+
"error_type": type(e).__name__,
716+
})
624717
return jsonify({"error": ERROR_GENERIC_MESSAGE}), 500
625718
finally:
626719
logger.info("Method conversation_custom ended")

infra/main.bicep

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1269,6 +1269,7 @@ module web 'modules/app/web.bicep' = {
12691269
AZURE_CLIENT_ID: managedIdentityModule.outputs.clientId // Required so LangChain AzureSearch vector store authenticates with this user-assigned managed identity
12701270
APP_ENV: appEnvironment
12711271
AZURE_SEARCH_DIMENSIONS: azureSearchDimensions
1272+
APPLICATIONINSIGHTS_ENABLED: enableMonitoring ? 'true' : 'false'
12721273
},
12731274
databaseType == 'CosmosDB'
12741275
? {
@@ -1368,6 +1369,7 @@ module adminweb 'modules/app/adminweb.bicep' = {
13681369
MANAGED_IDENTITY_RESOURCE_ID: managedIdentityModule.outputs.resourceId
13691370
APP_ENV: appEnvironment
13701371
AZURE_SEARCH_DIMENSIONS: azureSearchDimensions
1372+
APPLICATIONINSIGHTS_ENABLED: enableMonitoring ? 'true' : 'false'
13711373
},
13721374
databaseType == 'CosmosDB'
13731375
? {
@@ -1469,6 +1471,7 @@ module function 'modules/app/function.bicep' = {
14691471
APP_ENV: appEnvironment
14701472
BACKEND_URL: backendUrl
14711473
AZURE_SEARCH_DIMENSIONS: azureSearchDimensions
1474+
APPLICATIONINSIGHTS_ENABLED: enableMonitoring ? 'true' : 'false'
14721475
},
14731476
databaseType == 'CosmosDB'
14741477
? {

infra/modules/core/monitor/monitoring.bicep

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,6 @@ module avmAppInsights 'br/public:avm/res/insights/component:0.6.0' = {
131131
disableIpMasking: false
132132
flowType: 'Bluefield'
133133
workspaceResourceId: empty(workspaceResourceId) ? '' : workspaceResourceId
134-
diagnosticSettings: empty(workspaceResourceId) ? null : [{ workspaceResourceId: workspaceResourceId }]
135134
}
136135
}
137136

poetry.lock

Lines changed: 17 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)