Azure Observability

KQL That Earns Its Keep: summarize, join, make-series and Time-Bucketing Azure Logs

You can write the App Insights queries that find a slow endpoint, but the moment someone asks “show me failed requests per minute, per region, with a 7-day baseline,” the query falls apart. You reach for summarize, get ragged time gaps, try to join in the baseline and the row count explodes, and twenty minutes later you have a screenshot of something almost right. KQL — the Kusto Query Language that powers Azure Log Analytics, Application Insights, Azure Resource Graph, Microsoft Sentinel and Azure Data Explorer — is easy to start and deep once you push it. The gap between “I can filter logs” and “I can turn logs into answers” is four or five operators, and this article is about exactly those.

The four that earn their keep are summarize (collapse many rows into aggregates per group), bin() (snap timestamps into fixed buckets so “per minute” actually means per minute), join (correlate two tables — choosing the right one of five flavours so you don’t silently drop or duplicate rows), and make-series (a dense, gap-filled time series ready to chart or feed an anomaly function). Around them sits the glue: extend, project, parse/extract, mv-expand and materialize. Master that set and most “I need a dashboard / an alert / a root-cause query” tasks become a ten-line query you write in one pass.

This is a concept-and-pattern guide, not a syntax dump. We lead with the mental model — KQL is a left-to-right pipeline where each | takes a table in and emits a table out, and where you filter early and aggregate late because the engine scans columnar, time-partitioned data. Then we go operator by operator with decision tables (which join kind, which aggregation function, which time grain), walk an end-to-end observability query, and close with the cost and performance levers. Everything here runs in the Log Analytics query editor on the demo data or your own workspace; bring a workspace and follow along.

What problem this solves

Logs arrive as a firehose of individual events — one row per request, per heartbeat, per sign-in, per syslog line — and a single busy workspace ingests millions of rows an hour. A human cannot read that. The entire value of a query language over logs is reduction: turning ten million rows into the twenty numbers that answer “is it healthy, what changed, who is affected, and is this normal.” summarize is the reduction engine; bin() makes the reduction time-aware; join lets the reduction span more than one signal; make-series makes the reduction chart-ready and statistically tractable. Without fluency in these, you are stuck at “filter and eyeball,” which does not survive contact with production volume.

What breaks without this skill is subtle and expensive. People paste a requests | where success == false into a dashboard tile and wonder why the chart is jagged and the time axis lies — because they never bucketed. They join an exceptions table to a requests table with the default kind=innerunique and quietly lose duplicate matches, so “errors per request” is wrong and nobody notices for a quarter. Or they write a query that scans traces over 30 days with no time filter, the editor spins, and they conclude “Log Analytics is slow” when in fact they handed the engine an unbounded scan. Each of these is a query-shape problem, not a platform problem, fixed by understanding what these operators do and what they cost.

Who hits this: anyone who has graduated from “App Insights gave me a chart” to “I need a specific answer the canned charts don’t give.” SREs building alert queries (a Log Analytics alert is a KQL query on a schedule), platform engineers building workbooks, security analysts hunting in Sentinel, FinOps folks slicing usage. The skill is portable — the same summarize/bin/join/make-series you learn against AppRequests works against SigninLogs, AzureDiagnostics, Heartbeat, syslog, custom tables, and Resource Graph.

The small set of operators that does most of the heavy lifting, and the job each owns:

Operator One-line job The question it answers Cheap or expensive
where Filter rows “Which rows do I even care about?” Cheapest — do it first
summarize Aggregate into groups “How many / how fast / how much, per X?” Moderate; scales with input rows
bin() Snap time into buckets “Per minute / hour / day, exactly?” Free (used inside summarize)
extend Add a computed column “What’s the derived value per row?” Cheap
project Pick/rename output columns “What shape do I want out?” Cheap; trims width
join Correlate two tables “How do these two signals relate?” Expensive — pick the right kind
make-series Dense, gap-filled time series “Give me a chartable curve with no holes” Moderate; great for anomalies
parse / extract Pull fields from strings “Get the value out of this messy text” Cheap-ish; avoid on huge scans
materialize() Cache a subquery once “Stop recomputing this five times” Saves cost when reused

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already have a Log Analytics workspace with data in it (App Insights telemetry, a VM sending Heartbeat/Perf, activity logs, or the demo data) and know how to open the Logs blade and run a query. If you are still standing one up, How to Create a Log Analytics Workspace: Region, Access Modes and Resource-Context RBAC covers the workspace, and The Azure Monitor Data Platform Explained: Metrics, Logs, Traces and Where Each Lives covers what lands in logs versus metrics. You need no prior Kusto; we build from the pipeline up. SQL familiarity (GROUP BY, JOIN) helps, though KQL’s join semantics differ in ways this article makes explicit.

This sits in the Observability track — the language layer you use after you understand where data lives and before you build alerts, workbooks and dashboards. It pairs tightly with Metrics vs Log Analytics: Choosing the Right Store for Speed, Cost and Retention — KQL applies only to the logs store, and knowing when a question belongs to metrics saves you from forcing log queries to do a metric’s job. If queries return nothing at all, that is an ingestion problem, not a KQL problem; see No Logs Showing Up? Troubleshooting Empty Log Analytics Tables and Ingestion Gaps. The same language drives control-plane inventory in Azure Resource Graph Cookbook: KQL Queries for Inventory and Drift at Scale, so most of what you learn here transfers there.

The surfaces that run KQL — the language is shared, but the data and limits differ:

Surface What you query Time column convention Notable difference
Log Analytics (Logs blade) Workspace tables (Heartbeat, Perf, AzureDiagnostics, custom) TimeGenerated The canonical environment; full operator set
Application Insights Telemetry tables (requests, dependencies, exceptions, …) timestamp Workspace-based AI surfaces same data as AppRequests etc.
Azure Resource Graph ARM resource inventory (Resources, ResourceContainers) none (current state, not time-series) Subset of operators; no make-series; paged results
Microsoft Sentinel Security tables (SigninLogs, SecurityEvent, …) TimeGenerated Adds analytics-rules and hunting semantics on top of KQL
Azure Data Explorer (ADX) Your own ADX databases datetime columns The full Kusto engine; superset; can be cross-queried

Write your query in terms of TimeGenerated/timestamp and the standard operators, and it ports across these with minor column-name changes.

Core concepts

Five mental models make every later pattern obvious.

KQL is a pipeline, read left to right. A query is a source table followed by operators joined by the pipe |. Each operator receives a table and emits a table. AppRequests | where Success == false | summarize count() by Name reads literally as “take AppRequests, keep the failures, count them per operation name.” You compose the transformation step by step, and the shape at any point is just “whatever the last operator emitted” — so you can always inspect the table at every stage by truncating the query.

Filter early, aggregate late — because the data is columnar and time-partitioned. Log Analytics stores data in compressed, columnar shards partitioned by ingestion time. A where TimeGenerated > ago(1h) lets the engine skip every shard outside that window — it never reads them. Every row you eliminate before a summarize or join is a row those expensive operators never touch. The canonical good shape: narrow the time window → filter rows → project away unneeded columns → THEN summarize/join. This rule alone separates a 300-ms query from a multi-second one.

summarize collapses rows into groups; the by clause defines the groups. summarize <aggregations> by <grouping keys> produces one output row per distinct combination of the grouping keys. No by means one group — the whole table. The grouping keys can be columns or expressions, and the killer expression is bin(TimeGenerated, 1m), which rounds each timestamp down to its minute so “group by minute” works — demystifying aggregation and time-bucketing at once.

join correlates two pipelines, and the kind decides what happens to unmatched and duplicate rows. LeftTable | join kind=<kind> (RightTable) on <key> matches rows by key. The trap that bites everyone: KQL’s default join is innerunique, which first de-duplicates the left table on the join key, so it is not the inner join you know from SQL. The flavours differ precisely in whether unmatched rows survive and whether duplicates multiply — the difference between a correct answer and one silently 30% off. Full decision table below.

make-series is the dense cousin of summarize for time. summarize count() by bin(TimeGenerated, 1m) only emits rows for minutes that had data — quiet minutes are missing, so a chart connects across holes and lies about gaps. make-series instead produces a value for every bucket in the range, filling empties with a default, returning arrays ready to render or feed to anomaly/forecast functions. When you need a real, evenly-spaced curve, you want make-series, not summarize.

The pipeline and the cardinal rule

Everything starts with reading a query as a flow. Read this one aloud, stage by stage:

AppRequests
| where TimeGenerated > ago(1h)        // 1. narrow the time window (shard pruning)
| where Success == false               // 2. keep only failures
| project TimeGenerated, Name, ResultCode, DurationMs, OperationId  // 3. drop unused columns
| summarize failures = count(),        // 4. aggregate, late
            p95 = percentile(DurationMs, 95)
        by Name, bin(TimeGenerated, 5m)
| order by failures desc                // 5. sort the small result

Stage by stage the table shrinks: the time filter discards most shards, the success filter discards most surviving rows, the projection narrows the width, and only then does summarize group what’s left. By the time order by runs it sorts a handful of rows. That is the shape you want every query to have.

The ordering is not stylistic — it changes how much data the engine touches. This table makes the rule concrete with the why for each step:

Step Do it Why it’s cheap-first What it costs if done late
Time filter (where TimeGenerated > ago(...)) First, always Prunes whole time-partitioned shards from the scan Without it, unbounded scan over full retention
Row filters (where col == ...) Right after time Discards rows before any heavy op sees them Aggregating then filtering processes everything
project / project-away Before summarize/join Narrows column width moved through the pipeline Wide rows carried needlessly through joins
extend (computed cols) Just before you need them Compute only on rows that survived filters Computing on rows you’ll discard
summarize / make-series After filtering/projection Operates on the smallest possible input Grouping millions instead of thousands
join After both sides are filtered/projected Smaller inputs → smaller intermediate result Joining raw tables explodes intermediates
order / top / render Last Sorts/limits the already-tiny result Sorting large intermediates wastes work

The two shaping operators beginners conflate, distinguished:

Operator What it does Keeps other columns? Typical use
extend Adds new computed columns Yes — all originals stay extend durSec = DurationMs / 1000.0
project Selects an explicit column set (can compute too) No — only what you list Final output shaping
project-away Removes named columns, keeps the rest Yes minus the named Drop a few noisy columns
project-rename Renames columns Yes project-rename op = Name
project-keep Keep only columns matching names/patterns No Keep a wildcard set

A useful debugging habit: truncate the query after any stage to see the intermediate table. Append | take 10 to sample, or | getschema to see the columns and types the pipeline emits — you never guess about shape, you look.

summarize: the reduction engine

summarize is where logs become answers. It is two lists: the aggregations (what to compute) and, after by, the grouping keys (what to compute them per). The output has one row per distinct grouping-key combination, one column per aggregation plus the keys.

AppRequests
| where TimeGenerated > ago(24h)
| summarize
    total      = count(),
    failed     = countif(Success == false),
    p50        = percentile(DurationMs, 50),
    p95        = percentile(DurationMs, 95),
    p99        = percentile(DurationMs, 99),
    users      = dcount(UserId)
  by Name
| extend failRate = round(100.0 * failed / total, 2)
| order by failed desc

That one query gives you, per operation, the volume, failure count, latency distribution, distinct users, and a derived failure-rate — the core of an endpoint health view. Note the pattern: aggregate first, then extend the ratio from the aggregated columns, since failRate only makes sense after count() and countif() exist.

Choosing the aggregation function

The function decides what you compute per group. Picking the wrong one is a quiet error — count() when you meant dcount(), avg() when a percentile() would tell the truth about latency. The menu you actually use:

Function Computes Use it for Gotcha
count() Number of rows in the group Volume, request counts Counts rows, not distinct values
countif(predicate) Rows where predicate is true Failures, matches, conditional counts Cleaner than a where + separate count
dcount(col) Approx. distinct count (HyperLogLog) Unique users, IPs, devices Approximate (~1–2% error); use dcount(col, 4) for higher accuracy
dcountif(col, pred) Distinct count where predicate true Distinct failing users Same approximation caveat
sum(col) / sumif() Total of a numeric column Bytes, cost, RU, durations Watch for nulls (ignored)
avg(col) / avgif() Mean Quick central tendency Misleading for latency — outliers skew it
percentile(col, n) n-th percentile (approx) Latency p50/p95/p99 The honest latency metric; approximate
percentiles(col, 50,95,99) Several percentiles at once Latency distribution in one pass Returns multiple columns
min() / max() Extremes First/last value, peak Pair with arg_min/arg_max for context
arg_max(expr, *) The row with the max expr “Latest record per key”, peak details * carries all columns of that winning row
arg_min(expr, *) The row with the min expr “Earliest/first event per key” Same — returns the whole winning row
make_list(col) / make_set(col) Array of values (set = distinct) Collect values per group Bounded (~1M items); can be heavy
make_list_if(col, pred) Conditional list Collect only matching values
stdev() / variance() Spread Variability, noisy-signal detection
take_any(col) Any one value from the group Cheap representative value Non-deterministic which one

Two deserve emphasis. arg_max(TimeGenerated, *) is the idiomatic “latest row per entity” — summarize arg_max(TimeGenerated, *) by ResourceId gives the most recent record for each resource, all columns intact, in one line (no window functions, no self-join). And dcount() is approximate by design (HyperLogLog, counting distinct values without holding them all in memory); for “how many unique users” the ~1% error is irrelevant, but dcount(UserId, 4) raises accuracy if you need it tighter. Knowing it is approximate prevents the “my distinct count is off by a few” confusion.

Grouping by one key, many keys, and none

The by clause is flexible; the multi-key and no-key cases are the ones that trip people up:

by clause Output rows Use when
(omitted) Exactly 1 (whole table is one group) A single grand total / overall p95
by Name One per distinct Name Per-endpoint, per-resource rollups
by Name, ResultCode One per distinct combination Cross-tab: errors by endpoint and code
by bin(TimeGenerated, 5m) One per 5-minute bucket A time series of a metric
by Name, bin(TimeGenerated, 5m) One per endpoint per bucket Per-endpoint time series (multi-line chart)
by tostring(Properties.region) One per dynamic-field value Group by a field inside a JSON column

The last row points at a real subtlety: when you group by a value pulled from a dynamic (JSON) column, wrap it in tostring() (or the right to* function) so the key has a concrete scalar type — grouping by a raw dynamic can behave unexpectedly.

Time-bucketing with bin()

“Per minute” is meaningless until every timestamp in a bucket maps to the same representative value. bin(value, roundTo) rounds value down to the nearest multiple of roundTo. For datetimes, bin(TimeGenerated, 1m) turns 12:03:47.812 into 12:03:00, so every event in that minute shares one key and summarize ... by bin(TimeGenerated, 1m) yields one row per minute.

AppRequests
| where TimeGenerated > ago(6h)
| summarize requests = count(),
            failed   = countif(Success == false)
        by bin(TimeGenerated, 5m)
| render timechart

That is the canonical “requests over time” tile. Two things make or break it. First, the grain (5m here) controls resolution and cost: finer = more buckets, busier chart; coarser = fewer rows, smoother, cheaper. Second, this summarize emits only buckets that had data — quiet periods are absent, and a timechart draws a straight line across the gap as if traffic were steady. If that lie matters, switch to make-series (below).

Pick the grain to fit the window — too fine over a long range floods you with buckets; too coarse over a short range hides the spike:

Time grain (bin(... , X)) Good window Buckets produced Use for
1m Last 1–4 hours 60–240 Live incident view, fine spikes
5m Last 6–24 hours ~72–288 Standard dashboard tiles
15m Last 1–3 days ~96–288 Daily trend, smoother
1h Last 7–14 days ~168–336 Weekly capacity/trend
1d Last 30–90 days 30–90 Monthly rollups, baselines
7d Last quarter/year ~13–52 Long-horizon trend

Rule of thumb: aim for ~50–500 buckets across your window. Below that you lose resolution; far above it the chart is noise. A few time idioms beyond bin() worth keeping handy:

Idiom Meaning Example
ago(1h) “Now minus 1 hour” where TimeGenerated > ago(1h)
now() Current UTC time extend age = now() - TimeGenerated
bin(T, 1d) Round down to the day (UTC) Daily buckets
startofday(T) / startofweek(T) Calendar-aligned boundaries Calendar-day grouping (vs rolling)
datetime_diff('minute', a, b) Whole-unit difference Duration between two timestamps
format_datetime(T, 'HH:mm') Format for display Axis/label formatting
bin_at(T, 1d, datetime(... 09:00)) Bucket aligned to a fixed origin Business-day windows starting 09:00

One timezone note: Log Analytics stores and bins in UTC. bin(TimeGenerated, 1d) gives UTC days, which may not match a viewer’s local “today.” For display, shift before binning (TimeGenerated + 5h30m for IST) — but know the raw buckets are UTC.

join: correlating two signals (and the five flavours)

The moment your question spans two tables — “which requests triggered which exceptions,” “which resources have no heartbeat,” “errors enriched with deployment metadata” — you need join. The footgun: the default kind is innerunique, not inner, and it silently de-duplicates the left table on the join key first. If you don’t know that, your counts are wrong and you get no error.

The shape is LeftTable | join kind=<kind> (RightTable) on <key>; the right table goes in parentheses (itself a full pipeline). The flavour reference — the columns that matter are “what happens to unmatched rows” and “to duplicates”:

kind= Keeps left-only rows? Keeps right-only rows? Duplicate handling SQL analogue Use when
innerunique (default) No No De-dups left on key first (no exact equivalent) Lookups where left key is effectively unique; the default — know it’s deduping
inner No No Keeps all matching pairs (Cartesian per key) INNER JOIN True inner join; you want every matching combination
leftouter Yes (nulls on right) No All left rows, matched or not LEFT JOIN Enrich left; keep rows even when no match
rightouter No Yes (nulls on left) All right rows RIGHT JOIN Symmetric to leftouter, right-driven
fullouter Yes Yes Both sides, nulls where unmatched FULL OUTER JOIN Reconcile two sets, see both gaps
leftsemi Yes (left cols only) Left rows that have a match WHERE EXISTS “Left rows that appear in right”
leftanti Yes (left cols only) Left rows with no match WHERE NOT EXISTS Find the gap — left rows missing from right
rightsemi / rightanti mirror of above Right-driven semi/anti mirror Right-side existence/absence

Three solve recurring real problems.

inner vs innerunique — getting counts right. Joining requests to exceptions to count exceptions per request with the default innerunique collapses duplicate request keys and undercounts. Be explicit:

AppRequests
| where TimeGenerated > ago(1h)
| project OperationId, Name, ResultCode
| join kind=inner (
    AppExceptions
    | where TimeGenerated > ago(1h)
    | project OperationId, ExceptionType = Type, Method = Method
  ) on OperationId
| summarize exceptions = count() by Name, ExceptionType
| order by exceptions desc

leftanti — finding what’s missing. The one people forget exists and reinvent badly. “Which VMs stopped sending heartbeats?” is all expected VMs anti-joined against VMs seen recently:

// Machines that reported in the last 24h but NOT in the last 10 min = went silent
Heartbeat
| where TimeGenerated > ago(24h)
| distinct Computer
| join kind=leftanti (
    Heartbeat
    | where TimeGenerated > ago(10m)
    | distinct Computer
  ) on Computer
// result: computers present 24h ago but absent in the last 10 minutes

leftouter — enrich without dropping. When you join a lookup (deployment metadata, a tag table) and want to keep every left row even if the lookup misses, use leftouter; unmatched rows get nulls on the right, and you can coalesce() a default. Using inner here silently drops rows with no match — a classic data-loss bug.

Join mechanics that prevent surprises:

Concern Rule Why
Which table is “left”? The one before the | join KQL conventions (and innerunique) act on the left
Put the smaller table where? As the right (parenthesised) side; filter it hard The right side is loaded/broadcast; keep it small
Same column name on both sides? Output gets a key1 suffix on the right’s copy Rename with project to avoid confusion
Joining on multiple keys? on Key1, Key2 (or on $left.A == $right.B) Composite keys; or explicit when names differ
Big-to-big join is slow? Add hint.shufflekey=<key> Distributes the join by key across nodes
Just need existence? Use leftsemi/leftanti, not a full join + distinct Cheaper; expresses intent

A lighter alternative to join for combining similar-shaped results is union, which stacks rows vertically (not by key). Use union for “all of these events together” (union AppRequests, AppDependencies) and join for “these correlated by a key.”

make-series: dense, gap-filled time series

As we saw, summarize ... by bin() omits empty buckets — a cosmetic lie for a chart, fatal for anomaly detection, where the math expects an evenly-spaced series with no holes. make-series emits a value for every step across an explicit range, fills gaps with a default, and returns the result as arrays — typically one row whose columns are the whole series.

AppRequests
| where TimeGenerated > ago(24h)
| make-series
    requests = count() default = 0
    on TimeGenerated
    from ago(24h) to now() step 5m
    by cloud_RoleName
| render timechart

Read the clauses: make-series <agg> default=<fill> on <timecol> from <start> to <end> step <grain> by <splitkey>. Every 5-minute slot from 24h ago to now gets a requests value; quiet slots become 0; by cloud_RoleName produces one series per role. The difference from summarize is the guarantee of density — no missing buckets, ever.

When to choose which:

Need Use Why
A rollup table (one row per group) summarize You want scalar aggregates, not arrays
A chart that must show gaps honestly make-series Empty buckets filled, no false continuity
Feed an anomaly/forecast function make-series Those functions require dense arrays
Quick “count over time,” gaps OK summarize ... by bin() Simpler; fine when data is continuous
Per-entity multiple lines, evenly spaced make-series ... by entity One dense series per entity in one pass

Because make-series returns arrays, you process them with the series functions:

Function Does Typical use
series_decompose_anomalies(s, sensitivity) Splits series into baseline/seasonal/anomaly; flags outliers Automatic anomaly detection on a metric
series_decompose_forecast(s, points) Extends the series with a forecast “Where is this trending next hour/day”
series_fir() / series_fit_line() Smoothing / linear fit Trend lines, moving averages
series_stats() Min/max/avg/stdev of a series Quick stats over the curve
mv-expand Unpack arrays back into rows Turn a series back into per-bucket rows

A complete anomaly query:

AppRequests
| where TimeGenerated > ago(7d)
| make-series reqs = count() default = 0
    on TimeGenerated from ago(7d) to now() step 1h
| extend (anomalies, score, baseline) =
    series_decompose_anomalies(reqs, 1.5)   // 1.5 = sensitivity
| render anomalychart with (anomalycolumns = anomalies)

That flags hours where request volume deviated from its own learned baseline and seasonality — an “is this spike abnormal?” detector in five lines, impossible without the dense series. To turn the arrays back into a row-per-bucket table, mv-expand unpacks them.

Shaping and cleaning: extend, parse, mv-expand, dynamic

Real logs are messy: useful values are buried in free-text messages, packed into JSON dynamic columns, or stored as arrays. The shaping operators get the value out so you can group or filter on it.

Computed columns and string extraction

extend adds columns; parse and extract pull structure from strings. Use parse when the text has a stable pattern, extract (regex) when it’s irregular:

// parse: pull fields from a structured-ish message
AppTraces
| where Message has "latency"
| parse Message with * "endpoint=" endpoint " latency=" latencyMs:long "ms" *
| summarize p95 = percentile(latencyMs, 95) by endpoint

// extract: one regex group out of free text
AzureDiagnostics
| extend status = extract(@"status=(\d{3})", 1, ResultDescription)
| summarize count() by status

The shaping toolkit, with the job each owns:

Operator / function Pulls out Use when Note
extend A computed column You need a derived value Keeps all other columns
parse / parse-where Fields by a literal pattern (-where drops non-matches) Message has consistent delimiters Cleaner/faster than regex
extract(re, n, src) / extract_all One regex capture group / all matches Irregular text Returns string/array; cast as needed
split(s, delim) Array from a delimited string CSV-ish fields Index with [0], [1]
tostring/toint/todouble/todatetime Type coercion After extracting from dynamic/text Required before grouping/maths

Working with dynamic (JSON) columns

Many tables have a dynamic column — Properties, Tags, customDimensions — holding nested JSON. Reach in with dot/bracket notation, and coerce the leaf to a scalar type before using it:

AppRequests
| extend region = tostring(customDimensions.region),
         tier   = tostring(customDimensions.serviceTier)
| summarize count() by region, tier

For arrays inside JSON, mv-expand is the unpacker — it turns one row with an N-element array into N rows, so you can group across elements:

// One row per tag, from a JSON array of tags
SomeTable
| mv-expand tag = parsedTags          // array column -> one row per element
| summarize events = count() by tostring(tag)

The dynamic/array handling table:

Task Operator/function Example
Read a JSON field col.field / col["field"] tostring(Properties.region)
Parse a JSON string into dynamic parse_json(s) / todynamic(s) extend d = parse_json(Message)
Coerce a leaf to scalar tostring/toint/... toint(Properties.code)
Unpack an array into rows mv-expand one row per array element
Re-pack rows into an array make_list() / make_set() inside summarize
Spread a property bag into columns bag_unpack(col) turn JSON keys into real columns

One readability habit across all of this: define reusable pieces with let at the top. It binds a name to a scalar, list, table expression, or function, so you write the messy part once:

let lookback = 24h;
let slowMs   = 1000;
let prod = AppRequests | where TimeGenerated > ago(lookback) and cloud_RoleName == "prod-api";
prod
| summarize total = count(), slow = countif(DurationMs > slowMs) by Name
| extend slowPct = round(100.0 * slow / total, 1)
| order by slowPct desc

Architecture at a glance

There is no system diagram here because the “architecture” is the shape of a query and the path data takes through the engine — a mental model, not a deployment.

Data lands in your Log Analytics workspace continuously and is stored columnar and partitioned by ingestion time — a stack of immutable, compressed time-slices, each holding all columns for the rows ingested in that window. The engine reads a query as a left-to-right pipeline: the source table names which slices to consider, and each operator transforms the table and hands it on. First comes partition pruning — a where TimeGenerated > ago(1h) discards every slice outside that hour without reading it. Then column pruning: because storage is columnar, a query projecting only Name and DurationMs physically reads only those two columns. So the cheapest queries are narrow in time and narrow in columns, and they get there first.

After pruning, data flows through the operators in order. where clauses thin the rows; summarize/make-series then fan in, collapsing many rows to few grouped by your keys. A join is the one place data fans out — the engine builds the (filtered, projected, ideally small) right-hand table, distributes it across nodes, and matches the left stream against it; this is why an unfiltered join is the most expensive thing you can do. The final order/top/render act on a result that should, by now, be tiny. Picture every good query as a funnel: wide at the source but immediately pruned by time and columns, narrowed by filters, fanned-in by aggregation, emitted small and shaped. When a query is slow, point to where that funnel failed to narrow early — an absent time filter, a wide column carry, or a join on raw tables — and fix it there.

Real-world scenario

LumenCart, a mid-size online retailer (fictional, but the shape is real), runs a .NET API on App Service behind Front Door, fronting Azure SQL and a handful of third-party APIs, all telemetered to one Log Analytics workspace via Application Insights. During a Tuesday-evening flash sale, the on-call SRE, Priya, gets paged: checkout latency is up and a trickle of 5xx is showing on the public status page. The canned App Insights “Failures” blade shows more exceptions but doesn’t say whether this is a code bug, a dependency, or load — and the marketing team is asking “is the site actually down or just slow?” Priya has ninety seconds of credibility before someone says “just restart it.”

She starts with the funnel mindset. First, is volume abnormal or just elevated? A make-series of request count over seven days at hourly grain, with series_decompose_anomalies, confirms the spike is above baseline but not record-breaking — real load, not a retry storm. Next, where do the failures concentrate? A summarize failed = countif(Success==false), p95=percentile(DurationMs,95) by Name, bin(TimeGenerated, 5m) over the last hour pins failures to two operations — POST /checkout and POST /payment — with p95 climbing from 400 ms to 4.2 s on exactly those, while every other endpoint is flat. Not a global outage; two endpoints degrading.

Now the cross-signal step. She joins requests to dependencies on OperationId (kind=inner, explicitly — the default would dedupe and undercount) and summarizes dependency duration by target: the slow path is dominated by the external payment provider, whose dependency p95 has gone from 120 ms to 3.8 s. A second query — a leftanti of “expected DB calls” vs “successful DB calls” — comes back empty, exonerating SQL. The picture is now defensible: the payment provider is slow under flash-sale load, dragging checkout p95 up and tripping a fraction of requests past the Front Door origin timeout into 5xx. Not LumenCart’s code, not the database, not capacity.

The fix follows from the diagnosis, not panic. They raise the Front Door origin timeout to stop borderline-slow checkouts becoming hard 5xx, flip the payment client to a circuit-breaker-with-retry so a slow provider degrades gracefully, and — because the anomaly query proved load was within plan — do not scale up blindly. Priya pins three queries to a shared workbook so the next incident starts where this one ended. The whole diagnosis took under ten minutes, every claim backed by a query she could show. That is the difference KQL fluency makes: not prettier charts, but faster, defensible attribution under pressure — using exactly the five operators this article centres on: summarize, bin(), join (the right kind), make-series, and an anomaly function on top.

Advantages and disadvantages

KQL-on-Log-Analytics is a specific set of trade-offs; knowing them tells you when to lean on it and when a different tool fits better.

Advantages Disadvantages
Extremely expressive — aggregation, joins, time-series, ML functions in one language Real learning curve past the basics; join semantics surprise newcomers
Read-only and pipeline-structured — easy to reason about, hard to corrupt data Not for transactional/operational reads; it’s an analytics query language
Columnar + time-partitioned engine → fast over huge volumes when queried well A badly-shaped query (no time filter) can scan enormous data and feel “slow”
One language across Logs, App Insights, Resource Graph, Sentinel, ADX Subtle dialect/feature differences between surfaces (e.g. Resource Graph subset)
Built-in time intelligence (bin, make-series, anomaly/forecast) Time-series gap handling (summarize vs make-series) is a known foot-gun
Powers alerts, workbooks, dashboards directly — query is the artifact Query cost/performance is on you; the platform won’t stop a wasteful scan
Great approximate aggregates (dcount, percentile) for scale “Approximate” trips people who expect exact distinct counts

Where each matters: the expressiveness is decisive for incident attribution and security hunting, where the question is novel and a fixed dashboard can’t answer it. The performance-is-on-you caveat bites hardest on busy, paid workspaces and on alert rules that run on a schedule — a wasteful query that’s merely annoying interactively becomes a recurring cost and latency problem when it runs every five minutes forever. And the one-language-across-surfaces advantage compounds: the summarize/join/make-series muscle memory you build against App Insights is the same skill that hunts in Sentinel and inventories with Resource Graph.

Hands-on lab

This lab runs entirely in the Log Analytics Logs blade against data you likely already have (App Insights AppRequests, or VM Heartbeat/Perf). If a table is empty, swap in one you do have — the operators are identical. No resources are created, so there is nothing to tear down.

Step 1 — Open the editor and confirm data. Portal → your Log Analytics workspace → Logs. Sanity check:

AppRequests
| where TimeGenerated > ago(1h)
| take 5

Expected: up to five raw request rows. If empty, try Heartbeat | where TimeGenerated > ago(1h) | take 5 and use Heartbeat/Computer in place of request fields below.

Step 2 — Inspect the shape with getschema. Learn the columns and types before you aggregate:

AppRequests
| getschema
| project ColumnName, ColumnType

Expected: a list of columns (Name, Success, DurationMs, ResultCode, OperationId, TimeGenerated, …) — how you discover field names without guessing.

Step 3 — Your first summarize. Count and failure-rate per endpoint over a day:

AppRequests
| where TimeGenerated > ago(24h)
| summarize total = count(), failed = countif(Success == false) by Name
| extend failRate = round(100.0 * failed / total, 2)
| order by total desc

Expected: one row per operation, with totals, failures and a percentage. Note that failRate is computed after aggregation.

Step 4 — Add time-bucketing with bin(). Turn it into a time series and chart it:

AppRequests
| where TimeGenerated > ago(6h)
| summarize requests = count(), failed = countif(Success == false) by bin(TimeGenerated, 5m)
| render timechart

Expected: a line chart, one point per 5-minute bucket. Switch the bin to 1m (more, noisier points) then 1h (fewer, smoother) — the grain trade-off live.

Step 5 — Latency percentiles. Replace the average instinct with the honest metric:

AppRequests
| where TimeGenerated > ago(6h)
| summarize p50 = percentile(DurationMs, 50),
            p95 = percentile(DurationMs, 95),
            p99 = percentile(DurationMs, 99)
        by bin(TimeGenerated, 15m)
| render timechart

Expected: three latency lines. The gap between p50 and p99 is your tail; a rising p99 with a flat p50 means “most requests fine, some users suffering.”

Step 6 — A join to correlate. Count exceptions per failing endpoint (explicit kind=inner):

AppRequests
| where TimeGenerated > ago(3h) and Success == false
| project OperationId, Name
| join kind=inner (
    AppExceptions
    | where TimeGenerated > ago(3h)
    | project OperationId, ExceptionType = Type
  ) on OperationId
| summarize exceptions = count() by Name, ExceptionType
| order by exceptions desc

Expected: a table of endpoint × exception-type counts. (If AppExceptions is empty, skip — but read the query; the pattern is the lesson.)

Step 7 — A leftanti gap-finder. Find machines that went silent (use Heartbeat):

Heartbeat
| where TimeGenerated > ago(24h)
| distinct Computer
| join kind=leftanti (
    Heartbeat | where TimeGenerated > ago(15m) | distinct Computer
  ) on Computer

Expected: computers seen in the last day but not the last 15 minutes — i.e. recently-silent hosts. An empty result means everything is reporting (good).

Step 8 — A dense series with make-series + anomaly detection.

AppRequests
| where TimeGenerated > ago(7d)
| make-series reqs = count() default = 0 on TimeGenerated from ago(7d) to now() step 1h
| extend (anomalies, score, baseline) = series_decompose_anomalies(reqs, 1.5)
| render anomalychart with (anomalycolumns = anomalies)

Expected: a request-volume curve with anomalous hours highlighted. Compare to Step 4’s summarize+bin chart and notice the gaps are now filled (default 0).

Step 9 — Save and reuse. Click Save → save as a query, or pin the chart to a dashboard / Azure Workbook. This is how a good incident query becomes a permanent asset rather than a screenshot.

Teardown. Nothing to delete — queries create no resources. Remove any saved query or pinned tile if you want a clean slate.

Common mistakes & troubleshooting

The failure modes that turn a five-minute query into an afternoon. Each is root cause → how to confirm → fix.

1. Query is slow or times out. Root cause: no (or too-wide) time filter, so the engine scans far more shards than needed; or a join on unfiltered tables. Confirm: the query-stats panel shows huge “data scanned” with a tiny result. Fix: add where TimeGenerated > ago(<window>) as the first operator, project before any join, and filter both join inputs.

2. join returns fewer rows than expected. Root cause: the default kind=innerunique de-duplicated the left table on the join key. Confirm: re-run with kind=inner and compare counts; if they differ, dedup was happening. Fix: state the kind explicitly — inner for all matches, leftouter to keep unmatched left rows.

3. Chart shows steady traffic across an outage. Root cause: summarize ... by bin() omits empty buckets, and the timechart connects across them. Confirm: look for missing timestamps in the table form. Fix: use make-series ... default=0 ... step <grain> so every bucket exists.

4. Distinct count is “wrong.” Root cause: dcount is approximate (HyperLogLog). Confirm: compare with summarize by UserId | count (exact, heavier). Fix: accept the ~1% error for scale, or raise accuracy with dcount(UserId, 4); use the exact form only on small inputs.

5. Grouping by a JSON field yields one giant group or weird keys. Root cause: grouping by a raw dynamic value without coercing to a scalar. Confirm: getschema shows the column is dynamic. Fix: wrap it — by tostring(Properties.region).

6. Numbers look off after a parse/extract. Root cause: extracted values are strings until cast. Confirm: getschema shows the new column as string. Fix: cast — toint(), todouble(), todatetime() — before maths or range filters. Prefer parse ... latency:long to type at parse time.

7. Time-of-day buckets don’t match local time. Root cause: Log Analytics bins in UTC. Confirm: compare a known event’s TimeGenerated to local. Fix: shift before binning (TimeGenerated + 5h30m for IST) for display, knowing the raw buckets are UTC.

8. mv-expand multiplies your counts. Root cause: mv-expand legitimately turns one row into N, so counts now reflect array elements, not original events. Confirm: count before and after the mv-expand. Fix: aggregate at the right level — compute per-row counts before expanding, or summarize ... by an original key after.

9. Alert rule fires constantly or never. Root cause: the query’s time window doesn’t match the alert’s evaluation frequency/period, or the threshold is on a raw count that empty buckets distort. Confirm: run the exact alert query manually over the alert’s period. Fix: align the query window to the evaluation period; threshold on a rate, not a raw count.

10. union or wide query returns a sea of columns. Root cause: combining tables with different schemas, or never projecting. Confirm: getschema | count shows dozens of columns. Fix: project the columns you need before union/render; use project-keep/project-away to trim.

11. “Operator/function not supported” in Resource Graph. Root cause: Azure Resource Graph supports a subset of KQL (no make-series, limited joins, paged results). Confirm: the error names the unsupported operator. Fix: rework with supported operators, or move time-series work to Log Analytics; see Azure Resource Graph Cookbook: KQL Queries for Inventory and Drift at Scale.

12. Same subquery computed several times. Root cause: a plain let table expression may be recomputed at each reference. Confirm: the query references one subquery in two+ joins/unions. Fix: wrap it in materialize() so it’s computed once and cached for the query’s lifetime.

Best practices

Security notes

KQL itself is read-only — a query cannot modify or delete data — but what a query can see is governed by the workspace’s access model, and that is where the security thinking lives.

Concern Mechanism Practice
Who can query what Workspace access mode + Azure RBAC; resource-context RBAC scopes results to resources a user can read Prefer resource-context so app teams see only their resources’ logs; reserve workspace-wide read for platform/SecOps
Table-level exposure Table-level RBAC on sensitive tables Restrict tables like SigninLogs, custom tables with secrets/PII to least-privilege roles
PII/secrets in logs Data hygiene at ingestion Don’t log tokens, secrets, full PII; if present, restrict the table and consider transformation at ingestion to drop sensitive columns
Cross-workspace queries Explicit workspace()/app() references Cross-workspace reads still require permissions on each target; don’t assume one grant spans all
Query result leakage Workbooks/dashboards inherit the viewer’s permissions in resource-context, or the pinning context otherwise Validate that a pinned tile doesn’t expose data a viewer shouldn’t see
Audit of access Azure activity + workspace query audit Know that queries can be audited; sensitive-data access should be reviewed

The single most important lever is resource-context RBAC: when access is resource-scoped, the same query run by two users returns different rows based on what each can read, so you grant log visibility by granting resource access rather than blanket workspace read. Setup details for these modes live in How to Create a Log Analytics Workspace: Region, Access Modes and Resource-Context RBAC. Treat any table that might carry PII or credentials as sensitive: restrict it, and fix the logging so secrets never land there — a query control is a backstop, not a substitute for not logging the secret.

Cost & sizing

In Log Analytics you mostly pay for data ingested and retained, not per query — interactive queries on the analytics tier are generally not billed per-query. So the dominant cost lever is what you ingest and how long you keep it. Query shape still matters for capacity and latency, in specific ways:

Cost / capacity driver What it is How to control it Rough figure
Data ingestion GB/day written to the workspace Filter at source, drop noisy columns/tables, use Basic Logs for cheap high-volume tables Pay-as-you-go ~USD 2.30–2.76/GB; commitment tiers (100 GB/day+) discount materially
Data retention How long data is queryable Set per-table retention; archive cold data; default ~31 days included, longer is billed Per-GB-month beyond the included period
Basic vs Analytics logs Log plan per table Use Basic Logs for verbose/rarely-joined data (cheaper ingest, limited query, no alerts) Basic ingest markedly cheaper; query has a small per-GB scan charge
Query load / concurrency Engine work per query Filter early, project, materialize() — keeps queries from hogging shared capacity No per-query bill (analytics), but heavy queries can throttle/contend
Scheduled alert queries A KQL query run on a cadence Keep alert queries tight; align window to frequency Cost is the ingest they read, plus the alert rule’s own small fee

Right-sizing in practice: the biggest savings come before KQL runs — reduce ingestion (filter chatty diagnostic categories, move high-volume low-query data to Basic Logs, trim retention) and commit to a tier if you sustain ≥100 GB/day. On the query side, what you control is mostly capacity and latency: a well-shaped query finishes fast and doesn’t contend for shared engine resources — which matters most for alert queries that run forever on a schedule. The included allowances (a small monthly ingestion grant, ~31-day retention on many tables) are enough to learn on. For the deeper decision of what even belongs in logs vs metrics — the largest cost lever of all — see Metrics vs Log Analytics: Choosing the Right Store for Speed, Cost and Retention. (Prices vary by region and change; treat figures as order-of-magnitude.)

Interview & exam questions

These map to AZ-104 (Monitor and maintain Azure resources), AZ-400 (instrument an app, observability), AZ-700 (network monitoring with logs), and SC-200 (KQL for Microsoft Sentinel hunting).

Q1. What does summarize count() by bin(TimeGenerated, 5m) produce, and why might a chart of it mislead? One row per 5-minute bucket that contains data. It misleads because empty buckets are omitted, so a timechart draws a straight line across gaps as if traffic were continuous. Use make-series ... default=0 when honest gaps matter.

Q2. KQL’s default join kind is innerunique. How does it differ from inner, and why is the difference dangerous? innerunique first de-duplicates the left table on the join key, then matches; inner keeps every matching pair. The danger is silent undercounting — a bare join looks like a SQL inner join but drops left-side duplicates, with no error. Always state the kind.

Q3. When would you use leftanti? To find left rows with no match on the right — the “what’s missing” query: machines that stopped sending heartbeats, resources without a required tag. It returns left columns only, like WHERE NOT EXISTS.

Q4. Why is dcount() described as approximate, and how do you make it more accurate? It uses HyperLogLog to count distinct values without storing them all, trading ~1–2% error for billion-row scale. Raise accuracy with a second argument, dcount(col, 4); for an exact count on small data use summarize by col | count.

Q5. What is the difference between summarize and make-series? summarize emits rows only for groups that exist (sparse), returning scalars; make-series emits a value for every time bucket in an explicit range, filling gaps with a default, returning arrays. Use make-series for dense charts and anomaly/forecast functions.

Q6. Why does operator order matter for performance? Give the cardinal rule. Each operator processes the table the previous one emitted, and storage is columnar and time-partitioned. The rule is filter early, aggregate late: a leading where TimeGenerated > ago(...) prunes whole shards, then summarize/join run on the smallest possible data.

Q7. How do you extract a value from a JSON dynamic column and group by it safely? Reach in with dot/bracket notation (Properties.region) and coerce to a scalar before grouping: summarize count() by tostring(Properties.region). Grouping by a raw dynamic can behave unexpectedly; the cast fixes it.

Q8. What does arg_max(TimeGenerated, *) give you, and what problem does it solve elegantly? The entire row (all columns, via *) with the maximum TimeGenerated per group — the idiomatic “latest record per entity” (summarize arg_max(TimeGenerated, *) by ResourceId), replacing a self-join or window function with one line.

Q9. A Log Analytics query scans huge data for a tiny result. What’s the first fix? Add a time filter as the first operator (where TimeGenerated > ago(<window>)) so the engine prunes shards, and project only needed columns before any join. The query-stats panel (data scanned) confirms the improvement.

Q10. When should materialize() be used? When a subquery (a let-bound table expression) is referenced more than once — joins, unions, multiple branches. materialize() computes it once and caches the result for the query’s lifetime, avoiding repeated recomputation.

Q11. Across which Azure services does KQL apply, and what’s a key caveat when porting a query? Log Analytics, Application Insights, Azure Resource Graph, Microsoft Sentinel, and Azure Data Explorer. The caveat: the data and operator support differ — e.g. Resource Graph supports only a subset (no make-series, limited joins, current-state not time-series), and time columns differ (TimeGenerated vs timestamp).

Q12. How does a Log Analytics scheduled alert relate to KQL, and what’s a common shaping bug? A scheduled alert is a KQL query run on a cadence; the result (rows or an aggregate) is compared to a threshold. A common bug is a mismatch between the query’s time window and the alert’s evaluation period/frequency, or thresholding a raw count that empty buckets distort — align the window and prefer rate/make-series-based stability.

Quick check

  1. You want “requests per minute, exactly, including minutes with zero traffic, as a chart.” Which operator do you reach for, and why not the obvious summarize?
  2. A colleague’s join (no kind= specified) is undercounting. What is the most likely cause and the one-word fix to the query?
  3. You need the most recent heartbeat row (all columns) for each machine. Write the summarize clause.
  4. Your daily rollup buckets at a strange local hour. What’s the underlying reason?
  5. Name the cardinal performance rule for KQL in five words.

Answers

  1. make-series (with default=0 and an explicit step). summarize ... by bin() omits empty buckets, so the chart would connect across gaps and falsely imply steady traffic; make-series fills every bucket.
  2. The default join kind is innerunique, which de-duplicates the left table on the join key. The fix is to specify the kind explicitly — write kind=inner (or leftouter).
  3. summarize arg_max(TimeGenerated, *) by Computerarg_max(..., *) returns the entire winning row per group.
  4. Log Analytics stores and bins in UTC, so bin(TimeGenerated, 1d) produces UTC days, which won’t align with the viewer’s local “today” unless you shift the timestamp before binning.
  5. “Filter early, aggregate late.” (Equivalently: time-filter and project before summarize/join.)

Glossary

Next steps

AzureKQLLog AnalyticsAzure MonitorKustosummarizejoinObservability
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading