Lab 01 — KQL Query Engine + Alert Evaluator
Phase: 13 — Observability, Azure Monitor & KQL | Difficulty: ⭐⭐⭐☆☆ | Time: 4–6 hours
When a service degrades at 2 a.m., the portal is not where you find the cause — the query is. Azure Monitor stores billions of log rows in a Log Analytics workspace, and you interrogate them with KQL (Kusto Query Language): a left-to-right pipeline of tabular operators (
where | extend | summarize ... by bin() | join | top) that filters, buckets, aggregates, and correlates until the signal falls out. Pull the cover off and KQL is just a sequence of pure functions over a list of rows, each returning a new list — which is exactly why a query is safe to re-run and reason about. This lab builds that engine: the operators, the aggregations a principal recites in their sleep (count/sum/avg/min/max/dcount/percentile), thebin()that turns continuous time into 5-minute buckets, an inner/leftouterjoin, and — the thing that actually pages you — an alert-rule evaluator that aggregates a metric over a lookback window and compares it to a threshold.
What you build
Table(rows)— a fluent wrapper sot.where(...).summarize(...).top(...)reads like the KQL pipe (|). Every method returns a newTable(pure; nothing is mutated). Underneath are the real engine — the operator functions, usable directly on anylist[dict].- Row-shaping operators —
where(rows, predicate)(filter),project(rows, columns)(keep/rename columns; takes a list or a{new: old}dict),extend(rows, **computed)(add columns fromcallable(row) -> value). - Ordering / windowing —
order_by(rows, key, desc)(stable sort),top(rows, n, by, desc)(the n biggest/smallest),take(rows, n),count(rows). bin_time(value, width)— thebin()function:floor(value / width) * width, flooring continuous timestamps onto an absolute grid so rows in the same interval group together insidesummarize.percentile(values, p)— the nearest-rank percentile (rank = ceil(p/100 · n)), chosen because it returns an observed value so a p95 is exactly assertable.summarize(rows, aggregations, by)— the heart of KQL: group rows by thebycolumns and reduce each group withcount/sum/avg/min/max/dcount/percentile. Deterministic group order (sorted by the group key).by=[]gives one global summary row.join(left, right, on, kind)— a hash join on a single key,innerandleftouter.AlertRule+evaluate_alert(rows, rule, *, now)— the alert: window-filter rows to[now - window, now], aggregate the metric, compare to the threshold withgt/lt/ge/le. Empty window → does not fire (no data cannot breach).
Key concepts
| Concept | What to understand |
|---|---|
| KQL is a pipeline of pure functions | each operator takes rows, returns new rows; left-to-right ` |
where / project / extend | filter rows / pick-and-rename columns / add computed columns. extend callables see the original row (chain a second extend for dependent cols — KQL's actual semantics) |
summarize ... by | group by the by tuple, one output row per group; the by-columns + the aggregates. This is GROUP BY, KQL-style |
bin(t, w) | floor(t/w)*w — buckets onto an absolute grid, not relative to your data's start; that's why a bucket can floor below t0 |
count/sum/avg/min/max | the obvious folds over a group's column values |
dcount | distinct count — cardinality. The dimension that, when high (userId, requestId), blows up both query cost and storage |
percentile | nearest-rank here (rank = ceil(p/100·n)); real Kusto approximates with T-Digest for scale — same answer shape, different cost/fidelity |
join kind=inner vs leftouter | inner emits only matched pairs; leftouter keeps every left row (right columns null when unmatched) — the difference between "drop unknowns" and "see the gaps" |
| Alert = aggregation over a window vs threshold | agg(metric over [now-window, now]) <op> threshold. The window/lookback and the operator boundary (gt vs ge) are where false pages live |
| Determinism | sorted group order, stable sort, nearest-rank percentile, copies not mutations — same input → same bytes, so tests are exact |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers and full signatures/docstrings |
solution.py | complete reference; python solution.py runs a worked example over a request log |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # worked example
Success criteria
- All 35 tests pass against your implementation.
- You can explain why
test_percentile_nearest_rank_known_answersassertspercentile([1..100], 95) == 95exactly: nearest-rank takesrank = ceil(0.95·100) = 95, the 95th-smallest value — an observed point, never an interpolated one. (Then explain why real Kusto's T-Digest answer might be off by a hair, and why that's an acceptable cost/fidelity trade at billions of rows.) - You can explain why
test_bin_time_buckets_to_gridfloors299 → 0and300 → 300:bin()lands rows on an absolute grid ofwidth-sized buckets, which is why two queries with the samebin(Timestamp, 5m)line up even though they never coordinated. - You can explain why
test_summarize_count_by_group_is_deterministicmatters — real Kusto doesn't promise group order, so a teaching engine that does (sorted by key) is testable and reproducible. - You can explain why
test_alert_boundary_gt_vs_geis the off-by-one interviewers probe: a metric of exactly300against threshold300does not fire undergtbut does underge. Pick the wrong operator and you either miss the breach or page on the boundary forever. - You can explain why
test_alert_empty_window_does_not_fireis deliberate: an empty window has no metric to breach, so it returnsFalse— modeling Azure Monitor's "no data" behavior rather than a false page (and why "no data" is sometimes itself worth a separate alert).
How this maps to real Azure (Azure Monitor + Log Analytics + Application Insights)
| The lab | Real Azure |
|---|---|
a Table (list[dict]) | a table in a Log Analytics workspace — requests, traces, dependencies, exceptions, AzureDiagnostics, Heartbeat |
the operators (where/project/extend/summarize/join/top/order by/take/count) | the KQL tabular operators, piped with ` |
bin_time(t, w) | the KQL bin(Timestamp, 5m) (a.k.a. floor) used in summarize ... by bin(...) for time-series rollups and the x-axis of every time chart |
percentile(vals, p) | KQL percentile(Duration, 95) / percentiles(...) — but the engine uses a T-Digest approximation (bounded memory, slight error) instead of exact nearest-rank |
dcount | KQL dcount(...) — an HyperLogLog approximate distinct count at scale; exact count(distinct ...) doesn't scale to billions of rows |
join kind=inner / leftouter | the KQL join kind=inner / kind=leftouter operators (Kusto also has innerunique, rightouter, fullouter, leftanti, …) |
AlertRule + evaluate_alert | a scheduled-query (log) alert or a metric alert rule: a query/aggregation evaluated on a schedule over a lookback window, breaching when aggregate <op> threshold |
window (the lookback) | the alert's aggregation granularity + lookback (evaluation) window — and for / number-of-violations to debounce flapping |
the now parameter | the alert's evaluation time; Azure runs the rule on a schedule and supplies it |
| firing → (in real life) | the rule's action group: email/SMS/push, an ITSM/PagerDuty webhook, a Logic App, an Automation runbook |
operation_Id correlation (taught in WARMUP) | Application Insights distributed tracing — every request/dependency/trace row carries operation_Id + operation_ParentId, and a join on operation_Id reassembles the call tree across services |
What the miniature leaves out (and why it's fine): real Kusto is a columnar,
distributed, indexed engine that runs over petabytes with extent caching, a cost
model, ingestion-time transforms, materialized views, cross-workspace/cross-cluster
queries, make-series / time-series functions, mv-expand, parse, and dozens more
operators; percentile/dcount are approximate (T-Digest / HyperLogLog) precisely
because exact answers don't scale. None of that changes the two mechanisms this lab
isolates — (1) a query is a left-to-right pipeline of pure operators that culminates
in a summarize-style group-and-reduce, and (2) an alert is an aggregation over a
lookback window compared to a threshold. Those are exactly the parts an interview probes
and an incident turns on.
Extensions (build these in your own workspace)
make-series/ gap-fill: add amake_series(rows, agg, on_time, step, range)that emits a dense time series (one bucket perstepacross the whole range, zero-filled), the way KQLmake-seriesdoes for charting —summarize ... by bin()leaves gaps where a bucket had no rows, and gaps lie to the eye.- Tail-based sampling: model
sample_traces(traces, head_rate)(head sampling: keep each trace with fixed probability, decided at ingress) vs a tail sampler that keeps a trace iff it contains an error or a slow span (decided after the trace completes) — then reason about the cost/fidelity trade in the WARMUP. - Multi-table correlation: build
requests+dependenciestables sharingoperation_Id, thenjointhem to compute, per request, the sum of downstream dependency time — the classic "where did the latency go?" trace query. - Alert debounce (
forwindow): extendevaluate_alertto require the threshold be breached for N consecutive evaluations before firing (Azure's "number of violations" /forclause) — the single most effective noise-reduction knob. - Wire it to real Azure:
az monitor log-analytics workspace create; send a few custom logs; run your KQL in the portal's Logs blade (requests | summarize p95=percentile( DurationMs,95) by bin(TimeGenerated, 5m), Service); thenaz monitor scheduled-query createan alert on it and watch it fire into an action group.
Interview / resume
- Talking points: "Walk me through a KQL query that finds the p95-latency regression by
service and 5-minute bin." / "How does
percentilescale to billions of rows — what's T-Digest costing you?" / "Why does high-cardinalitydcount(per userId) blow up cost, and what do you do instead?" / "Metric alert vs log (scheduled-query) alert — when each, and how do you stop it flapping?" / "How doesoperation_Idreconstruct a distributed trace across five services?" - Resume bullet: Built a miniature KQL query engine — the tabular operators
(
where/project/extend/summarize ... by bin()/join/top), the aggregations (count/sum/avg/min/max/dcount/nearest-rankpercentile), and a window-aggregation alert-rule evaluator — modeling Azure Monitor Logs, Log Analytics, and Application Insights alerting.