It reads like a sentence
Most query languages make you say things inside-out — SQL wants the columns before you have named the table. KQL — the Kusto Query Language, the native tongue of every Log Analytics workspace — flows the way you think: start with a table, then pipe it through transformations, top to bottom, each step feeding the next. Say it aloud and it is barely code: take the storage logs; keep the last day; keep the failures; count them by operation.
// the sentence, written down
StorageBlobLogs
| where TimeGenerated > ago(1d)
| where StatusText != "Success"
| summarize count() by OperationName
- KQL query
- A pipeline that starts from a table and flows downward through operators — each | hands the previous step's rows to the next. Reading order is execution order, which is why KQL can be read aloud and SQL mostly cannot.
Three habits orient the rest. The Log Analytics query editor reads data; these examples do not change the stored records. Queries can still consume resources or incur charges under some table plans, so keep practice scoped. KQL identifiers and operators are case-sensitive: where works; Where does not. Put a relevant time filter near the start so the query engine has less data to examine, while remembering that the engine also performs its own optimizations.1
Try it here
A small copy of three tables this class already talks about — StorageBlobLogs, AppRequests, AzureActivity — sits in this page, nowhere else. Type a query, run it, watch the row count move. It supports a deliberately small slice of KQL; anything outside it says so plainly instead of guessing.
This box runs entirely in your browser against canned data. Lab 1, below, is the real thing — a public Log Analytics workspace with real telemetry.
Play it through
Three minutes, one table of forty million rows. Put the filter in front of the pipe, then rank what is left to find the one operation that is actually failing. It plays on its own and stops when it needs your hands.
The ten patterns that cover on-call
Fluency in KQL is not knowing four hundred operators; it is having ten patterns loaded and ready at 2am. Here they are, each in the form you will actually type. Read them now; type them all in Lab 1.
// 1 · peek — what does this table even look like?
StorageBlobLogs
| take 10
// 2 · filter — the workhorse: a time window, then conditions
StorageBlobLogs
| where TimeGenerated > ago(24h)
| where StatusText != "Success"
// 3 · project — keep only these columns; rename StatusText
StorageBlobLogs
| where TimeGenerated > ago(24h)
| project TimeGenerated, OperationName, Status=StatusText
// 4 · count by — how many rows per operation?
StorageBlobLogs
| where TimeGenerated > ago(24h)
| summarize count() by OperationName
// 5 · top — the worst offenders, sorted server-side
AzureActivity
| top 20 by TimeGenerated desc
// 6 · distinct — what values exist in this column?
Perf
| distinct CounterName
// 7 · math by group — avg, max, min, percentiles per group
Perf
| where CounterName == "% Free Space"
| summarize min(CounterValue) by Computer
// 8 · trend — average free space in hourly buckets
Perf
| where TimeGenerated > ago(1d)
| where CounterName == "% Free Space"
| summarize AvgFree = avg(CounterValue) by bin(TimeGenerated, 1h)
// 9 · chart the hourly result; a time column is required
Perf
| where TimeGenerated > ago(1d)
| where CounterName == "% Free Space"
| summarize AvgFree = avg(CounterValue) by bin(TimeGenerated, 1h)
| render timechart
// 10 · search — the last resort, when you don't know the table
search in (StorageBlobLogs, AzureActivity) "store-23"
| take 20
Notice the shape of the set: explore a table, narrow the rows and columns, group what remains, then present the result. Use search when you do not know which table holds a value. An unscoped search may inspect many tables, so name the tables with search in (...) when you can; a known table and column filter is usually a better starting point.2 The patterns below show how to combine these operators.3
Text hunting — the workhorse's sharper edge
Pattern 2 filters on whole values; the 2am filter is usually a fragment — an error phrase, a store id buried in a URL. KQL offers two verbs that look interchangeable and are not. has matches whole terms against the workspace's index, so it is fast — and it will not find store-23 inside store-234, because that is not a term match. contains is a raw substring scan: it finds the fragment anywhere, and pays for the privilege by reading everything. Reach for has first; drop to contains only when what you hunt is genuinely a piece of a longer word.
// 2a · hunt text — has for whole terms (indexed), contains for substrings (scan)
AppTraces
| where TimeGenerated > ago(1h)
| where Message has "timeout"
| where Message contains "tore-2" // only a substring scan finds a fragment
The case rules ride alongside: has and contains ignore case by default, == does not, =~ is equality with case forgiven, and the _cs suffix — has_cs, contains_cs — buys sensitivity back when the case carries meaning. And when the column you want to filter on does not exist, extend manufactures it: a computed column the table never had, available to every operator downstream.
// 2b · extend — compute the column you wish the table had
AppRequests
| extend DurationSeconds = DurationMs / 1000.0
| where DurationSeconds > 2
Time windows and bins
Log questions are time questions — last night, since the deploy, the hour around the alert — so time handling deserves its own section. Two tools do nearly all of it. ago() anchors a window to now: ago(1h), ago(24h), ago(7d). For an incident with known bounds, between brackets it exactly:
// the hour around a 02:00 incident — remember: TimeGenerated is UTC
ContainerAppConsoleLogs_CL
| where TimeGenerated between (datetime(2026-07-12 01:30) .. datetime(2026-07-12 03:00))
Then bin() — the function behind every chart you will ever build. Summarizing by raw TimeGenerated is useless (every millisecond is its own group); bin(TimeGenerated, 1h) rounds each timestamp down into hour buckets, so summarize count() by bin(TimeGenerated, 1h) means "how many per hour" — the exact shape a render timechart wants. Two habits keep time from lying to you: everything in the workspace is UTC, so translate before comparing against anyone's "2am"; and choose the window before the operators, because the single most common broken query on-call is a correct question aimed at the wrong hours — Drill 04 is that mistake, preserved like a specimen.
Joins, sparingly
Sometimes two sets of records share a stable identifier. A join pairs rows with the same identifier. Start with tiny in-query tables so you can predict the result before joining live telemetry:
// op-1 matches on both sides; op-2 has no error row
let Requests = datatable(OperationId:string, Name:string)
["op-1", "checkout", "op-2", "browse"];
let Errors = datatable(OperationId:string, Error:string)
["op-1", "timeout"];
Requests
| join kind=inner Errors on OperationId
The result contains checkout and timeout because both rows carry op-1; browse disappears because inner keeps only matches. With real tables, filter each side by time before joining and confirm that the keys have the same meaning. A shared column name is not enough: two unrelated services might both call a field CorrelationId without sharing values. Specify the join kind; KQL's default innerunique can deduplicate left-side keys. The leftouter example below keeps unmatched requests so you can investigate missing error records.
The words summarize actually needs
count() counts returned rows. summarize can calculate several named aggregates grouped by the same key, so one query can answer related questions together. Query charges depend on the table plan and data scanned; do not infer billing from the number of aggregate expressions.
| Function | Answers | The catch worth remembering |
|---|---|---|
| count() | how many rows fell in the group | counts rows, not events, when App Insights sampling is on4 |
| countif(predicate) | how many rows also matched a condition | the clean way to count failures beside a total in one line |
| dcount(X) | an approximate number of distinct values | accuracy depends on data and settings; use count_distinct() where an exact supported count is required |
| sum(X) · avg(X) | the total, the mean | the mean is easily seduced by a handful of outliers |
| min(X) · max(X) | the extremes of a column | pair with arg_max(T, *) to keep the whole row at the extreme |
| percentile(X, 95) | the value 95% of rows fall under | the honest latency number the average is hiding |
| make_set(X) | the distinct values, collected into a list | "which stores did this," in one cell, ready to eyeball |
Percentiles, because the average lies about latency
An average can hide a slow minority of checkout requests. percentile(DurationMs, 95) estimates the threshold below which about 95% of observed requests fall; the slowest 5% are at or above that value. Compare p50, p95, and p99 with request count and failure rate. Define any service-level objective from the user's actual experience and measurement method, not from a percentile chosen by habit.
The average is a comfortable lie.
// Campux checkout, per endpoint — load and honest latency, last 24h
AppRequests
| where TimeGenerated > ago(24h)
| summarize
calls = count(),
clients = dcount(ClientIP),
failed = countif(Success == false),
p50 = percentile(DurationMs, 50),
p95 = percentile(DurationMs, 95),
p99 = percentile(DurationMs, 99)
by Name
| extend fail_rate = round(100.0 * failed / calls, 1)
| top 10 by calls desc
Read the row for POST /checkout and the whole shape of a problem arrives at once: how busy it is (calls), how many distinct shoppers felt it (clients), how often it failed (fail_rate), and — the line the average was hiding — a p50 of 180 beside a p99 of 3,100. A healthy median and a savage tail is the signature of a resource under intermittent contention, and you would never have seen it in the mean. Note dcount earning its place: it counts distinct clients cheaply because it estimates rather than tallies — right for "roughly how many shoppers," wrong for anything an auditor will read.
let — name a thing once, change it once
The moment a query mentions the same window twice, the same threshold in two places, or an inner table that wants a name, reach for let. It binds a value — a duration, a number, a string, even a whole subquery — to a name at the top, and the rest of the query reads it. This is not decoration; it is the difference between a query you can safely edit at 2am and one where you change the window in one where, miss the second, and quietly compare two different hours. Define the tunable things once, up top, where the next reader — future you — can see and adjust them without hunting:
// who changed what across the estate today — one knob, defined once let window = 24h; let noisy = 20; // flag anyone past this many changes AzureActivity | where TimeGenerated > ago(window) | where ActivityStatusValue == "Success" | summarize changes = count(), kinds = dcount(OperationNameValue) by Caller, ResourceGroup | where changes > noisy | order by changes desc
To widen the sweep to a week, change one character — 24h to 7d — and every mention updates together. AzureActivity is the estate's control-plane diary: every create, delete, and role change, stamped with its Caller. Grouped this way it answers the question that opens most Campux incidents — "what changed before this broke?" — and the noisy threshold is exactly the kind of number you will tune per estate, which is exactly why it belongs on its own line and not buried in a filter.
parse and extract — the column the table never had
Half of on-call is fishing a field out of a text column: a store id inside a request URL, a queue depth inside a log message. Two tools do it, and the distinction is worth keeping straight. extract takes a regular expression and pulls one capture group — the right reach when the value sits at no fixed position, like a query-string parameter that may or may not be present:
// which stores drove the most traffic — store id lives inside the URL
AppRequests
| where TimeGenerated > ago(1h)
| extend Store = extract(@"[?&]store=(\d+)", 1, Url)
| where isnotempty(Store)
| summarize calls = count() by Store
| top 5 by calls desc
parse is the other tool, and it shines where extract is overkill: a message with a known, fixed layout, where you name the pieces in reading order and KQL fills them in. It reads cleanly precisely because the format is fixed — reach for it on your own structured log lines, not on a URL whose parameters arrive in any order:
// a fixed-format app log line, unpicked into named columns // "job=nightly-pos store=23 rows=48120 ms=920" AppTraces | where Message has "job=nightly-pos" | parse Message with "job=" Job " store=" Store " rows=" Rows:long " ms=" Ms:long | where Ms > 500 | summarize avg(Ms) by Store
Either way, the payoff is the same: a computed column the table never stored becomes something every operator downstream can filter, group, and chart. The field was always in the data — you just gave it a name.
inner versus leftouter — the join that finds what is missing
§4 taught the rules that keep any join honest — filter both sides first, state the kind, check the join column means the same thing on both sides. The kind itself carries a second lesson worth its own paragraph, because inner and leftouter answer opposite questions. inner keeps only rows that match on both sides — the intersection, and the right choice when you trust that a match exists and want the enriched pair. leftouter keeps every left row and pads the misses with empty columns — which is the only way to ask the most useful question a join can pose: which left rows had no match at all? The failures nobody logged, the requests with no exception record, the orders with no shipment — the gaps live exactly where an inner join throws rows away.
// failed requests with NO exception logged — the silent failures inner would hide
AppRequests
| where TimeGenerated > ago(1h) and Success == false
| join kind=leftouter (
AppExceptions
| where TimeGenerated > ago(1h)
| project OperationId, Problem = Type
) on OperationId
| where isempty(Problem)
| summarize count() by Name, ResultCode
Run it with kind=inner and you get the opposite report — failures that did log an exception, the ones already easy to debug. The leftouter plus isempty pattern is how you find the failures that left no fingerprint, which are the ones that eat an evening. Choosing the kind is not a syntax detail; it is choosing which half of reality the query is allowed to see.
From query to saved search to alert
A query typed once answers a question. A query kept becomes infrastructure, and there are three rungs on that ladder:
- Saved
- A good query gets a name and a place in the workspace's shared query pack — 2am-you should never rewrite what 2pm-you already perfected, and a team's saved queries are its institutional memory of every past incident.
- Scheduled
- The same query becomes a log alert rule: Azure runs it on a cadence, and when the results cross a threshold ("any computer with under 10 percent free disk"), it fires into the alerting machinery Class Thirty builds.
- Answered before asked
- Pinned to a workbook or dashboard, the query runs every time someone looks — turning "run this to check" into "glance at this."
A saved query is a lesson that stays learned.
The query that was worth nine hours
In the Campux scenario, a full disk led to a nine-hour outage at forty stores. Here is a query that could help detect low free space if the machines report the right performance counter:
Perf
| where TimeGenerated > ago(1h)
| where CounterName == "% Free Space"
| summarize MinFree = min(CounterValue) by Computer
| where MinFree < 10
This returns machines whose lowest reported free-space value in the last hour is under ten percent. In the Campux scenario, a scheduled alert using this query could have warned the on-call engineer while readings were still available. Whether it would have prevented the outage depends on collection frequency, alert evaluation, delivery, and response. Save it as disk-space-low, then test the alert and its notification route before relying on it.
The official pages, and a CAMPUX overview
Get started with log queries in Azure Monitor
learn.microsoft.com/azure/azure-monitor/logs/get-started-queries
KQL — Kusto Query Language reference
learn.microsoft.com/kusto/query/
Type all ten against Microsoft's free demo workspace
You put the filter first above. Now try the patterns in Microsoft's public Log Analytics demo. The sample tables and available time ranges can change. Run each query separately, inspect the actual schema and results, and widen the time window if a recent interval has no rows.
Open the demo workspace: sign in to the Azure portal, then browse to
https://portal.azure.com/#blade/Microsoft_Azure_Monitoring_Logs/DemoLogsBlade
What to notice: a full Log Analytics query window over someone else's estate — dozens of tables in the schema pane on the left. Make sure the editor is in KQL mode, and note the time picker beside Run: it silently scopes every query you type.Patterns 1, 6, and 2 — explore, enumerate, filter:
SecurityEvent | take 10 Perf | distinct CounterName Perf | where TimeGenerated > ago(1h) | where CounterName == "% Free Space" | take 20
On screen: first the raw shape of a table; then the values in an unfamiliar counter column. The last query may return no rows if that counter has no samples in the last hour. Highlight one query at a time and press Run.Patterns 3, 4, and 5 — shape, count, rank:
SecurityEvent | where TimeGenerated > ago(4h) | project TimeGenerated, Computer, Activity SecurityEvent | where TimeGenerated > ago(4h) | summarize count() by Activity SecurityEvent | where TimeGenerated > ago(4h) | summarize Events = count() by Computer | top 5 by Events desc
On screen: the same table asked three escalating questions — what happened, how much of each kind, and who is noisiest. That third query is the universal on-call opener: top 5 by count finds the outlier in any table in ten seconds.Patterns 7, 8, and 9 — the trend, then the picture:
Perf | where TimeGenerated > ago(1d) | where CounterName == "% Free Space" | summarize avg(CounterValue) by bin(TimeGenerated, 1h) | render timechart
On screen: if the demo has matching samples, a line chart of average free disk with hourly points. Change 1h to 15m and rerun to see finer time buckets.Pattern 10 — and why it is last:
search "error" | take 20
The lesson: search can locate a value when you do not know its table. Compare its reported duration and scope with a query against a known table; results depend on the demo's current data and query plan. Narrow the tables with search in (...) when possible.
Build the case-file query, then break it like Drill 04
Assemble the disk-space query one pipe at a time, inspect any matching machines, then deliberately aim it at the wrong window. An empty result can mean no matching samples, so check the time range and counter before interpreting it.
Start wide, then narrow — run after each added line:
Perf | where TimeGenerated > ago(1h) | where CounterName == "% Free Space" | summarize MinFree = min(CounterValue) by Computer
On screen: a machine appears only if it reported this counter in the last hour. The named result column MinFree holds that machine's lowest matching reading.Add the threshold — the line that turns a report into a detector:
Perf | where TimeGenerated > ago(1h) | where CounterName == "% Free Space" | summarize MinFree = min(CounterValue) by Computer | where MinFree < 60
What to notice: the case file uses 10 percent; 60 is a practice threshold and may still return no rows. A threshold after summarize filters grouped results, not raw readings.Now sabotage it, Drill 04 style. Pretend the incident was 02:00–03:00 yesterday, and run the query with today's window:
Perf | where TimeGenerated > ago(1h) // wrong window | where CounterName == "% Free Space" | summarize MinFree = min(CounterValue) by Computer | where MinFree < 60
What to notice: whether this returns rows or not, it cannot answer yesterday's incident question: ago(1h) means the last hour from now.Fix the query with an explicit UTC window. This example means 02:00–03:00 UTC yesterday; convert the incident's local time to UTC first if it happened in another zone:
let incidentStartUtc = startofday(ago(1d)) + 2h; let incidentEndUtc = incidentStartUtc + 1h; Perf | where TimeGenerated between (incidentStartUtc .. incidentEndUtc) | where CounterName == "% Free Space" | summarize MinFree = min(CounterValue) by Computer | where MinFree < 60
The lesson: the time window now names the incident instead of the most recent hour. The demo may have no matching disk samples; an empty result is a possible answer, not proof the syntax failed. Save a reusable version only after checking the table, counter, time zone, and threshold against your own environment.
Zoom out: a query is a question you can answer at 3am — or not at all
You can write KQL. Now reason about what querying does as a system, because logs are only worth collecting if a fast answer is reachable when speed matters most.
As retained data grows, broad queries may take longer and cost more under some table plans. What table, time range, and fields do you actually need to answer this incident question?
The answer depends on the data being there and structured; a query is only ever as good as what was collected and how it was shaped.
In an incident the constraint is time-to-answer; a bookmarked, tight query is the difference between a four-minute triage and a lost hour.
A narrow time filter can make a query faster, but it can also hide the incident if you choose the wrong hours. Confirm the window against the event timeline.
A query that returns in a second over a day’s logs times out over a year’s; the habit of scoping tight is what survives real volume.
The engineer who ships is asked "does it return the rows?" The engineer who gets promoted is asked "and will it still, fast, mid-incident?" — and scoped it tight.
The one who found it first
Something spiked at 3am and everyone wants to know what. You open Log Analytics, start with a table, and build up a query one pipe at a time — a where, a time filter, a summarize by hour — until a million rows collapse into the single line that explains the spike. Five operators, and you are the one who found it while others were still refreshing dashboards.
Examination
Four drills, then two situations. The situations have no marking scheme — write your answer before you reveal the reasoning, or the exercise is worthless. Nothing is stored; this is between you and the page.
B — rows versus columns, the two axes of every table. where travels vertically, discarding rows that fail a condition; project travels horizontally, keeping and renaming the columns you care about. D is the same sentence reversed, and it is the reversal that produces the classic beginner error: trying to project away bad rows, or where-ing a column that project already dropped upstream — remember the pipe flows down, so a column removed on line three does not exist on line four. Hold the axes straight and patterns 2 and 3 never tangle: narrow the rows first, then trim the columns, then aggregate what survives.
bin() rounds timestamps into buckets, and buckets are what make grouping by time mean anything. Raw TimeGenerated values are precise to fractions of a second, so grouping by them directly produces a group per row — technically valid (so not A), practically useless: a million one-member groups instead of twenty-four hourly counts. bin is real arithmetic, not chart decoration (B), and it has nothing to do with limiting the window (D — that is where's job). The mental model: bin(TimeGenerated, 1h) asks "which hour does this row belong to?", and the bucket size is a resolution dial — 1h for a day's overview, 5m for the minutes around an incident. Every timechart you will ever render is a summarize-by-bin wearing a picture.
Options 1, 2, and 4. These Log Analytics examples read records and pipe results through operators. A time filter states the incident window clearly and can reduce the data considered. A broad search may examine many tables and is usually less efficient than a known table and column filter. Operator and table names are case-sensitive. Read-only does not mean free: query cost and limits depend on the table plan and data scanned.
// checkout failures from last night's incident
1. AppServiceHTTPLogs
2. | where TimeGenerated > ago(1h)
3. | where ScStatus >= 500
4. | summarize count() by CsUriStem
5. | order by count_ desc
Line two. ago(1h) is always anchored to now — at 09:30 it means "since 08:30," a window that ends six and a half hours after the incident did. The query is syntactically perfect and semantically aimed at the wrong morning, and its clean, confident "no failures found" is the trap: an engineer who trusts it concludes the incident left no trace and starts doubting the logging instead of the window. Wrong-time-filter is the single most common broken query in on-call life precisely because nothing errors.
The fix is §3's bracket: where TimeGenerated between (datetime(2026-07-12 02:00) .. datetime(2026-07-12 03:00)) — with the incident's local "2am" first converted to UTC, the second half of the same trap. The other lines are healthy: numeric comparison on status codes is normal (A), stacked wheres before a summarize is the house style (C), and order by is real (D). The habit that prevents all of this: before reading any query's results, read its window, out loud, and ask whether the incident is inside it.
Query one establishes the shape — when exactly, how bad, one line or a cliff. Bracket the incident generously and bin fine: AppServiceHTTPLogs | where TimeGenerated between (datetime(…01:30) .. datetime(…03:00)) | where ScStatus >= 500 | summarize count() by bin(TimeGenerated, 5m) | render timechart. Thirty seconds of reading tells you whether the spike was a wall (something broke at an instant — look for a deploy, a job, a certificate) or a ramp (something filled or saturated — look at resources), and whether it truly ended at 02:40 or is still smouldering. The shape is the diagnosis's first fork, and people who skip it interrogate details before knowing which details matter. Note what the window is not: ago(1h) — Drill 04 was last night's rehearsal for exactly this page.
Query two finds where it concentrates — because spikes are almost never uniform. Same window, group by the suspects: | summarize count() by CsUriStem | top 10 by count_ — or by instance, or by client, depending on the architecture. If ninety percent of the errors are one endpoint, you have a code path; one instance, you have a sick machine; everything evenly, you have a dependency below all of them — three different investigations, separated by one summarize. This is Lab 1's "top 5 by count" opener doing its real job: not answering the question, but choosing the next one, which is what second queries are for.
Then follow the concentration to the sentences. Whatever query two names, query three reads its actual error records — project TimeGenerated, details… | take 50 — and correlates the 02:00 timestamp against what else happened at 02:00, which at Campux is a short and suggestive list: the POS job's cron fires at 02:00. The craft in this situation is not exotic KQL — every query here is Lab 1 material. It is asking the questions in an order where each answer shrinks the search space. Shape, concentration, sentences: memorise the sequence and 2am stops being improvisation.
Start from questions, not queries. The temptation is to import fifty clever queries from a blog; the discipline is to list the questions this team actually asks — from the last quarter's incidents, the services that page, the things the lead checks every morning — and write only those. A first pack is small and boring on purpose: errors-by-service-last-hour, the disk-space detector from this class's case file, failed-job-executions, top-noisy-resources, who-changed-what (AzureActivity). Ten queries that map to real questions beat fifty that map to someone else's estate. Each gets a name that reads like the question it answers and a one-line comment saying when to reach for it — because the audience is a stressed stranger at 2am, and that stranger is future you.
Then give the pack a home and a ratchet. Home: the workspace's shared saved queries, so they appear in everyone's Log Analytics — not a wiki page that drifts, not a personal folder that leaves when its owner does, which is precisely the failure you were hired into. Ratchet: a rule the team adopts in one meeting — every incident's postmortem contributes its best query to the pack. That single habit converts each painful night into permanent capability; it is the Class Twenty-Two "add the missing check" reflex, applied to investigation instead of CI. Six months of the ratchet and the pack is the team's actual incident memory, indifferent to resignations.
Close the loop by promoting the ones that earn it. Review the pack quarterly with §6's ladder in hand: a query someone runs every morning belongs on a dashboard; a query whose answer should never be "yes" (disk under ten percent, job failed twice) belongs in an alert rule, asked by a robot on a schedule. The pack is not a library — it is a pipeline from "question we asked once" to "question the system asks itself." The departing engineer's real failure was not messiness; it was keeping institutional knowledge in the one storage tier with no redundancy. You are moving it to durable storage. Say it that way in the interview, too — this exact situation is a favourite.
Five things worth carrying out of this class
- In Log Analytics, start with a table and pass results through operators with a pipe. These examples read stored records. Names and operators are case-sensitive; a relevant early time filter helps scope the question.
- Ten patterns cover on-call: take, where+ago, project, summarize count() by, top, distinct, math-by-group, bin trends, render timechart, and search as the expensive last resort.
- Time is the axis that lies: ago() anchors to now, between() brackets an incident, bin() buckets timestamps into chartable groups — and everything is UTC, converted before compared.
- Joins sparingly: filter both sides first, state the kind explicitly, and check whether one table already holds both facts. A semicolon, not a comma.
- A useful query can be saved, added to an alert rule, and shown on a dashboard. Test collection, thresholds, notification delivery, and response before trusting it to prevent an outage.
- Filtering early makes the question easier to inspect. The engine may reorder safe operations, and performance depends on the table plan and query. Check the actual result and diagnostics. Most importantly, put the right incident window in the query. ↩
- KQL appears in several Azure services, but available tables, functions, and commands differ by service. Check the schema and documentation of the place you are querying before reusing a Log Analytics example. ↩
- Log Analytics also offers a Simple mode — point-and-click filters that build queries without KQL — and AI assistants increasingly draft KQL from natural language. Both are legitimate; neither replaces reading the language, because the drafted query still has to be checked by someone who can see that its time window is wrong. Drill 04's query would sail through any assistant looking plausible. The tools write; you still audit. ↩
- A quiet trap in the aggregation table: when Application Insights sampling is on — and at volume it usually is, to keep the bill sane — the workspace keeps one row in every few and marks it with an ItemCount of how many it stood in for. count() then counts retained rows and undercounts reality; sum(ItemCount) is the true number. Treat a suspiciously round count() on a busy AppRequests table as a question, not an answer, and check whether sampling is on before you quote it to anyone. ↩
Write a KQL query that answers a real question, from filter to summarise to render.
Play the lesson above to stamp this class.