Skip to content
CAMPUX Cloud Bootcamp Phase Four · Class Twenty-Nine
Phase Four — Operate, Secure & AI
4 drills · 2 situations · 2 Labs
Aligned to AZ-104
Class Twenty-Nine

KQL essentials

The reservoir from last class is filling with millions of rows nobody will ever scroll through — KQL is the language that asks them questions, and it reads, genuinely, like a sentence.

Phase Four · Operate, Secure, AI · Class 29 of 42 · 28 min · Assumes Class 28

After this you can write a KQL query that answers a real question, from filter to summarise to render.

New to this? Read Class 28 first. Already done it, or know the material? Carry on.

§1

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

Figure — The pipeline, shrinking
AppRequests 1,200,000 rows in the table | where ago(24h) 40,000 rows remain | summarize by Name 300 groups | top 4 by count_ 4 rows — the answer put the time filter first — everything downstream works on less
Illustrative counts: the table begins with 1.2 million rows; the time filter leaves 40 thousand; summarize makes 300 groups; top returns four. A pipe passes its current result to the next operator. Operators such as project can change columns without reducing rows. Set the incident window before grouping so the result answers the right time-bound question.
§1a

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

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.

§2

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
§3

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.

Figure — What the sentence returns
the grid — which Name count_ avg_ms GET /api/stock 41,210 182 GET /product 12,004 210 POST /checkout 8,412 940 POST /api/upload 512 3,120 render timechart — when 00:00 24:00 same source, two different groupings — group by Name for "which"; bin time for "when"
These are two queries over the same source. Group by Name for the endpoint grid. Group by bin(TimeGenerated, 1h) for the chart, then add render timechart. Adding render to the endpoint grid alone would not produce a meaningful timeline because its rows have no time column.
Later · §4

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.

§5

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.

Table 1 — The aggregation functions worth memorising
FunctionAnswersThe catch worth remembering
count()how many rows fell in the groupcounts rows, not events, when App Insights sampling is on4
countif(predicate)how many rows also matched a conditionthe clean way to count failures beside a total in one line
dcount(X)an approximate number of distinct valuesaccuracy depends on data and settings; use count_distinct() where an exact supported count is required
sum(X) · avg(X)the total, the meanthe mean is easily seduced by a handful of outliers
min(X) · max(X)the extremes of a columnpair with arg_max(T, *) to keep the whole row at the extreme
percentile(X, 95)the value 95% of rows fall underthe 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.

§6

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.

Case File · Campux Retail

The query that was worth nine hours

written, framed, and scheduled — twenty-eight classes late

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.

Watch · Microsoft Learn

The official pages, and a CAMPUX overview

The tutorial mirrors Lab 1; keep the KQL reference open forever
Microsoft Learn · Docs

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/

Lab 1 · The ten patterns

Type all ten against Microsoft's free demo workspace

~25 minutes · browser only · Microsoft's shared sample 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
Note · the demo workspace is shared and read-only; its data shape occasionally changes. If a table is missing, pick a similar one from the schema pane — the patterns are the point, not the table names.
Lab 2 · The nine-hour query

Build the case-file query, then break it like Drill 04

~15 minutes · same demo workspace · continues Lab 1

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
Think in systems

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.

Feedback loops

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?

Dependencies & coupling

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.

The constraint

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.

Second-order effects

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.

What breaks when it scales

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.

On the job

The one who found it first

You · Cloud Engineer · an incident needs answers fast

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.

Class Twenty-Nine

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.

Drill 01Recall · operators
What is the difference between where and project?
Marked

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.

Drill 02Recall · bin
Why does summarize count() by bin(TimeGenerated, 1h) need the bin() — what happens without it?
Marked

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.

Drill 03Select three
Which three are true of KQL in a Log Analytics workspace?
Marked

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.

Drill 04Spot the error
An engineer investigates checkout failures that occurred 02:00–03:00 last night. It is now 09:30. The query runs cleanly and returns "no failures found." Which line is the mistake?
// 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
Marked

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.

Situation 01Write before you reveal
You are paged: the storefront's 500 errors spiked around 02:00 and recovered by 02:40. You open Log Analytics. Write the first query you would run, and then the second — and say what each is for.
First establish the shape, then interrogate the worst of it. Two queries, two different jobs.
Reasoning

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.

Situation 02Write before you reveal
You join a team as the new engineer. There is a well-stocked workspace — and not a single saved query; the departing engineer investigated everything from memory, and the memory has resigned. The lead asks you to fix this. What do you build, and how do you keep it alive?
You cannot write queries for incidents that haven't happened. So where do the first ones come from?
Reasoning

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.

Examination record · first attempt
0/4
Class Twenty-Nine · Complete
Retain this much

Five things worth carrying out of this class

  1. 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.
  2. 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.
  3. 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.
  4. Joins sparingly: filter both sides first, state the kind explicitly, and check whether one table already holds both facts. A semicolon, not a comma.
  5. 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.
Notes
  1. 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. ↩
  2. 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. ↩
  3. 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. ↩
  4. 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. ↩