diff --git a/infra/aca.bicep b/infra/aca.bicep index 7a63624..fb3a1b9 100644 --- a/infra/aca.bicep +++ b/infra/aca.bicep @@ -12,6 +12,7 @@ param openAiEndpoint string param cosmosDbAccount string param cosmosDbDatabase string param cosmosDbContainer string +param applicationInsightsConnectionString string = '' resource acaIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { name: identityName @@ -58,6 +59,11 @@ module app 'core/host/container-app-upsert.bicep' = { name: 'AZURE_COSMOSDB_CONTAINER' value: cosmosDbContainer } + // We typically store sensitive values in secrets, but App Insights connection strings are not considered highly sensitive + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: applicationInsightsConnectionString + } ] targetPort: 8000 } diff --git a/infra/main.bicep b/infra/main.bicep index ac54d34..d9a6c07 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -155,6 +155,20 @@ module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0 } } +// Application Insights for telemetry +module applicationInsights 'br/public:avm/res/insights/component:0.4.2' = if (useMonitoring) { + name: 'applicationinsights' + scope: resourceGroup + params: { + name: '${prefix}-appinsights' + location: location + tags: tags + workspaceResourceId: logAnalyticsWorkspace.?outputs.resourceId + kind: 'web' + applicationType: 'web' + } +} + // https://learn.microsoft.com/en-us/azure/container-apps/firewall-integration?tabs=consumption-only module containerAppsNSG 'br/public:avm/res/network/network-security-group:0.5.1' = if (useVnet) { name: 'containerAppsNSG' @@ -669,6 +683,7 @@ module aca 'aca.bicep' = { cosmosDbAccount: cosmosDb.outputs.name cosmosDbDatabase: cosmosDbDatabaseName cosmosDbContainer: cosmosDbContainerName + applicationInsightsConnectionString: useMonitoring ? applicationInsights.outputs.connectionString : '' exists: acaExists } } @@ -738,3 +753,6 @@ output AZURE_COSMOSDB_ACCOUNT string = cosmosDb.outputs.name output AZURE_COSMOSDB_ENDPOINT string = cosmosDb.outputs.endpoint output AZURE_COSMOSDB_DATABASE string = cosmosDbDatabaseName output AZURE_COSMOSDB_CONTAINER string = cosmosDbContainerName + +// We typically do not output sensitive values, but App Insights connection strings are not considered highly sensitive +output APPLICATIONINSIGHTS_CONNECTION_STRING string = useMonitoring ? applicationInsights.outputs.connectionString : '' diff --git a/infra/write_env.ps1 b/infra/write_env.ps1 index d8f7e37..6169d03 100644 --- a/infra/write_env.ps1 +++ b/infra/write_env.ps1 @@ -11,4 +11,5 @@ Add-Content -Path $ENV_FILE_PATH -Value "AZURE_TENANT_ID=$(azd env get-value AZU Add-Content -Path $ENV_FILE_PATH -Value "AZURE_COSMOSDB_ACCOUNT=$(azd env get-value AZURE_COSMOSDB_ACCOUNT)" Add-Content -Path $ENV_FILE_PATH -Value "AZURE_COSMOSDB_DATABASE=$(azd env get-value AZURE_COSMOSDB_DATABASE)" Add-Content -Path $ENV_FILE_PATH -Value "AZURE_COSMOSDB_CONTAINER=$(azd env get-value AZURE_COSMOSDB_CONTAINER)" +Add-Content -Path $ENV_FILE_PATH -Value "APPLICATIONINSIGHTS_CONNECTION_STRING=$(azd env get-value APPLICATIONINSIGHTS_CONNECTION_STRING)" Add-Content -Path $ENV_FILE_PATH -Value "API_HOST=azure" diff --git a/infra/write_env.sh b/infra/write_env.sh index a3b4ace..733467d 100755 --- a/infra/write_env.sh +++ b/infra/write_env.sh @@ -15,4 +15,5 @@ echo "AZURE_TENANT_ID=$(azd env get-value AZURE_TENANT_ID)" >> "$ENV_FILE_PATH" echo "AZURE_COSMOSDB_ACCOUNT=$(azd env get-value AZURE_COSMOSDB_ACCOUNT)" >> "$ENV_FILE_PATH" echo "AZURE_COSMOSDB_DATABASE=$(azd env get-value AZURE_COSMOSDB_DATABASE)" >> "$ENV_FILE_PATH" echo "AZURE_COSMOSDB_CONTAINER=$(azd env get-value AZURE_COSMOSDB_CONTAINER)" >> "$ENV_FILE_PATH" -echo "API_HOST=azure" >> "$ENV_FILE_PATH" \ No newline at end of file +echo "APPLICATIONINSIGHTS_CONNECTION_STRING=$(azd env get-value APPLICATIONINSIGHTS_CONNECTION_STRING)" >> "$ENV_FILE_PATH" +echo "API_HOST=azure" >> "$ENV_FILE_PATH" diff --git a/pyproject.toml b/pyproject.toml index 7a49d59..c7196ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,10 @@ dependencies = [ "azure-ai-agents>=1.1.0", "agent-framework>=1.0.0b251016", "azure-cosmos>=4.9.0", + "azure-monitor-opentelemetry>=1.6.4", + "opentelemetry-instrumentation-starlette>=0.49b0", + "logfire>=4.15.1", + "azure-core-tracing-opentelemetry>=1.0.0b12" ] [dependency-groups] diff --git a/servers/__init__.py b/servers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/servers/deployed_mcp.py b/servers/deployed_mcp.py index bed88c0..20a4d15 100644 --- a/servers/deployed_mcp.py +++ b/servers/deployed_mcp.py @@ -1,3 +1,5 @@ +"""Run with: cd servers && uvicorn deployed_mcp:app --host 0.0.0.0 --port 8000""" + import logging import os import uuid @@ -5,21 +7,43 @@ from enum import Enum from typing import Annotated +import logfire +from azure.core.settings import settings from azure.cosmos.aio import CosmosClient from azure.identity.aio import DefaultAzureCredential, ManagedIdentityCredential +from azure.monitor.opentelemetry import configure_azure_monitor from dotenv import load_dotenv from fastmcp import FastMCP +from opentelemetry.instrumentation.starlette import StarletteInstrumentor + +try: + from opentelemetry_middleware import OpenTelemetryMiddleware +except ImportError: + from servers.opentelemetry_middleware import OpenTelemetryMiddleware + +RUNNING_IN_PRODUCTION = os.getenv("RUNNING_IN_PRODUCTION", "false").lower() == "true" -load_dotenv(override=True) +if not RUNNING_IN_PRODUCTION: + load_dotenv(override=True) -logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(message)s") +logging.basicConfig(level=logging.WARNING, format="%(asctime)s - %(message)s") logger = logging.getLogger("ExpensesMCP") +logger.setLevel(logging.INFO) + +# Configure OpenTelemetry tracing, either via Azure Monitor or Logfire +# We don't support both at the same time due to potential conflicts with tracer providers +if os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING"): + logger.info("Setting up Azure Monitor instrumentation") + configure_azure_monitor() +elif os.getenv("LOGFIRE_PROJECT_NAME"): + logger.info("Setting up Logfire instrumentation") + settings.tracing_implementation = "opentelemetry" # Configure Azure SDK to use OpenTelemetry tracing + logfire.configure(service_name="expenses-mcp", send_to_logfire=True) # Cosmos DB configuration from environment variables AZURE_COSMOSDB_ACCOUNT = os.environ["AZURE_COSMOSDB_ACCOUNT"] AZURE_COSMOSDB_DATABASE = os.environ["AZURE_COSMOSDB_DATABASE"] AZURE_COSMOSDB_CONTAINER = os.environ["AZURE_COSMOSDB_CONTAINER"] -RUNNING_IN_PRODUCTION = os.getenv("RUNNING_IN_PRODUCTION", "false").lower() == "true" AZURE_CLIENT_ID = os.getenv("AZURE_CLIENT_ID", "") # Configure Cosmos DB client and container @@ -37,6 +61,7 @@ logger.info(f"Connected to Cosmos DB: {AZURE_COSMOSDB_ACCOUNT}") mcp = FastMCP("Expenses Tracker") +mcp.add_middleware(OpenTelemetryMiddleware("ExpensesMCP")) class PaymentMethod(Enum): @@ -152,7 +177,4 @@ def analyze_spending_prompt( # ASGI application for uvicorn app = mcp.http_app() - -if __name__ == "__main__": - logger.info("MCP Expenses server starting (HTTP mode on port 8000)") - mcp.run(transport="http", host="0.0.0.0", port=8000) +StarletteInstrumentor.instrument_app(app) diff --git a/servers/opentelemetry_middleware.py b/servers/opentelemetry_middleware.py new file mode 100644 index 0000000..cf91e0b --- /dev/null +++ b/servers/opentelemetry_middleware.py @@ -0,0 +1,85 @@ +from fastmcp.server.middleware import Middleware, MiddlewareContext +from opentelemetry import trace +from opentelemetry.trace import Status, StatusCode + + +class OpenTelemetryMiddleware(Middleware): + """Middleware that creates OpenTelemetry spans for MCP operations.""" + + def __init__(self, tracer_name: str): + self.tracer = trace.get_tracer(tracer_name) + + async def on_call_tool(self, context: MiddlewareContext, call_next): + """Create a span for each tool call with detailed attributes.""" + tool_name = context.message.name + + with self.tracer.start_as_current_span( + f"tool.{tool_name}", + attributes={ + "mcp.method": context.method, + "mcp.source": context.source, + "mcp.tool.name": tool_name, + # If arguments are sensitive, consider omitting or sanitizing them + # If arguments are long/nested, consider adding a size or depth limit + "mcp.tool.arguments": str(context.message.arguments), + }, + ) as span: + try: + result = await call_next(context) + span.set_attribute("mcp.tool.success", True) + span.set_status(Status(StatusCode.OK)) + return result + except Exception as e: + span.set_attribute("mcp.tool.success", False) + span.set_attribute("mcp.tool.error", str(e)) + span.set_status(Status(StatusCode.ERROR, str(e))) + span.record_exception(e) + raise + + async def on_read_resource(self, context: MiddlewareContext, call_next): + """Create a span for each resource read.""" + resource_uri = str(getattr(context.message, "uri", "unknown")) + + with self.tracer.start_as_current_span( + f"resource.{resource_uri}", + attributes={ + "mcp.method": context.method, + "mcp.source": context.source, + "mcp.resource.uri": resource_uri, + }, + ) as span: + try: + result = await call_next(context) + span.set_attribute("mcp.resource.success", True) + span.set_status(Status(StatusCode.OK)) + return result + except Exception as e: + span.set_attribute("mcp.resource.success", False) + span.set_attribute("mcp.resource.error", str(e)) + span.set_status(Status(StatusCode.ERROR, str(e))) + span.record_exception(e) + raise + + async def on_get_prompt(self, context: MiddlewareContext, call_next): + """Create a span for each prompt retrieval.""" + prompt_name = getattr(context.message, "name", "unknown") + + with self.tracer.start_as_current_span( + f"prompt.{prompt_name}", + attributes={ + "mcp.method": context.method, + "mcp.source": context.source, + "mcp.prompt.name": prompt_name, + }, + ) as span: + try: + result = await call_next(context) + span.set_attribute("mcp.prompt.success", True) + span.set_status(Status(StatusCode.OK)) + return result + except Exception as e: + span.set_attribute("mcp.prompt.success", False) + span.set_attribute("mcp.prompt.error", str(e)) + span.set_status(Status(StatusCode.ERROR, str(e))) + span.record_exception(e) + raise diff --git a/uv.lock b/uv.lock index 7f4c4cb..1062ddb 100644 --- a/uv.lock +++ b/uv.lock @@ -656,6 +656,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, ] +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317 }, +] + [[package]] name = "fastapi" version = "0.119.1" @@ -1246,6 +1255,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/a0/b91504515c1f9a299fc157967ffbd2f0321bce0516a3d5b89f6f4cad0355/lazy_object_proxy-1.12.0-pp39.pp310.pp311.graalpy311-none-any.whl", hash = "sha256:c3b2e0af1f7f77c4263759c4824316ce458fabe0fceadcd24ef8ca08b2d1e402", size = 15072 }, ] +[[package]] +name = "logfire" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "executing" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "protobuf" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/7b/3cdbfbd8fc085912d7322afc5f16ffd396197574fc9c786422b0ce3f5232/logfire-4.15.1.tar.gz", hash = "sha256:fac8463c0319af6d1bf66788802ff0a04b481dac006564f6837f64c7404f474a", size = 549319 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/44/45c39998cb3920a11498455f3f62f173bd3f5d9a15bd0db40f88bedb9e1f/logfire-4.15.1-py3-none-any.whl", hash = "sha256:b931d2becf937c08d7c89f1ab68ab05298095b010dfaf29cbd22f7bcacbaa2bb", size = 228716 }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1668,6 +1695,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/f0/bd831afbdba74ca2ce3982142a2fad707f8c487e8a3b6fef01f1d5945d1b/opentelemetry_exporter_otlp_proto_grpc-1.38.0-py3-none-any.whl", hash = "sha256:7c49fd9b4bd0dbe9ba13d91f764c2d20b0025649a6e4ac35792fb8d84d764bc7", size = 19695 }, ] +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/0a/debcdfb029fbd1ccd1563f7c287b89a6f7bef3b2902ade56797bfd020854/opentelemetry_exporter_otlp_proto_http-1.38.0.tar.gz", hash = "sha256:f16bd44baf15cbe07633c5112ffc68229d0edbeac7b37610be0b2def4e21e90b", size = 17282 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/77/154004c99fb9f291f74aa0822a2f5bbf565a72d8126b3a1b63ed8e5f83c7/opentelemetry_exporter_otlp_proto_http-1.38.0-py3-none-any.whl", hash = "sha256:84b937305edfc563f08ec69b9cb2298be8188371217e867c1854d77198d0825b", size = 19579 }, +] + [[package]] name = "opentelemetry-instrumentation" version = "0.59b0" @@ -1792,6 +1837,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/ea/c282ba418b2669e4f730cb3f68b02a0ca65f4baf801e971169a4cc449ffb/opentelemetry_instrumentation_requests-0.59b0-py3-none-any.whl", hash = "sha256:d43121532877e31a46c48649279cec2504ee1e0ceb3c87b80fe5ccd7eafc14c1", size = 12966 }, ] +[[package]] +name = "opentelemetry-instrumentation-starlette" +version = "0.59b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/39/95e5868e731bcf08a642483afaa34c455bfa4f61d6cfdd096626e9ee40d6/opentelemetry_instrumentation_starlette-0.59b0.tar.gz", hash = "sha256:3f033fd92d6a8e4122ebcb3d83afc5c64d6be7930e9094876eb02b8afbd08ba5", size = 14657 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/fc/4727e11d7c442ba8bb79ba261303c9e061c235eabcf633ddefc10230c8c4/opentelemetry_instrumentation_starlette-0.59b0-py3-none-any.whl", hash = "sha256:a833b97d297e4b2aaf58041612663dbabb8380e3993c76a4e32b3e351470f321", size = 11778 }, +] + [[package]] name = "opentelemetry-instrumentation-urllib" version = "0.59b0" @@ -2280,15 +2341,19 @@ source = { virtual = "." } dependencies = [ { name = "agent-framework" }, { name = "azure-ai-agents" }, + { name = "azure-core-tracing-opentelemetry" }, { name = "azure-cosmos" }, { name = "azure-identity" }, + { name = "azure-monitor-opentelemetry" }, { name = "debugpy" }, { name = "fastmcp" }, { name = "langchain" }, { name = "langchain-core" }, { name = "langchain-mcp-adapters" }, { name = "langchain-openai" }, + { name = "logfire" }, { name = "mcp" }, + { name = "opentelemetry-instrumentation-starlette" }, ] [package.dev-dependencies] @@ -2301,15 +2366,19 @@ dev = [ requires-dist = [ { name = "agent-framework", specifier = ">=1.0.0b251016" }, { name = "azure-ai-agents", specifier = ">=1.1.0" }, + { name = "azure-core-tracing-opentelemetry", specifier = ">=1.0.0b12" }, { name = "azure-cosmos", specifier = ">=4.9.0" }, { name = "azure-identity", specifier = ">=1.25.1" }, + { name = "azure-monitor-opentelemetry", specifier = ">=1.6.4" }, { name = "debugpy", specifier = ">=1.8.0" }, { name = "fastmcp", specifier = ">=2.12.5" }, { name = "langchain", specifier = "==1.0.0a5" }, { name = "langchain-core", specifier = ">=0.3.0" }, { name = "langchain-mcp-adapters", specifier = ">=0.1.11" }, { name = "langchain-openai", specifier = ">=1.0.1" }, + { name = "logfire", specifier = ">=4.15.1" }, { name = "mcp", specifier = ">=1.3.0" }, + { name = "opentelemetry-instrumentation-starlette", specifier = ">=0.49b0" }, ] [package.metadata.requires-dev]