Dynamically Routing Traces to Customer-Specific App Insights

July 07, 2025 • Written by Jakob Serlier

Tags: Azure, OpenTelemetry, SaaS, Observability, Python
---

When building a SaaS offering publishing platform that helps customers publish azure marketplace offers, we faced an interesting observability challenge: how do you maintain separate telemetry streams for both your platform and each customer's application?

The Problem

Our platform automatically provisions Azure Application Insights instances for each marketplace offer. Customers can send telemetry to their dedicated instance, but we also need to route our internal billing and management traces to the same destination. The tricky part is to dynamically determine and route the relevant traces to customer app insights based on billing event context.

The Solution: Dynamic Telemetry Routing

We built a factory pattern that creates subscription-specific OpenTelemetry exporters on-demand:

from azure.monitor.opentelemetry.exporter import AzureMonitorTraceExporter

class TelemetryExporterFactory:
    def __init__(self):
        self.key_vault_service = KeyVaultService(settings.keyvault_url)
        self._connection_string_cache = {}
        self._connection_string_lock = asyncio.Lock()

    async def get_trace_exporter(self, mp_sub_id: str, db: AsyncSession):
        connection_string = await self._get_connection_string(
            mp_sub_id=mp_sub_id, db=db
        )
        return AzureMonitorTraceExporter(connection_string=connection_string)

    async def _get_connection_string(self, mp_sub_id: str, db: AsyncSession):
        if mp_sub_id in self._connection_string_cache:
            return self._connection_string_cache[mp_sub_id]

        async with self._connection_string_lock:
            ... # find and return the connection string for the specific offer given a specific subscriber

While also setting up our own observability stack:

def setup_observability(app: FastAPI | None = None):
    # Set up platform-wide observability for our SaaS platform
    LoggingInstrumentor().instrument(set_logging_format=True)
    configure_azure_monitor(credential=DefaultAzureCredential())

    tracer = trace.get_tracer(__name__)
    if app:
        FastAPIInstrumentor.instrument_app(app)

Clean Usage with Context Managers

The real magic happens with our convenience wrapper that makes subscription-specific tracing feel natural:

def create_subscription_span(mp_sub_id: str, name: str, factory=None, db_session=None):
    @asynccontextmanager
    async def _span_context():
        with get_tracer().start_as_current_span(name) as span:
            yield span
            if factory and db_session and span:
                await factory.export_span(mp_sub_id=mp_sub_id, span=span, db_session=db_session)

    return _span_context

# Usage in billing operations
async with create_subscription_span(
    subscription_id,
    "billing.process_usage",
    telemetry_factory,
    db_session
) as span:
    span.set_attribute("usage.quantity", quantity)
    span.set_attribute("usage.dimension_id", dimension_id)
    await process_billing_event(subscription_id, usage_data)

Why This Matters

  1. Each customer gets their own observability stack, completely isolated
  2. We can separate logging and tracing for our own observability stack, and add span events & attributes for information relevant to our customers
  3. Customer traces and platform traces appear in the same App Insights instance and can be correlated
  4. No configuration needed - the system automatically routes based on subscription