Application logs arrive as one long line of text. You cannot take a p95 of "...duration_ms=118..." - you need 118 as a number in its own column first. This lesson is how to cut fields out of text and out of JSON, the KQL version of what awk and jq did for you in Ch 7.
What you need to know already: 24.3 (where, extend, verbatim strings), 24.5 (summarize, percentiles), Ch 7 (regular expressions, awk fields, jq paths), 22.3 (JSON).
A payments log line:
2026-09-24T09:21:44.120Z INFO [payments-api] POST /api/payments/authorize status=200 duration_ms=118 trace=5c1e0a97d3f24b1c
Time, level, service, method and path, then key=value fields. trace= is a request ID: a random ID the app puts on every log line that belongs to the same request, so you can find all of them later.
parse: when the shape is fixed
ContainerLogV2
| where PodNamespace == "payments"
| parse LogMessage with * "status=" status:int " duration_ms=" ms:long " trace=" trace
| where isnotempty(trace)
| summarize avg(ms), max(ms) by status
parse <column> with <pattern> walks the text left to right. The pattern is made of literals (the exact text in quotes that must be there, like "status=") and captures (name:type - "whatever comes next, up to the following literal, goes into a new column called name, converted to type"). * means "skip anything". int/long are whole numbers, real has decimals; no type means string. Lines that do not match get empty/null captures - filter them out. Cheap and readable when every line has the same shape.
extract: one field, by regex
ContainerLogV2
| extend ms = toint(extract(@"duration_ms=(\d+)", 1, tostring(LogMessage)))
| where isnotnull(ms)
| summarize percentiles(ms, 50, 95, 99) by PodNamespace
extract(regex, group, text) runs a regular expression (Ch 7) over the text and returns the part inside the Nth pair of parentheses (the capture group; here group 1 is (\d+), the digits) as a string, or empty if nothing matched; convert with toint(), todouble(). Use verbatim strings (@"...") so the backslashes survive. Regex scans every row - filter with has first.
LogMessage is dynamic
dynamic is KQL's type for a JSON value - an object, an array, or a plain value. In ContainerLogV2, LogMessage is dynamic: if the container logged JSON, it is already parsed and you can walk into it with a dot, like a jq path (Ch 7):
ContainerLogV2 | where LogMessage.level == "error" | project LogMessage.msg
For plain text it is just a string, and string functions need tostring(LogMessage). Structured JSON logging from the app is worth more than any clever regex.
JSON in columns
Many columns hold JSON as strings - KubeNodeInventory.Labels, AzureActivity.Properties, ContainerLastStatus:
KubeNodeInventory
| summarize arg_max(TimeGenerated, Labels) by Computer
| extend pool = tostring(parse_json(Labels)[0].agentpool)
AzureActivity
| extend role = tostring(parse_json(Properties).roleDefinitionName)
parse_json() turns the string into a dynamic value; [0], .key walk into it; wrap the result in tostring() before grouping or comparing.
mv-expand (multi-value expand) turns an array into one row per element. print makes a one-row table from values you type, handy for trying things:
print tags = dynamic(["a", "b", "c"]) | mv-expand tags
Which to use
has / contains find the lines
parse fixed layout, several fields at once
extract one field, flexible layout
parse_json + .field JSON strings
Filter before you parse: where LogMessage has "duration_ms" then extract. A regex over every log line of the last day is the query that times out.
Worked: an access log line
10.0.4.17 - - [24/Sep/2026:09:21:44 +0000] "POST /api/payments/refund HTTP/1.1" 502 157 "-" "okhttp/4.12" 30.004
ContainerLogV2
| where ContainerName == "nginx"
| parse LogMessage with ip " - - [" ts "] \"" method " " path " " proto "\" " status:int " " bytes:long " \"" * "\" \"" ua "\" " rt:real
| where status >= 500
| summarize n = count(), p95 = percentile(rt, 95) by path, status
(Chapter 7 did the same with awk; the pattern thinking is identical.) Note the escaped quotes inside the pattern, and that status:int lets you compare numerically afterwards.
When parse returns nothing
Every capture empty means the literal parts did not match: an extra space, a different quote character, a field that is sometimes missing. Test on one row with take 1, and prefer extract for fields that move around.
What you can now do
- Pull several fields out of fixed-layout log lines with
parse, typed so you can do maths on them. - Pull one field out by regex with
extract, and convert it withtoint. - Read values inside JSON columns with
parse_json(...)andtostring().