A KQL query cookbook for Copilot Studio agents

Forty-odd Kusto queries for Copilot Studio telemetry, each with what it returns, when to reach for it, and how to read the result without fooling yourself.

This is the reference half of the Application Insights post. That one covers why the telemetry is shaped the way it is and how to work an incident. This one is the list of queries I actually paste, grouped by the question I am trying to answer rather than by the table they hit.

Two things before any of it will work.

First, know which telemetry scope you have. Agent-level telemetry writes Bot Framework activity events to customEvents. Environment-level telemetry writes OpenTelemetry spans to dependencies. They are not two views of the same rows, and running an environment-level query against an agent-level resource returns nothing at all, which reads exactly like a broken pipeline. Every query below is labelled with the scope it belongs to.

Second, filter out your own testing. Agent-level telemetry logs conversations from the Copilot Studio test canvas alongside real ones, and leaving them in quietly inflates every count you produce.

Finding the one conversation someone complained about

Almost every investigation starts here, and the identifier you need is gen_ai.conversation.id at environment level or session_Id at agent level.

The everyday entry point. You know the agent, you want its most recent conversation, and you want the spans ordered with each turn’s root before its children.

latest-conversation.kqlEnvironment-level
let Window = 7d;
let AgentName = "Purchasing Assistant";
let LatestConvo = toscalar(
    dependencies
    | where timestamp > ago(Window)
    | where tostring(customDimensions["gen_ai.agent.name"]) == AgentName
    | where isnotempty(tostring(customDimensions["gen_ai.conversation.id"]))
    | top 1 by timestamp desc
    | project tostring(customDimensions["gen_ai.conversation.id"])
);
dependencies
| where timestamp > ago(Window)
| where tostring(customDimensions["gen_ai.conversation.id"]) == LatestConvo
| order by operation_Id asc, iff(name == "InvokeAgent", 0, 1) asc, timestamp asc
| project timestamp, name, id, operation_Id, operation_ParentId,
          duration, resultCode, customDimensions

Read it as a story. Each operation_Id block is one turn, the InvokeAgent row opens it, each ExecuteTool row is a tool the agent chose, and OutputMessages is what the user saw.

When you already have an identifier from a ticket, skip the lookup.

trace-by-conversation.kqlEnvironment-level
let Convo = "<paste the conversation ID>";
dependencies
| where tostring(customDimensions["gen_ai.conversation.id"]) == Convo
| order by operation_Id asc, iff(name == "InvokeAgent", 0, 1) asc, timestamp asc
| project timestamp, name, id, operation_Id, operation_ParentId,
          duration, resultCode, customDimensions

Raw customDimensions becomes unreadable after about three rows. This pulls the fields worth looking at into real columns, which makes the grid sortable and lets you scan a whole conversation at a glance.

flatten-genai-fields.kqlEnvironment-level
let Convo = "<paste the conversation ID>";
dependencies
| where tostring(customDimensions["gen_ai.conversation.id"]) == Convo
| extend
    Operation  = tostring(customDimensions["gen_ai.operation.name"]),
    Agent      = tostring(customDimensions["gen_ai.agent.name"]),
    Model      = tostring(customDimensions["gen_ai.request.model"]),
    Tool       = tostring(customDimensions["gen_ai.tool.name"]),
    ToolType   = tostring(customDimensions["gen_ai.tool.type"]),
    Args       = tostring(customDimensions["gen_ai.tool.call.arguments"]),
    Result     = tostring(customDimensions["gen_ai.tool.call.result"]),
    Channel    = tostring(customDimensions["microsoft.channel.name"])
| extend
    UserInput   = tostring(parse_json(tostring(customDimensions["gen_ai.input.messages"]))[0].parts[0].content),
    AgentOutput = tostring(parse_json(tostring(customDimensions["gen_ai.output.messages"]))[0].parts[0].content)
| order by operation_Id asc, iff(name == "InvokeAgent", 0, 1) asc, timestamp asc
| project timestamp, name, Operation, Agent, Model, Tool, ToolType,
          Args, Result, UserInput, AgentOutput, duration, Channel

Sometimes you have a person and a rough time rather than an identifier. This finds their conversations in a window and gives you the identifier to feed the queries above.

conversations-by-user.kqlEnvironment-level
dependencies
| where timestamp between (datetime(2026-08-04 12:00) .. datetime(2026-08-04 18:00))
| extend
    UserEmail    = tostring(customDimensions["user.email"]),
    Conversation = tostring(customDimensions["gen_ai.conversation.id"])
| where UserEmail =~ "[email protected]"
| summarize turns = dcount(operation_Id),
            started = min(timestamp),
            ended = max(timestamp)
    by Conversation
| order by started desc

user.email is only populated when the channel authenticates the user, so on anonymous web chat this returns nothing and you fall back to time and channel.

A conversation spans many turns, and when an agent calls another agent as a tool the sub-agent inherits the parent identifier with a _<subConversationId> suffix. Matching on the root portion reconstructs the whole tree.

sub-agent-tree.kqlEnvironment-level
let Window = 7d;
let RootConvo = "<paste the root conversation ID>";
dependencies
| where timestamp > ago(Window)
| extend Conversation = tostring(customDimensions["gen_ai.conversation.id"])
| where Conversation == RootConvo or Conversation startswith strcat(RootConvo, "_")
| extend
    Role  = iff(Conversation == RootConvo, "root", "sub-agent"),
    Depth = countof(Conversation, "_"),
    Agent = tostring(customDimensions["gen_ai.agent.name"]),
    Tool  = tostring(customDimensions["gen_ai.tool.name"])
| order by timestamp asc
| project timestamp, Role, Depth, Agent, name, Tool, duration, operation_Id

The agent-level equivalent of finding a conversation. You have a name and a rough time, and you want the session identifier.

find-session.kqlAgent-level
customEvents
| where timestamp between (datetime(2026-08-04 12:00) .. datetime(2026-08-04 18:00))
| extend
    UserName = tostring(customDimensions["fromName"]),
    Channel  = tostring(customDimensions["channelId"]),
    Text     = tostring(customDimensions["text"])
| where UserName has "A. User"
| project timestamp, name, Channel, Text, session_Id
| order by timestamp asc

Then the whole event stream for that session, in order. This is the closest agent-level telemetry gets to a transcript.

session-event-stream.kqlAgent-level
let Session = "<paste the session ID>";
customEvents
| where session_Id == Session
| extend
    Topic = tostring(customDimensions["TopicName"]),
    Kind  = tostring(customDimensions["Kind"]),
    Text  = tostring(customDimensions["text"])
| project timestamp, name, Topic, Kind, Text
| order by timestamp asc

The same stream with the gap between consecutive events, which is how you find the step the user actually waited on. Long gaps usually sit in front of a connector call.

session-gaps.kqlAgent-level
let Session = "<paste the session ID>";
customEvents
| where session_Id == Session
| order by timestamp asc
| extend GapMs = iff(isnull(prev(timestamp)), 0.0, (timestamp - prev(timestamp)) / 1ms)
| project timestamp, name, GapMs,
          Topic = tostring(customDimensions["TopicName"]),
          Kind  = tostring(customDimensions["Kind"])

Keeping your own testing out of the numbers

Agent-level telemetry records test-canvas conversations too. This is the filter that belongs on the front of every agent-level aggregate you write.

exclude-test-canvas.kqlAgent-level
customEvents
| where timestamp > ago(7d)
| extend isDesignMode = tostring(customDimensions["designMode"])
| where isDesignMode == "False"

Microsoft’s own documentation disagrees with itself about the casing of that key. The Copilot Studio page uses designMode, the Dynamics 365 guidance uses DesignMode. KQL bag lookups are case-sensitive and a miss returns zero rows rather than an error, so a filter with the wrong casing silently keeps everything. Check yours once and write it down.

check-key-casing.kqlAgent-level
customEvents
| where timestamp > ago(1d)
| mv-expand Key = bag_keys(customDimensions) to typeof(string)
| where Key contains "designmode"
| distinct Key

Tool calls are where agents actually break

Generative orchestration means the interesting failures are tool calls, not topics. These all run against ExecuteTool spans.

The one to pin first. It finds failures using the fields that are actually populated, and groups them so you see a pattern rather than one unlucky conversation.

failed-tool-calls.kqlEnvironment-level
dependencies
| where timestamp > ago(24h)
| where name == "ExecuteTool"
| extend
    Tool       = tostring(customDimensions["gen_ai.tool.name"]),
    StatusCode = tostring(customDimensions["Status.code"]),
    ErrorType  = tostring(customDimensions["error.type"]),
    Detail     = tostring(customDimensions["Status.message"])
| where StatusCode == "2" or isnotempty(ErrorType)
| summarize failures = count(), example = take_any(Detail) by Tool, ErrorType
| order by failures desc

Status.code of 2 is the OpenTelemetry error status. A large count against a single ErrorType of 401 or 403 is a credential or consent problem rather than a bug in the tool.

Volume next to failures, so you can tell a tool that fails constantly from one that fails twice out of forty thousand calls.

tool-reliability.kqlEnvironment-level
dependencies
| where timestamp > ago(7d)
| where name == "ExecuteTool"
| extend
    Tool   = tostring(customDimensions["gen_ai.tool.name"]),
    Type   = tostring(customDimensions["gen_ai.tool.type"]),
    Failed = tostring(customDimensions["Status.code"]) == "2"
             or isnotempty(tostring(customDimensions["error.type"]))
| summarize
    calls       = count(),
    failures    = countif(Failed),
    failureRate = round(100.0 * countif(Failed) / count(), 2),
    p95Ms       = percentile(duration, 95)
  by Tool, Type
| order by failureRate desc

The same failure rate over time, which is how you tell a regression from a background rate. A step change on one day is a deployment or a credential expiry.

tool-failure-trend.kqlEnvironment-level
dependencies
| where timestamp > ago(14d)
| where name == "ExecuteTool"
| extend
    Tool   = tostring(customDimensions["gen_ai.tool.name"]),
    Failed = tostring(customDimensions["Status.code"]) == "2"
             or isnotempty(tostring(customDimensions["error.type"]))
| summarize failureRate = 100.0 * countif(Failed) / count()
    by Tool, bin(timestamp, 1d)
| render timechart

Tools that succeed and return nothing are the cause of a whole class of confident wrong answers. The call worked, the payload was empty, and the model narrated around the hole.

tools-returning-nothing.kqlEnvironment-level
dependencies
| where timestamp > ago(24h)
| where name == "ExecuteTool"
| extend
    Tool   = tostring(customDimensions["gen_ai.tool.name"]),
    Result = tostring(customDimensions["gen_ai.tool.call.result"])
| where isempty(Result) or Result in ("[]", "{}", "null", "\"\"")
| summarize empties = count(), sample = take_any(Result) by Tool
| order by empties desc

When you suspect the agent is calling a tool with the wrong arguments, read the arguments. This surfaces the slowest calls with their payloads attached, which is usually where malformed input shows up.

slowest-tool-calls.kqlEnvironment-level
dependencies
| where timestamp > ago(24h)
| where name == "ExecuteTool"
| extend
    Tool = tostring(customDimensions["gen_ai.tool.name"]),
    Args = tostring(customDimensions["gen_ai.tool.call.arguments"])
| top 25 by duration desc
| project timestamp, Tool, duration, Args, operation_Id

Which tools the agent reaches for at all. Run it after changing instructions: a tool that drops out of this list is one the model stopped choosing, which is a behaviour change that no error will report.

tool-selection-mix.kqlEnvironment-level
dependencies
| where timestamp > ago(7d)
| where name == "ExecuteTool"
| extend Tool = tostring(customDimensions["gen_ai.tool.name"])
| summarize calls = count(), conversations = dcount(tostring(customDimensions["gen_ai.conversation.id"]))
    by Tool
| order by calls desc

Latency questions need percentiles, not averages

An average hides the tail, and the tail is what users complain about.

Percentiles by span and tool. This is the query that tells you which specific thing is slow, rather than that the agent is slow.

latency-percentiles.kqlEnvironment-level
dependencies
| where timestamp > ago(24h)
| where name in ("InvokeAgent", "ExecuteTool")
| extend Tool = tostring(customDimensions["gen_ai.tool.name"])
| summarize
    calls = count(),
    p50 = percentile(duration, 50),
    p95 = percentile(duration, 95),
    p99 = percentile(duration, 99)
  by name, Tool
| order by p95 desc

Look at the distance between p50 and p95 rather than at p50 alone. A tool with a 400 ms median and an eleven-second p95 is a timeout wearing a disguise, and it reaches you as “the agent is sometimes slow”.

Where the time inside a turn actually goes. This splits each turn into time spent in tools and time spent everywhere else, which settles the argument about whether the model or the connector is responsible.

turn-time-split.kqlEnvironment-level
dependencies
| where timestamp > ago(24h)
| where name in ("InvokeAgent", "ExecuteTool")
| summarize
    turnMs = sumif(duration, name == "InvokeAgent"),
    toolMs = sumif(duration, name == "ExecuteTool"),
    tools  = countif(name == "ExecuteTool")
  by operation_Id
| where turnMs > 0
| extend agentMs = turnMs - toolMs
| summarize
    turns    = count(),
    p50Turn  = percentile(turnMs, 50),
    p95Turn  = percentile(turnMs, 95),
    p95Tool  = percentile(toolMs, 95),
    p95Agent = percentile(agentMs, 95),
    avgTools = round(avg(tools), 2)

The worst individual turns, with their conversation identifiers so you can go and read them.

slowest-turns.kqlEnvironment-level
dependencies
| where timestamp > ago(24h)
| where name == "InvokeAgent"
| extend
    Agent        = tostring(customDimensions["gen_ai.agent.name"]),
    Conversation = tostring(customDimensions["gen_ai.conversation.id"])
| top 25 by duration desc
| project timestamp, Agent, Conversation, operation_Id, duration, performanceBucket

Application Insights buckets durations for you, and the shape of that distribution is often a faster read than a percentile table.

performance-buckets.kqlEnvironment-level
dependencies
| where timestamp > ago(7d)
| where name == "InvokeAgent"
| summarize turns = count() by performanceBucket
| order by turns asc
| render barchart

Latency by day, to catch the slow drift that no alert fires on.

latency-trend.kqlEnvironment-level
dependencies
| where timestamp > ago(30d)
| where name == "InvokeAgent"
| summarize p50 = percentile(duration, 50), p95 = percentile(duration, 95)
    by bin(timestamp, 1d)
| render timechart

Model mix and what each model costs you in time. Useful after a model change, and the only easy way to notice that half your traffic is on something you did not intend.

model-mix.kqlEnvironment-level
dependencies
| where timestamp > ago(7d)
| where name == "InvokeAgent"
| extend Model = tostring(customDimensions["gen_ai.request.model"])
| summarize turns = count(), p50 = percentile(duration, 50), p95 = percentile(duration, 95)
    by Model
| order by turns desc

Errors hide from the columns you would expect

Turns that started and never produced a reply. These are the silent failures, and nothing in the error tables will show them to you.

turns-with-no-reply.kqlEnvironment-level
dependencies
| where timestamp > ago(24h)
| summarize
    invoked      = countif(name == "InvokeAgent"),
    tools        = countif(name == "ExecuteTool"),
    replies      = countif(name == "OutputMessages"),
    Conversation = take_any(tostring(customDimensions["gen_ai.conversation.id"])),
    started      = min(timestamp)
  by operation_Id
| where invoked > 0 and replies == 0
| order by started desc
| project started, Conversation, operation_Id, tools

Everything carrying an error status, grouped by the message, so recurring failures rise to the top.

all-error-spans.kqlEnvironment-level
dependencies
| where timestamp > ago(24h)
| extend
    StatusCode = tostring(customDimensions["Status.code"]),
    ErrorType  = tostring(customDimensions["error.type"]),
    Detail     = tostring(customDimensions["Status.message"]),
    Agent      = tostring(customDimensions["gen_ai.agent.name"])
| where StatusCode == "2" or isnotempty(ErrorType)
| summarize occurrences = count(), lastSeen = max(timestamp)
    by Agent, name, ErrorType, Detail
| order by occurrences desc

Responsible AI filtering. When a user says the agent refused to answer something innocuous, this is where the evidence is.

content-filtered.kqlAgent-level
customEvents
| where timestamp > ago(30d)
| where customDimensions contains "ContentFiltered"
| project timestamp, name, session_Id, user_Id, cloud_RoleInstance, customDimensions
| order by timestamp desc

Copilot Studio error codes are embedded in message text rather than exposed as a field, so counting them means pulling the substring out.

top-error-codes.kqlAgent-level
customEvents
| where timestamp > ago(7d)
| where name == "BotMessageSend"
| extend Text = tostring(customDimensions["text"])
| where Text contains "Error code:"
| extend
    errorStart = indexof(Text, "Error code:") + strlen("Error code:"),
    convoStart = indexof(Text, "Conversation ID:")
| extend ErrorCode = trim(" ", substring(Text, errorStart, convoStart - errorStart))
| summarize occurrences = count() by ErrorCode
| order by occurrences desc
| render columnchart

The exceptions table, which catches thrown errors that never made it into a span.

exceptions.kqlAny scope
exceptions
| where timestamp > ago(7d)
| summarize occurrences = count(), lastSeen = max(timestamp)
    by type, outerMessage, problemId
| order by occurrences desc
| take 25

Generative answers and how they turned out. The Result field separates an answer the agent produced from one it declined to, which is the single most useful quality signal available at agent level.

generative-answers.kqlAgent-level
customEvents
| where timestamp > ago(7d)
| where name == "GenerativeAnswers"
| extend cd = todynamic(customDimensions)
| extend
    Conversation = tostring(cd.conversationId),
    Topic        = tostring(cd.TopicName),
    Result       = tostring(cd.Result),
    Summary      = tostring(cd.Summary)
| summarize answers = count() by Result
| order by answers desc

The same events unrolled, when you want to read the ones that failed rather than count them.

generative-answer-detail.kqlAgent-level
customEvents
| where timestamp > ago(7d)
| where name == "GenerativeAnswers"
| extend cd = todynamic(customDimensions)
| extend
    Conversation = tostring(cd.conversationId),
    Topic        = tostring(cd.TopicName),
    Message      = tostring(cd.Message),
    Result       = tostring(cd.Result)
| where Result != "Success"
| project timestamp, Conversation, Topic, Message, Result
| order by timestamp desc

Usage answers a different question from health

These are the queries for the monthly review rather than the incident.

Sessions and messages per day, the standard adoption chart.

sessions-per-day.kqlAgent-level
requests
| where timestamp > ago(30d)
| summarize sessions = dcount(session_Id), messages = count()
    by bin(timestamp, 1d)
| render timechart

Distinct users per day. Worth reading with the caveat attached: the count is only meaningful when users authenticate, because anonymous channels mint a fresh identifier per conversation and inflate it.

distinct-users.kqlAgent-level
customEvents
| where timestamp > ago(14d)
| where tostring(customDimensions["designMode"]) == "False"
| summarize users = dcount(user_Id) by bin(timestamp, 1d)
| render timechart

Where your traffic comes from. A channel with high volume and poor outcomes is usually a surface nobody tested.

channel-breakdown.kqlAgent-level
customEvents
| where timestamp > ago(30d)
| extend Channel = tostring(customDimensions["channelId"])
| where isnotempty(Channel)
| summarize sessions = dcount(session_Id) by Channel
| order by sessions desc

Which topics fire, and how often. The tail of this list is where topics that nobody triggers live.

top-topics.kqlAgent-level
customEvents
| where timestamp > ago(7d)
| where name == "TopicStart"
| where tostring(customDimensions["designMode"]) == "False"
| extend Topic = tostring(customDimensions["TopicName"])
| summarize starts = count(), sessions = dcount(session_Id) by Topic
| order by starts desc

Peak hours, for capacity questions and for choosing a deployment window.

busiest-hours.kqlAgent-level
customEvents
| where timestamp > ago(30d)
| extend Hour = datetime_part("hour", timestamp)
| summarize messages = count() by Hour
| order by Hour asc
| render columnchart

How deep conversations go. A p95 of two turns means people ask once and leave, which is a different problem from a p95 of thirty.

conversation-depth.kqlEnvironment-level
dependencies
| where timestamp > ago(7d)
| extend Conversation = tostring(customDimensions["gen_ai.conversation.id"])
| where isnotempty(Conversation)
| summarize turns = dcount(operation_Id) by Conversation
| summarize
    conversations = count(),
    p50 = percentile(turns, 50),
    p95 = percentile(turns, 95),
    longest = max(turns)

A leaderboard across every agent in the environment, which is the view a platform team wants and no single-agent dashboard gives.

agent-leaderboard.kqlEnvironment-level
dependencies
| where timestamp > ago(7d)
| extend
    Agent  = tostring(customDimensions["gen_ai.agent.name"]),
    Failed = tostring(customDimensions["Status.code"]) == "2"
| where isnotempty(Agent)
| summarize
    turns         = countif(name == "InvokeAgent"),
    toolCalls     = countif(name == "ExecuteTool"),
    errors        = countif(Failed),
    conversations = dcount(tostring(customDimensions["gen_ai.conversation.id"])),
    p95Ms         = percentile(duration, 95)
  by Agent
| order by turns desc

Roughly what each table is costing you to ingest. Row counts are a proxy rather than a bill, but they are enough to notice that node execution events are three quarters of your volume.

ingestion-by-table.kqlAny scope
union withsource = SourceTable *
| where timestamp > ago(7d)
| summarize rows = count() by SourceTable, bin(timestamp, 1d)
| render timechart

Voice agents write somewhere else entirely

Real-time voice telemetry does not go to dependencies or customEvents. It goes to traces, keyed on customDimensions.Subject, and the top-level message field is a placeholder you should ignore.

Every row from one call, in order.

voice-one-call.kqlVoice
let Corr = "<paste the correlationId>";
traces
| extend cl = parse_json(tostring(customDimensions.CallLifecycle))
| where tostring(cl.TrackingContext.CorrelationId) == Corr
| project
    timestamp,
    Subject   = tostring(customDimensions.Subject),
    EventType = tostring(cl.EventType)
| order by timestamp asc

Caller-perceived latency by model. Duration_Ms on an LlmInvocation row is time to first audio back, measured from the moment the caller stopped speaking, and a single turn can produce several rows that all anchor to the same moment. Taking the maximum per turn is what makes the number mean anything.

voice-ttfab.kqlVoice
traces
| where timestamp > ago(24h)
| where tostring(customDimensions.Subject) == "LlmInvocation"
| extend cl = parse_json(tostring(customDimensions.CallLifecycle))
| extend
    Call  = tostring(cl.TrackingContext.CorrelationId),
    Ms    = toint(customDimensions.Duration_Ms),
    Model = tostring(customDimensions.Model)
| summarize ttfabMs = max(Ms) by Call, Model, bin(timestamp, 30s)
| summarize p50 = percentile(ttfabMs, 50), p95 = percentile(ttfabMs, 95), turns = count()
    by Model

The 30-second bin is standing in for a turn boundary, which the schema does not mark explicitly. Tighten it if your calls are fast and conversational, loosen it if they are not.

Token usage per call, which is the closest thing to a cost signal in this table.

voice-tokens.kqlVoice
traces
| where timestamp > ago(7d)
| where tostring(customDimensions.Subject) == "LlmInvocation"
| extend cl = parse_json(tostring(customDimensions.CallLifecycle))
| extend
    Call   = tostring(cl.TrackingContext.CorrelationId),
    InTok  = toint(customDimensions.InputTokens),
    OutTok = toint(customDimensions.OutputTokens)
| summarize replies = count(), inputTokens = sum(InTok), outputTokens = sum(OutTok)
    by Call
| summarize
    calls          = count(),
    medianIn       = percentile(inputTokens, 50),
    medianOut      = percentile(outputTokens, 50),
    p95In          = percentile(inputTokens, 95)

Input tokens climb through a call as history accumulates, so the p95 tells you what a long call costs rather than a typical one.

Tool execution on the voice side, where the platform and external portions of a call are reported separately.

voice-tool-execution.kqlVoice
traces
| where timestamp > ago(24h)
| where tostring(customDimensions.Subject) == "ToolExecution"
| extend
    Tool       = tostring(customDimensions.ToolName),
    Result     = tostring(customDimensions.ToolResult),
    TotalMs    = toint(customDimensions.TotalDuration_Ms),
    PlatformMs = toint(customDimensions.McsProcessingTime_Ms),
    ExternalMs = toint(customDimensions.ExternalCallTime_Ms)
| summarize
    calls       = count(),
    failures    = countif(Result == "Failed"),
    p95Total    = percentile(TotalMs, 95),
    p95Platform = percentile(PlatformMs, 95),
    p95External = percentile(ExternalMs, 95)
  by Tool
| order by failures desc

How calls end, and how long they ran. A rise in one end reason is usually the first sign of something worth investigating.

voice-call-outcomes.kqlVoice
traces
| where timestamp > ago(7d)
| where tostring(customDimensions.Subject) == "DialogLifecycle"
| extend
    EndReason = tostring(customDimensions.EndReason),
    Ms        = toint(customDimensions.DialogDuration_Ms)
| summarize calls = count(), medianMs = percentile(Ms, 50), p95Ms = percentile(Ms, 95)
    by EndReason
| order by calls desc

The queries that keep working when the schema moves

Environment-level telemetry is in preview and its field list will change. These two are worth more than any field reference, including the one in this post.

The native columns, straight from the table.

table-schema.kqlAny scope
dependencies
| getschema
| project ColumnName, ColumnType
| order by ColumnName asc

Every key inside customDimensions, which events it appears on, and a sample value. Because it reads the data rather than a document, it stays correct as new gen_ai.* attributes appear.

discover-custom-dimensions.kqlAny scope
dependencies
| where timestamp > ago(7d)
| mv-expand Key = bag_keys(customDimensions) to typeof(string)
| summarize Events = make_set(name), Sample = take_any(tostring(customDimensions[Key]))
    by Key
| order by Key asc

Run that before you trust any field list. It has caught more of my broken queries than anything else here.

Last, the pattern that saves the most typing. Save the extend block once as a function in the workspace and every later query starts from readable columns.

AgentTurns.kqlSave as a function
// Save this in the workspace as a function named AgentTurns.
// Then query it directly:  AgentTurns | where Agent == "Purchasing Assistant"
dependencies
| extend
    Agent        = tostring(customDimensions["gen_ai.agent.name"]),
    Conversation = tostring(customDimensions["gen_ai.conversation.id"]),
    Operation    = tostring(customDimensions["gen_ai.operation.name"]),
    Tool         = tostring(customDimensions["gen_ai.tool.name"]),
    Model        = tostring(customDimensions["gen_ai.request.model"]),
    Channel      = tostring(customDimensions["microsoft.channel.name"]),
    StatusCode   = tostring(customDimensions["Status.code"]),
    ErrorType    = tostring(customDimensions["error.type"]),
    Failed       = tostring(customDimensions["Status.code"]) == "2"
                   or isnotempty(tostring(customDimensions["error.type"]))

What I keep pinned

Four of these live in my saved queries and the rest I look up: latest conversation, trace by conversation ID, failed tool calls, and latency percentiles. That set covers the first ten minutes of nearly every investigation, and the specific ones get rebuilt from the schema discovery query when I need them.

The compromise is the AgentTurns function. It makes every query shorter and it hides which raw key each column came from, so when the preview schema shifts underneath you the failure looks like a column full of empty strings rather than an obvious mistake. I still use it. Just check the function first when a query starts returning blanks.1

Notes

  1. Field names, span names, event names and the known limitations behind these queries were checked on 5 August 2026, against agent-level telemetry, environment-level telemetry in preview, and real-time voice telemetry. Environment-level telemetry is the fastest moving of the three. Where a query here disagrees with your data, trust the schema discovery query rather than this post.

ESC
Move OpenT Theme