SaveMyCert
Log in
5 of 5 free questions left today·for 10 a day
Secure, monitor, and troubleshoot Azure solutions

AI-200 OpenTelemetry Tracing and KQL: Monitor and Troubleshoot Azure Solutions

22 min readAI-200 · Secure, monitor, and troubleshoot Azure solutionsUpdated

Monitoring and troubleshooting an Azure solution means instrumenting each service with OpenTelemetry so its traces, logs and metrics reach Application Insights as one correlated picture, then querying that data with KQL (Kusto Query Language) to find slow, failing or noisy behaviour. This lesson covers both AI-200 skills in this area for Python services: tracing distributed systems with the Microsoft OpenTelemetry Distro and the OpenTelemetry SDK (setup, Microsoft Entra authentication, cloud role names, span kinds, custom spans and processors, context propagation, exceptions, sampling, filtering, logs, custom events and metrics), and writing KQL that aggregates, filters, correlates and counts telemetry and platform metrics correctly.

What you’ll learn
  • Enable Azure Monitor export for a Python service with the Microsoft OpenTelemetry Distro, a connection string, offline storage and Microsoft Entra authentication
  • Set cloud role names and instances with resource attributes so each service appears correctly on Application Map
  • Choose span kinds, create custom spans, enrich spans with processors and propagate W3C trace context across services and custom transports
  • Record handled and unhandled exceptions with the right span status, without duplicates
  • Configure sampling and pick the right filtering method for HTTP endpoints, non-HTTP spans and ingestion-time rules
  • Collect logs, custom events and custom metrics with the right logger names, levels and metric instruments
  • Write KQL that aggregates time series, filters strings efficiently, counts sampled data, correlates operations with joins and queries platform metrics

Enabling Azure Monitor export with the Microsoft OpenTelemetry Distro

A Python app sends traces, logs and metrics to Application Insights by installing the Microsoft OpenTelemetry Distro (pip install microsoft-opentelemetry) and calling use_microsoft_opentelemetry(enable_azure_monitor=True) once, before the app starts handling work. Microsoft directs new Python apps to this distro; apps already on the older Azure Monitor OpenTelemetry Distro (configure_azure_monitor()) need no change, but you never initialize both distros in one app.

import logging
from microsoft.opentelemetry import use_microsoft_opentelemetry

use_microsoft_opentelemetry(
    enable_azure_monitor=True,   # export to Application Insights
    logger_name="billing",       # collect logs from this logger tree
)

logger = logging.getLogger("billing")
logger.setLevel(logging.INFO)

The destination is the resource's connection string. Microsoft recommends the APPLICATIONINSIGHTS_CONNECTION_STRING environment variable for production: the same image or package then reports to a different Application Insights resource in each environment just by changing the variable. Passing azure_monitor_connection_string=... in code works but hard-codes one resource into the build, so keep it for local testing. A bare instrumentation key (APPINSIGHTS_INSTRUMENTATIONKEY) is the legacy SDK mechanism, not how the OpenTelemetry exporter is configured.

Bundled instrumentation: don't add it twice

The distro bundles and enables instrumentation for common libraries (web frameworks such as Flask, Django and FastAPI, HTTP clients such as requests and urllib3, database drivers and others). Microsoft warns against also instrumenting a bundled library by hand, for example calling a framework's Instrumentor().instrument_app(...) yourself: duplicate instrumentation isn't supported and shows up as doubled or inconsistent telemetry. The fix is to remove the manual call, not to filter or sample the duplicates away. Libraries the distro doesn't cover can still be added with their own OpenTelemetry instrumentation package.

Offline storage and retries

When ingestion is briefly unavailable, the exporter writes unsent telemetry to local disk and retries later. This is on by default (azure_monitor_exporter_disable_offline_storage defaults to False) and uses a folder under the temp directory. If the process can't write there, as in a locked-down container running as a non-root user, set azure_monitor_exporter_storage_directory to a writable path such as a mounted volume. Disabling offline storage silences the write errors but throws the data away.

Authenticating telemetry with Microsoft Entra ID

Telemetry ingestion can require a Microsoft Entra token instead of the connection string alone. Once the security team sets local authentication to disabled on the Application Insights resource (DisableLocalAuth), only authenticated telemetry is accepted, and any app still sending with just its connection string is rejected. The connection string is still needed to find the endpoint; it simply stops being enough on its own.

Setting it up takes three things:

  1. An identity for the app: a managed identity (preferred) or a service principal.
  2. The Monitoring Metrics Publisher role assigned to that identity with the Application Insights resource as the scope. Despite its name, the role publishes all telemetry types. Reader or contributor roles (Monitoring Reader, Application Insights Component Contributor, Log Analytics Contributor) manage or read resources and don't grant the ingestion data action. A role on the Log Analytics workspace behind the resource doesn't count either, because the workspace is a different resource from the Application Insights component.
  3. A credential passed to the distro with azure_monitor_exporter_credential.
from azure.identity import ManagedIdentityCredential
from microsoft.opentelemetry import use_microsoft_opentelemetry

use_microsoft_opentelemetry(
    enable_azure_monitor=True,
    # user-assigned identity: name it by client ID
    azure_monitor_exporter_credential=ManagedIdentityCredential(
        client_id="<client-id-of-the-identity>"
    ),
)

ManagedIdentityCredential() with no arguments uses the system-assigned identity, so an app that has only a user-assigned identity must pass that identity's client_id. A ClientSecretCredential also authenticates, but it means storing a secret, which is exactly what managed identities avoid. Some Microsoft samples still show the older distro's configure_azure_monitor(credential=...); with the Microsoft OpenTelemetry Distro the parameter is azure_monitor_exporter_credential.

Cloud role name and role instance

Application Insights labels each service by its cloud role name, which the exporter builds from OpenTelemetry resource attributes, the attributes that describe the process emitting telemetry rather than any one operation. Application Map draws one node per role name, so when several services share one Application Insights resource Microsoft requires each to set its own role name; otherwise they collapse into a single node. You don't need separate resources to separate them.

Resource attributeSet withBecomes
service.nameOTEL_SERVICE_NAME, or service.name=... in OTEL_RESOURCE_ATTRIBUTESCloud role name (with the namespace, if any)
service.namespaceOTEL_RESOURCE_ATTRIBUTESPrefix of the role name: <namespace>.<name>
service.instance.idOTEL_RESOURCE_ATTRIBUTESCloud role instance (one replica or pod)

Two rules decide the final value. First, the role name is service.namespace + . + service.name, or just service.name when no namespace is set. Second, under the OpenTelemetry SDK configuration rules, OTEL_SERVICE_NAME takes precedence over a service.name given inside OTEL_RESOURCE_ATTRIBUTES.

OTEL_SERVICE_NAME=invoicer
OTEL_RESOURCE_ATTRIBUTES=service.namespace=billing,service.instance.id=replica-3
# AppRoleName     -> billing.invoicer
# AppRoleInstance -> replica-3

In workspace tables these values appear as AppRoleName and AppRoleInstance, which is what KQL filters on. Two things don't change the role: a service.name span attribute (span attributes describe one operation and become custom properties) and other custom resource attributes, which the exporter doesn't copy onto every row (it reports them separately, in a metric named _OTELRESOURCE_). For a per-row property, use a span processor (next section).

Span kinds, custom spans and span processors

A span's kind decides which Application Insights table it lands in: spans for incoming work become requests, and spans for outgoing or in-process work become dependencies. Requests drive request rates, failure rates and the operation-based Performance and Failures views, so the kind matters for any span you create yourself.

SpanKindMeaningTable
SERVERHandling an incoming synchronous call (HTTP request, RPC)requests (AppRequests)
CONSUMERProcessing a received messagerequests (AppRequests)
CLIENTMaking an outgoing synchronous call to another componentdependencies (AppDependencies)
PRODUCERSending a message to a brokerdependencies (AppDependencies)
INTERNAL (the default)Local work inside the processdependencies, type InProc

A span created with no kind is INTERNAL. That is right for a sub-step, but wrong for the top-level unit of work in a background worker that autoinstrumentation doesn't capture (a custom queue loop, a scheduled job): Microsoft recommends kind=SpanKind.SERVER there so the work shows up as an operation. A call to a remote system over a protocol no library instruments should be CLIENT, so it appears as a remote dependency rather than in-process work.

Creating spans

Parent-child links come from the active context. tracer.start_as_current_span(name) starts a span and makes it current, so spans created inside the block, including auto-instrumented HTTP or database calls, become its children. Used as a context manager it ends the span on exit (end_on_exit=True by default), which is what gets it exported.

from opentelemetry import trace
from opentelemetry.trace import SpanKind

tracer = trace.get_tracer(__name__)

with tracer.start_as_current_span("rerank", kind=SpanKind.INTERNAL) as span:
    span.set_attribute("candidates", len(items))
    ranked = rerank(items)      # calls inside are children of "rerank"
CallBecomes current?Ends when the block exits?
start_as_current_span(name)YesYes (default end_on_exit=True)
start_as_current_span(name, end_on_exit=False)YesNo: you must call span.end()
start_span(name)No: nested spans attach to whatever was current beforeYes, when used in a with block (Span.__exit__ calls end()); otherwise call end() yourself
trace.use_span(span)Yes, for an existing spanNo by default (end_on_exit=False)

Attributes set with span.set_attribute become custom properties (Properties in the workspace tables).

Span processors: enrich every span without touching handlers

A span processor is called by the SDK for every span, auto-instrumented ones included, so it's how you add a property to all requests and dependencies without editing route handlers. Register it with span_processors=[...] in the distro call. Set attributes in on_start, which receives the live, writable span. on_end receives a read-only ReadableSpan that has no set_attribute method, so writes there fail. A request middleware only reaches the request span, not its dependencies.

from opentelemetry.sdk.trace import SpanProcessor

class TenantTagger(SpanProcessor):
    def on_start(self, span, parent_context=None):
        span.set_attribute("tenantTier", TIER)   # value known up front

use_microsoft_opentelemetry(enable_azure_monitor=True,
                            span_processors=[TenantTagger()])

Propagating trace context across services

A distributed trace survives a hop between services only if the caller sends its trace context and the receiver continues from it. OpenTelemetry Python's default propagators are W3C Trace Context (the traceparent and tracestate headers) plus baggage. Instrumented HTTP clients and servers inject and extract these headers automatically; a proxy, gateway or other hop that strips them breaks the trace, and the fix is to forward them unchanged. The legacy Request-Id header isn't what OpenTelemetry sends.

Reading a traceparent header

traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
             |  |                                |                |
          version  trace ID (32 hex)        parent span ID (16 hex)  flags (01 = sampled)

In Application Insights the trace ID is the OperationId shared by every item in the trace. Each span has its own Id (span ID), and its ParentId is the span ID of its direct parent. So the server span that handled an incoming call has the caller's trace ID as its OperationId and the caller's span ID (the third field) as its ParentId, while getting a new Id of its own.

Custom transports: inject and extract

When work travels through something no instrumentation covers, such as jobs pushed onto a Redis list or a custom socket protocol, carry the context yourself. The producer calls opentelemetry.propagate.inject(carrier) to write traceparent/tracestate into a dict that travels with the payload; the consumer calls extract(carrier) and passes the result as the context of its span.

from opentelemetry.propagate import inject, extract

# producer
message = {"body": payload, "meta": {}}
inject(message["meta"])                 # adds traceparent / tracestate
queue.push(json.dumps(message))

# consumer
message = json.loads(queue.pop())
ctx = extract(message["meta"])
with tracer.start_as_current_span("handle-message", context=ctx,
                                  kind=SpanKind.CONSUMER):
    handle(message["body"])

Setting a span kind, sharing a role name or logging the trace ID doesn't link spans: only a propagated context makes both sides share one OperationId.

Recording exceptions and span status

An exception that escapes a start_as_current_span block is handled for you: by default (record_exception=True, set_status_on_exception=True) the SDK records it as an exception event, which becomes an AppExceptions row linked to the span, and sets the span's status to ERROR, which marks the request or dependency failed. The two flags are independent: one controls the exception record, the other the failed status.

A handled exception (caught inside the block, perhaps with a fallback) triggers neither. To keep the failure visible while still recovering, record it explicitly:

from opentelemetry.trace import Status, StatusCode

with tracer.start_as_current_span("fetch-profile") as span:
    try:
        profile = primary.get(user_id)
    except Exception as ex:
        span.record_exception(ex)                       # AppExceptions row
        span.set_status(Status(StatusCode.ERROR, str(ex)))  # span failed
        profile = replica.get(user_id)                  # fallback continues
SituationWhat to do
Caught exception that should still count as a failurespan.record_exception(ex) plus span.set_status(StatusCode.ERROR). A span attribute named after the exception is only a custom property.
Manual record_exception followed by raiseThe SDK records it again on the way out, so each failure appears twice. Start the span with record_exception=False (or drop the manual call).
Expected exception that must propagate but isn't a failureStart the span with record_exception=False and set_status_on_exception=False. These apply to any exception leaving that span, so a genuine failure in it then needs explicit recording.

Logging the error at a level below the collected threshold produces nothing, and re-raising to get automatic recording also cancels the fallback.

Sampling and filtering telemetry

Sampling in the Python distro keeps a share of whole traces and is configured with two environment variables set before the distro initializes: OTEL_TRACES_SAMPLER picks the sampler and OTEL_TRACES_SAMPLER_ARG its argument. Microsoft says not to carry over the older distro's sampling_ratio and traces_per_second arguments.

SamplerARG meansVolume as traffic grows
microsoft.rate_limited (the default)Traces per second (default 5)Capped near a fixed rate
microsoft.fixed_percentageA ratio from 0.0 to 1.0 (0.1 = about 10%)Rises in proportion to traffic

Because the default is rate-limited, a busy service with no sampling settings already keeps only about five traces a second. A ratio of 10 isn't "10%": the fixed-percentage argument is a fraction. Generic OpenTelemetry samplers such as parentbased_always_on keep everything.

Sampling decides per trace, so a kept trace is complete. It doesn't apply to everything: metrics are never sampled, and in Python trace-based sampling for logs is off by default, so log volume doesn't drop when spans are sampled. Passing enable_trace_based_sampling_for_logs=True to use_microsoft_opentelemetry drops log records that belong to unsampled traces and keeps the others as they are. Kept items carry an ItemCount (how many original items each represents), which KQL must use to count correctly (see below).

Filtering: choose by where and what you drop

MethodWhere it actsUse when
OTEL_PYTHON_EXCLUDED_URLSIn the app, configuration onlyDrop HTTP request telemetry for specific paths (probes, health endpoints). Applies to HTTP instrumentations only.
Span processor setting TraceFlags.DEFAULT in on_startIn the app, codeDrop non-HTTP spans by name or attribute (for example a frequent cache ping) so they never leave the process.
Workspace transformation (DCR) on an App* tableAt ingestion, no app changeDrop rows by any column, such as successful health checks while failures are kept, without redeploying or restarting. Activation can take up to an hour.

Lowering the sampling rate cuts all traces rather than one endpoint, and a logger level controls log records only, never request telemetry. Deleting rows after ingestion doesn't prevent them from being ingested.

Logs, custom events and custom metrics

The distro collects Python logging records from the logger named in logger_name and from every logger below it in the dot-separated hierarchy, and sends them to traces (AppTraces) with the active trace's IDs attached, which correlates each log line with its request. Microsoft suggests a named application logger so the SDK's own log messages aren't collected.

  • Hierarchy. With logger_name="billing", records from billing.invoices and billing.invoices.pdf propagate up to it. Names that merely contain the text (billingapi, billing_jobs) or put it later (jobs.billing) are separate branches and aren't collected.
  • Levels. A logger with no level of its own inherits from its ancestors; with nothing set, the root's WARNING applies, so info and debug calls are discarded before they reach the exporter. Set the level on the collected logger (setLevel(logging.INFO)); child loggers without a level inherit it. Microsoft's advice is to control log volume with levels and filters.

Custom events

A log call becomes a custom event (customEvents, AppEvents in the workspace) when its extra dict carries the key microsoft.custom_event.name, logged on a collected logger at a collected level:

logger.info("invoice sent",
            extra={"microsoft.custom_event.name": "invoice-sent"})

Custom metrics: pick the instrument by the shape of the data

InstrumentRecordsAggregates toExample
create_counter → add()Increments that only go upSumFiles processed
create_up_down_counter → add()Increments and decrementsSumActive sessions you open and close
create_histogram → record()Individual measurementsCount, sum, min, max (so average)Call latency
create_observable_gauge(name, [callback])A current value read on each collectionLast valueCurrent backlog read from an API

Attributes passed with a measurement, such as {"region": r}, become dimensions, so one metric can be split per value; putting the value in the metric name instead loses that shared dimension. An observable gauge's callback receives an options argument and yields one Observation(value, attributes) per series:

from opentelemetry import metrics
from opentelemetry.metrics import Observation

meter = metrics.get_meter(__name__)

def read_backlog(options):
    for pool, size in pool_sizes().items():
        yield Observation(size, {"pool": pool})

meter.create_observable_gauge("pool_backlog", [read_backlog])

If you only know the current value, not each change, a counter or up-down counter is wrong: they add whatever you pass, so repeatedly adding the current value inflates the total.

KQL for logs and metrics: aggregation, string filters and sampled counts

KQL (Kusto Query Language) queries Application Insights data in a Log Analytics workspace as a pipeline of operators separated by |, each working on the rows the previous one produced. Workspace-based Application Insights stores telemetry in AppRequests, AppDependencies, AppTraces, AppExceptions, AppEvents and AppMetrics (the classic requests, dependencies, traces and so on), with columns such as TimeGenerated, Name, DurationMs, Success, AppRoleName, OperationId and ItemCount.

AppDependencies
| where TimeGenerated > ago(6h) and DependencyType == "SQL"
| summarize p90 = percentile(DurationMs, 90), calls = sum(ItemCount)
    by bin(TimeGenerated, 15m)
| render timechart
  • where with ago() limits the time range; between (a .. b) and in (...) test ranges and lists.
  • summarize ... by bin(TimeGenerated, 5m) creates fixed-size time buckets. Grouping by raw TimeGenerated gives one group per timestamp. Aggregates include count(), countif(), avg(), percentile()/percentiles(), dcount(), arg_max().
  • Order of operators matters. To rank groups, aggregate first and then top N by <aggregate>; top before summarize ranks individual rows, and take returns arbitrary rows with no sort.
  • render timechart draws the result over time.

Gap-free time series with make-series

summarize never invents rows: a bin with no data is simply absent, even when countif would have been 0. Series functions such as series_decompose_anomalies() need evenly spaced arrays, which make-series builds, filling empty steps with default=. It fills only inside its range, and without from ... to ... the range runs from the first to the last row present, so set both:

AppDependencies
| make-series Errors = countif(Success == false) default=0
    on TimeGenerated from ago(7d) to now() step 1h
| extend anomalies = series_decompose_anomalies(Errors)

String operators

Kusto indexes string columns by terms, runs of letters and digits split at other characters (so cache-v2 holds the terms cache and v2). Term operators can use that index; substring operators must scan.

OperatorMatchesCaseIndex
has / !hasA whole termInsensitiveYes (fast)
has_csA whole termSensitiveYes
contains / !containsAny substring, including inside a termInsensitiveNo (slower)
contains_csAny substringSensitiveNo
startswithStart of the valueInsensitiveNot a term lookup
== / =~The entire valueSensitive / insensitiveYes

Choose has for a whole word in any case on large tables, contains when the text can sit inside a longer word (has can't find sync inside AsyncWorker), and =~ for an exact value in any case; has or contains would also match longer values that include the term.

Counting sampled telemetry

When telemetry is sampled, each stored row stands for ItemCount original items (about 1 ÷ the sampling ratio, or more under rate-limited sampling as traffic grows). count() and dcount() count only the stored rows, so they understate traffic and, under rate limiting, flatten at the cap. sum(ItemCount) estimates the real total, which is what the portal's own charts do. avg(ItemCount) shows the effective sampling factor, useful for checking configuration but not a total. With unsampled data every ItemCount is 1, so the two agree.

Correlating telemetry and querying platform metrics in KQL

Telemetry for one distributed operation is correlated in KQL through three columns: OperationId (the trace ID every item shares), Id (this item's span ID) and ParentId (its direct parent's span ID).

  • Everything in one operation: union AppRequests, AppDependencies, AppTraces, AppExceptions | where OperationId == id | order by TimeGenerated asc. A join of two tables leaves out logs and exceptions, and filtering on Id or ParentId returns one item or one item's children.
  • Direct children of a request: join the request's Id to the dependency's ParentId. Joining on OperationId returns every dependency anywhere in the trace, including other services' calls.

Join kinds

kind=Returns
innerunique (the default)Matches, after keeping only one left row per join key. Counts of left rows come out too low.
innerEvery matching left/right combination
leftouterAll left rows, matched or not (nulls when unmatched)
leftantiLeft rows with no match: "what has no partner?"
rightantiRight rows with no match

lookup extends a fact table with columns from a smaller dimension table and defaults to leftouter behaviour, so unmatched rows stay. The default join is easy to miss: A | join (B) on OperationId without a kind is innerunique, which silently de-duplicates the left side.

AppDependencies
| where Success == false
| join kind=leftanti (AppTraces | where SeverityLevel >= 3) on OperationId
// failed dependencies whose operation logged no error

Platform metrics versus application metrics

Azure resources' platform metrics (for example a Service Bus namespace's server errors or a storage account's transactions) live in the Azure Monitor metrics store, where metrics explorer reads them. They reach a Log Analytics workspace, and so KQL and the workspace's retention, only after you create a diagnostic setting on the resource that sends AllMetrics to the workspace. They then land in the AzureMetrics table (Resource, MetricName, Total, Average, Minimum, Maximum, Count, TimeGrain), where they can be joined or unioned with App* data. AppMetrics holds application telemetry (custom and SDK metrics), not platform metrics, and an app can't measure a broker's internal errors itself.

AzureMetrics
| where MetricName == "UsedCapacity"
| summarize arg_max(TimeGenerated, Average) by Resource

arg_max(TimeGenerated, cols) returns, per group, the columns from the same row as the latest timestamp: the latest value per resource. Separate max(TimeGenerated) and max(Average) aggregates can mix values from different rows, and top 1 has no grouping.

One limitation catches people: the diagnostic-setting export flattens dimensions, summing or averaging across them, so per-entity values that metrics explorer can split (such as a Service Bus EntityName) aren't in AzureMetrics, and no diagnostic-setting option adds them. Microsoft's workaround is to read dimensional values from the Azure Monitor Metrics REST API and, if they're needed in the workspace, ingest them with the Logs Ingestion API.

Tip. Questions on this topic are developer scenarios built around Python services that use the Microsoft OpenTelemetry Distro, plus KQL against the workspace App* tables and AzureMetrics. Each stem carries a deciding requirement, such as a constraint on code changes, cost, identity or query accuracy. Several show a code or query snippet with a missing line, argument, operator or join condition. Expect near-twin options: span kinds and the table each lands in, start_as_current_span versus start_span and end_on_exit, on_start versus on_end, OTEL_SERVICE_NAME versus resource attributes, rate-limited versus fixed-percentage samplers and what their argument means, excluded URLs versus span processors versus transformation DCRs, counter versus up-down counter versus histogram versus observable gauge, has versus contains versus =~, count() versus sum(ItemCount), join kinds, Id versus ParentId versus OperationId, and AzureMetrics versus AppMetrics. Troubleshooting items describe a symptom (duplicates, missing info logs, a broken trace, a flat chart, rejected telemetry) and ask for the cause or fix. Some items are select-two.

Key takeaways
  • use_microsoft_opentelemetry(enable_azure_monitor=True) plus the APPLICATIONINSIGHTS_CONNECTION_STRING environment variable is the production setup; never initialize two distros or re-instrument a bundled library.
  • With local authentication disabled, telemetry needs azure_monitor_exporter_credential and the Monitoring Metrics Publisher role on the Application Insights resource itself.
  • Cloud role name = service.namespace + '.' + service.name; OTEL_SERVICE_NAME overrides service.name in OTEL_RESOURCE_ATTRIBUTES, and service.instance.id is the role instance.
  • SERVER and CONSUMER spans become requests; CLIENT, PRODUCER and INTERNAL (the default, InProc) become dependencies.
  • start_as_current_span makes the span current and ends it on exit; span processors enrich every span in on_start, because on_end gets a read-only span.
  • OperationId is the trace ID, ParentId is the direct parent's span ID; use inject/extract when no instrumentation carries traceparent.
  • Escaping exceptions are recorded and set ERROR automatically; handled ones need record_exception plus set_status, and manual recording before a re-raise duplicates unless record_exception=False.
  • The Python default sampler is rate-limited (traces per second); fixed_percentage takes a 0–1 ratio; metrics are never sampled and log sampling by trace is opt-in.
  • Filter HTTP paths with OTEL_PYTHON_EXCLUDED_URLS, other spans with a TraceFlags.DEFAULT processor, and rows at ingestion with a workspace transformation DCR.
  • In KQL, count sampled data with sum(ItemCount), remember join defaults to innerunique, and query platform metrics in AzureMetrics only after a diagnostic setting exports them.

Frequently asked questions

Which Python package should a new app use to send OpenTelemetry data to Application Insights?

Microsoft directs new Python apps to the Microsoft OpenTelemetry Distro, installed with pip install microsoft-opentelemetry and started with use_microsoft_opentelemetry(enable_azure_monitor=True). Apps already using the older Azure Monitor OpenTelemetry Distro (configure_azure_monitor) can keep it, but an app should never initialize both. The distro reads the connection string from APPLICATIONINSIGHTS_CONNECTION_STRING.

How do I make each service show up as its own node on Application Map?

Give each service a distinct cloud role name by setting OTEL_SERVICE_NAME (or service.name in OTEL_RESOURCE_ATTRIBUTES). If service.namespace is also set, the role name becomes namespace.name. Services can keep sharing one Application Insights resource; service.instance.id only distinguishes replicas of the same role.

Why do my custom spans appear as InProc dependencies instead of requests?

A span created without a kind is INTERNAL, which Application Insights records as an InProc dependency. Spans for incoming work should use SpanKind.SERVER (or CONSUMER for message processing) to land in the requests table and the operation-based Performance and Failures views. CLIENT and PRODUCER stay in dependencies.

How do I keep one distributed trace across a message queue that isn't instrumented?

Carry the W3C trace context with the message yourself. The producer calls opentelemetry.propagate.inject on a dict sent with the payload, which adds traceparent and tracestate; the consumer calls extract on that dict and passes the result as the context argument when starting its span. Both sides then share one OperationId in Application Insights.

Why do KQL counts of requests come out lower than the real traffic?

Sampling stores only some items, and each stored row has an ItemCount saying how many original items it represents. count() counts stored rows only, so with sampling it understates traffic, and under the Python distro's default rate-limited sampler it flattens at the cap. Use sum(ItemCount) to estimate the true total.

What is the difference between has and contains in KQL?

has matches a whole term, ignoring case, and can use the term index, so it is fast on large tables. contains matches any substring, also ignoring case, including text inside a longer word, but must scan. The _cs variants are case-sensitive, and == or =~ compare the entire value.

Why does my KQL join return fewer rows than expected?

A join without kind= uses innerunique, which keeps only one left-side row per join key before matching. If several left rows share a key, such as many dependencies in one operation, all but one are dropped. Use kind=inner to keep every matching combination, or leftouter or leftanti when unmatched rows matter.

How do I query Azure platform metrics with KQL?

Create a diagnostic setting on the resource that sends AllMetrics to a Log Analytics workspace; the values then appear in the AzureMetrics table (MetricName, Average, Total, Maximum and so on) and can be joined with Application Insights tables. The export flattens dimensions, so per-entity values need the Metrics REST API, optionally imported with the Logs Ingestion API.

Source

This lesson covers the "Secure, monitor, and troubleshoot Azure solutions" domain of the official AI-200 exam guide. Vendors revise their guides — check the source for the current version.

Sign up free to mark lessons complete, bookmark topics and track your exam readiness.

Spotted a mistake in this lesson?