Skip to content

Commit d37e062

Browse files
authored
Merge pull request Azure-Samples#10 from pamelafox/appinsights
Add app insights and Logfire instrumentation
2 parents 617952c + 37bcbd3 commit d37e062

File tree

9 files changed

+214
-8
lines changed

9 files changed

+214
-8
lines changed

infra/aca.bicep

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ param openAiEndpoint string
1212
param cosmosDbAccount string
1313
param cosmosDbDatabase string
1414
param cosmosDbContainer string
15+
param applicationInsightsConnectionString string = ''
1516

1617
resource acaIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
1718
name: identityName
@@ -58,6 +59,11 @@ module app 'core/host/container-app-upsert.bicep' = {
5859
name: 'AZURE_COSMOSDB_CONTAINER'
5960
value: cosmosDbContainer
6061
}
62+
// We typically store sensitive values in secrets, but App Insights connection strings are not considered highly sensitive
63+
{
64+
name: 'APPLICATIONINSIGHTS_CONNECTION_STRING'
65+
value: applicationInsightsConnectionString
66+
}
6167
]
6268
targetPort: 8000
6369
}

infra/main.bicep

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,20 @@ module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0
155155
}
156156
}
157157

158+
// Application Insights for telemetry
159+
module applicationInsights 'br/public:avm/res/insights/component:0.4.2' = if (useMonitoring) {
160+
name: 'applicationinsights'
161+
scope: resourceGroup
162+
params: {
163+
name: '${prefix}-appinsights'
164+
location: location
165+
tags: tags
166+
workspaceResourceId: logAnalyticsWorkspace.?outputs.resourceId
167+
kind: 'web'
168+
applicationType: 'web'
169+
}
170+
}
171+
158172
// https://learn.microsoft.com/en-us/azure/container-apps/firewall-integration?tabs=consumption-only
159173
module containerAppsNSG 'br/public:avm/res/network/network-security-group:0.5.1' = if (useVnet) {
160174
name: 'containerAppsNSG'
@@ -669,6 +683,7 @@ module aca 'aca.bicep' = {
669683
cosmosDbAccount: cosmosDb.outputs.name
670684
cosmosDbDatabase: cosmosDbDatabaseName
671685
cosmosDbContainer: cosmosDbContainerName
686+
applicationInsightsConnectionString: useMonitoring ? applicationInsights.outputs.connectionString : ''
672687
exists: acaExists
673688
}
674689
}
@@ -738,3 +753,6 @@ output AZURE_COSMOSDB_ACCOUNT string = cosmosDb.outputs.name
738753
output AZURE_COSMOSDB_ENDPOINT string = cosmosDb.outputs.endpoint
739754
output AZURE_COSMOSDB_DATABASE string = cosmosDbDatabaseName
740755
output AZURE_COSMOSDB_CONTAINER string = cosmosDbContainerName
756+
757+
// We typically do not output sensitive values, but App Insights connection strings are not considered highly sensitive
758+
output APPLICATIONINSIGHTS_CONNECTION_STRING string = useMonitoring ? applicationInsights.outputs.connectionString : ''

infra/write_env.ps1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ Add-Content -Path $ENV_FILE_PATH -Value "AZURE_TENANT_ID=$(azd env get-value AZU
1111
Add-Content -Path $ENV_FILE_PATH -Value "AZURE_COSMOSDB_ACCOUNT=$(azd env get-value AZURE_COSMOSDB_ACCOUNT)"
1212
Add-Content -Path $ENV_FILE_PATH -Value "AZURE_COSMOSDB_DATABASE=$(azd env get-value AZURE_COSMOSDB_DATABASE)"
1313
Add-Content -Path $ENV_FILE_PATH -Value "AZURE_COSMOSDB_CONTAINER=$(azd env get-value AZURE_COSMOSDB_CONTAINER)"
14+
Add-Content -Path $ENV_FILE_PATH -Value "APPLICATIONINSIGHTS_CONNECTION_STRING=$(azd env get-value APPLICATIONINSIGHTS_CONNECTION_STRING)"
1415
Add-Content -Path $ENV_FILE_PATH -Value "API_HOST=azure"

infra/write_env.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ echo "AZURE_TENANT_ID=$(azd env get-value AZURE_TENANT_ID)" >> "$ENV_FILE_PATH"
1515
echo "AZURE_COSMOSDB_ACCOUNT=$(azd env get-value AZURE_COSMOSDB_ACCOUNT)" >> "$ENV_FILE_PATH"
1616
echo "AZURE_COSMOSDB_DATABASE=$(azd env get-value AZURE_COSMOSDB_DATABASE)" >> "$ENV_FILE_PATH"
1717
echo "AZURE_COSMOSDB_CONTAINER=$(azd env get-value AZURE_COSMOSDB_CONTAINER)" >> "$ENV_FILE_PATH"
18-
echo "API_HOST=azure" >> "$ENV_FILE_PATH"
18+
echo "APPLICATIONINSIGHTS_CONNECTION_STRING=$(azd env get-value APPLICATIONINSIGHTS_CONNECTION_STRING)" >> "$ENV_FILE_PATH"
19+
echo "API_HOST=azure" >> "$ENV_FILE_PATH"

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ dependencies = [
1616
"azure-ai-agents>=1.1.0",
1717
"agent-framework>=1.0.0b251016",
1818
"azure-cosmos>=4.9.0",
19+
"azure-monitor-opentelemetry>=1.6.4",
20+
"opentelemetry-instrumentation-starlette>=0.49b0",
21+
"logfire>=4.15.1",
22+
"azure-core-tracing-opentelemetry>=1.0.0b12"
1923
]
2024

2125
[dependency-groups]

servers/__init__.py

Whitespace-only changes.

servers/deployed_mcp.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,49 @@
1+
"""Run with: cd servers && uvicorn deployed_mcp:app --host 0.0.0.0 --port 8000"""
2+
13
import logging
24
import os
35
import uuid
46
from datetime import date
57
from enum import Enum
68
from typing import Annotated
79

10+
import logfire
11+
from azure.core.settings import settings
812
from azure.cosmos.aio import CosmosClient
913
from azure.identity.aio import DefaultAzureCredential, ManagedIdentityCredential
14+
from azure.monitor.opentelemetry import configure_azure_monitor
1015
from dotenv import load_dotenv
1116
from fastmcp import FastMCP
17+
from opentelemetry.instrumentation.starlette import StarletteInstrumentor
18+
19+
try:
20+
from opentelemetry_middleware import OpenTelemetryMiddleware
21+
except ImportError:
22+
from servers.opentelemetry_middleware import OpenTelemetryMiddleware
23+
24+
RUNNING_IN_PRODUCTION = os.getenv("RUNNING_IN_PRODUCTION", "false").lower() == "true"
1225

13-
load_dotenv(override=True)
26+
if not RUNNING_IN_PRODUCTION:
27+
load_dotenv(override=True)
1428

15-
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(message)s")
29+
logging.basicConfig(level=logging.WARNING, format="%(asctime)s - %(message)s")
1630
logger = logging.getLogger("ExpensesMCP")
31+
logger.setLevel(logging.INFO)
32+
33+
# Configure OpenTelemetry tracing, either via Azure Monitor or Logfire
34+
# We don't support both at the same time due to potential conflicts with tracer providers
35+
if os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING"):
36+
logger.info("Setting up Azure Monitor instrumentation")
37+
configure_azure_monitor()
38+
elif os.getenv("LOGFIRE_PROJECT_NAME"):
39+
logger.info("Setting up Logfire instrumentation")
40+
settings.tracing_implementation = "opentelemetry" # Configure Azure SDK to use OpenTelemetry tracing
41+
logfire.configure(service_name="expenses-mcp", send_to_logfire=True)
1742

1843
# Cosmos DB configuration from environment variables
1944
AZURE_COSMOSDB_ACCOUNT = os.environ["AZURE_COSMOSDB_ACCOUNT"]
2045
AZURE_COSMOSDB_DATABASE = os.environ["AZURE_COSMOSDB_DATABASE"]
2146
AZURE_COSMOSDB_CONTAINER = os.environ["AZURE_COSMOSDB_CONTAINER"]
22-
RUNNING_IN_PRODUCTION = os.getenv("RUNNING_IN_PRODUCTION", "false").lower() == "true"
2347
AZURE_CLIENT_ID = os.getenv("AZURE_CLIENT_ID", "")
2448

2549
# Configure Cosmos DB client and container
@@ -37,6 +61,7 @@
3761
logger.info(f"Connected to Cosmos DB: {AZURE_COSMOSDB_ACCOUNT}")
3862

3963
mcp = FastMCP("Expenses Tracker")
64+
mcp.add_middleware(OpenTelemetryMiddleware("ExpensesMCP"))
4065

4166

4267
class PaymentMethod(Enum):
@@ -152,7 +177,4 @@ def analyze_spending_prompt(
152177

153178
# ASGI application for uvicorn
154179
app = mcp.http_app()
155-
156-
if __name__ == "__main__":
157-
logger.info("MCP Expenses server starting (HTTP mode on port 8000)")
158-
mcp.run(transport="http", host="0.0.0.0", port=8000)
180+
StarletteInstrumentor.instrument_app(app)
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from fastmcp.server.middleware import Middleware, MiddlewareContext
2+
from opentelemetry import trace
3+
from opentelemetry.trace import Status, StatusCode
4+
5+
6+
class OpenTelemetryMiddleware(Middleware):
7+
"""Middleware that creates OpenTelemetry spans for MCP operations."""
8+
9+
def __init__(self, tracer_name: str):
10+
self.tracer = trace.get_tracer(tracer_name)
11+
12+
async def on_call_tool(self, context: MiddlewareContext, call_next):
13+
"""Create a span for each tool call with detailed attributes."""
14+
tool_name = context.message.name
15+
16+
with self.tracer.start_as_current_span(
17+
f"tool.{tool_name}",
18+
attributes={
19+
"mcp.method": context.method,
20+
"mcp.source": context.source,
21+
"mcp.tool.name": tool_name,
22+
# If arguments are sensitive, consider omitting or sanitizing them
23+
# If arguments are long/nested, consider adding a size or depth limit
24+
"mcp.tool.arguments": str(context.message.arguments),
25+
},
26+
) as span:
27+
try:
28+
result = await call_next(context)
29+
span.set_attribute("mcp.tool.success", True)
30+
span.set_status(Status(StatusCode.OK))
31+
return result
32+
except Exception as e:
33+
span.set_attribute("mcp.tool.success", False)
34+
span.set_attribute("mcp.tool.error", str(e))
35+
span.set_status(Status(StatusCode.ERROR, str(e)))
36+
span.record_exception(e)
37+
raise
38+
39+
async def on_read_resource(self, context: MiddlewareContext, call_next):
40+
"""Create a span for each resource read."""
41+
resource_uri = str(getattr(context.message, "uri", "unknown"))
42+
43+
with self.tracer.start_as_current_span(
44+
f"resource.{resource_uri}",
45+
attributes={
46+
"mcp.method": context.method,
47+
"mcp.source": context.source,
48+
"mcp.resource.uri": resource_uri,
49+
},
50+
) as span:
51+
try:
52+
result = await call_next(context)
53+
span.set_attribute("mcp.resource.success", True)
54+
span.set_status(Status(StatusCode.OK))
55+
return result
56+
except Exception as e:
57+
span.set_attribute("mcp.resource.success", False)
58+
span.set_attribute("mcp.resource.error", str(e))
59+
span.set_status(Status(StatusCode.ERROR, str(e)))
60+
span.record_exception(e)
61+
raise
62+
63+
async def on_get_prompt(self, context: MiddlewareContext, call_next):
64+
"""Create a span for each prompt retrieval."""
65+
prompt_name = getattr(context.message, "name", "unknown")
66+
67+
with self.tracer.start_as_current_span(
68+
f"prompt.{prompt_name}",
69+
attributes={
70+
"mcp.method": context.method,
71+
"mcp.source": context.source,
72+
"mcp.prompt.name": prompt_name,
73+
},
74+
) as span:
75+
try:
76+
result = await call_next(context)
77+
span.set_attribute("mcp.prompt.success", True)
78+
span.set_status(Status(StatusCode.OK))
79+
return result
80+
except Exception as e:
81+
span.set_attribute("mcp.prompt.success", False)
82+
span.set_attribute("mcp.prompt.error", str(e))
83+
span.set_status(Status(StatusCode.ERROR, str(e)))
84+
span.record_exception(e)
85+
raise

uv.lock

Lines changed: 69 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)