Parsing IIS Logs with a Small F# Script
Australian web teams running Microsoft stacks often inherit IIS servers with months of compressed log files. Whether you're maintaining intranet sites for a Brisbane council or running hosting infrastructure out of Melbourne, sifting through W3C fields by hand is tedious work. Logs arrive in u_exYYMMDD.log files inside %SystemDrive%\inetpub\logs\LogFiles, and once you have more than a handful, the built-in IIS Log Viewer in IIS Manager stops being useful. You want something scriptable, repeatable, and quick to tweak.
F# Interactive (FSI) is an excellent fit for this kind of ad-hoc task. Because the language leans on immutability and expression-based syntax, you can pipe data through transformations the way you would in a shell, but with proper types and pattern matching underneath. The .NET SDK ships FSI on every platform, and the same script can later be promoted into a small console app without rewriting the parsing logic.
This piece walks through a minimal F# script that opens an IIS log file, splits each W3C record into fields, filters by status code, and groups hits by endpoint. By the end you'll have a reusable starting point for log forensics, capacity planning, or just answering that urgent Slack message about why the API was throwing 500s at 2am AEST.
Anatomy of a W3C log entry
W3C extended format is the default IIS logging format. Each line begins with a # for comments, and the first non-comment line is a #Fields: directive that names every column. A typical IIS request looks like this:
2024-08-12 23:14:07 192.168.1.42 GET /api/widgets - 80 - 10.0.0.5 Mozilla/5.0 200 0 0 412
The fields you'll reach for most often are date, time, cs-method, cs-uri-stem, sc-status, time-taken, and c-ip. Custom fields like cs(User-Agent) or cs(Referer) appear when the site enables them. Knowing which fields your site emits matters, because the parser needs to index by position rather than name unless you build a field map.
Installing F# Interactive
Install the latest .NET SDK from Microsoft, then open a Command Prompt or PowerShell window and type dotnet fsi. You'll be dropped into an interactive REPL with a > prompt. The script you'll write here can either be typed in directly or saved to a .fsx file and executed with dotnet fsi script.fsx.
Visual Studio Code with the Ionide extension gives you syntax highlighting and inline errors while you edit the script, which is handy if you're new to F#. For one-off analysis, plain dotnet fsi in a terminal is faster — no project file, no build step, just results.
A note for sysadmins in shops where PowerShell is mandatory: FSI scripts can be invoked from PowerShell, and the .NET objects they return are usable directly. So you don't have to choose between the two stacks.
Reading the log file line by line
The first step is to stream the file rather than load it whole. IIS logs can grow into the gigabytes when a busy site has been running for months, which is common for Australian e-commerce sites during end-of-financial-year sales.
open System.IO
let lines =
File.ReadLines("C:\\inetpub\\logs\\LogFiles\\u_ex240812.log")
|> Seq.filter (fun l -> not (l.StartsWith "#"))
Seq.filter drops the comment lines that begin with #. At this point lines is a lazy sequence, so no work happens until you iterate. That matters when you're peeking at a 4 GB file and only want the error rows.
Parsing fields with pattern matching
F#'s pattern matching makes short work of splitting a tab-delimited line into a record. A minimal parser looks like this:
type LogEntry = {
Timestamp: string
Method: string
Path: string
Status: int
Duration: int
}
let parse (line: string) =
let parts = line.Split '\t'
{
Timestamp = parts.[0] + " " + parts.[1]
Method = parts.[3]
Path = parts.[4]
Status = parts.[8] |> int
Duration = parts.[10] |> int
}
Because record fields are immutable, every transform downstream produces a new sequence rather than mutating shared state. That fits the way IIS logs are conceptually immutable — each request happened once and won't change.
Filtering requests by status code
Most of the time you don't want every record, you want the failures. Combining the parser with a filter:
let errors =
lines
|> Seq.map parse
|> Seq.filter (fun e -> e.Status >= 500)
For an Australian retailer whose checkout flow broke at midnight AEST, this line alone is often enough to triage. You can chain additional filters for time windows, paths, or specific subnets — useful when an internal monitoring service in Sydney is hammering a path that real customers never touch.
Active patterns let you give names to the status code buckets:
let (|ClientError|ServerError|Success|Other|) code =
match code with
| c when c >= 500 -> ServerError
| c when c >= 400 -> ClientError
| c when c >= 200 -> Success
| _ -> Other
Now a match entry.Status with | ServerError -> ... reads like English.
Aggregating hits and writing them out
Once you have parsed records, grouping by path turns the log into something resembling an analytics dashboard. From there, writing a CSV is a small step.
open System.Text
let hitsByPath =
lines
|> Seq.map parse
|> Seq.groupBy (fun e -> e.Path)
|> Seq.map (fun (path, entries) ->
path, Seq.length entries, entries |> Seq.averageBy (fun e -> float e.Duration))
|> Seq.sortByDescending (fun (_, n, _) -> n)
let writeCsv (rows: seq<string * int * float>) (path: string) =
let sb = StringBuilder()
sb.AppendLine "path,count,avg_ms" |> ignore
for p, n, avg in rows do
sb.AppendLine(sprintf "%s,%d,%.1f" p n avg) |> ignore
File.WriteAllText(path, sb.ToString())
writeCsv hitsByPath "C:\\reports\\hits.csv"
Sorting by count surfaces your hottest endpoints first, which is what you want when chasing performance wins on a busy Australian media site or a government portal that's been slammed since 9am AEST. If you'd rather see error rates per endpoint, swap Seq.length for Seq.filter (fun e -> e.Status >= 500) >> Seq.length and divide.
From the CSV you can hand the file to Power BI, paste it into Excel, or feed it into Grafana via the CSV datasource. If you're running this regularly, the script can be wrapped in a small console project and dropped onto a scheduled task on the IIS box itself. Storage planning for those growing log archives is its own discipline — if you're weighing controller cards against ZFS pools for the media server next to your IIS host, hardware versus software RAID lays out the options clearly.
Practical tips for log scripts in F#
- Open the file with
File.ReadLinesrather thanReadAllLinesso memory stays flat regardless of file size. - Keep the parser pure — return a record, never print inside
parse. - Use
Seq.pipechains (|>) so each step is independently testable. - Match on
Resultrather than throwing exceptions for malformed lines, and accumulate errors separately. - Prefer
int.TryParseoverintwhen the field might be-, a common value for missingcs-uri-stem. - Keep the field index map in a comment at the top of the script — your future self at 2am will thank you.
- Version-control the
.fsxfile in the same repo as the IIS site config so changes stay aligned.
If you've been staring at IIS Manager and wishing for a saner way to slice a log, the script above is a starting point. Open a terminal, run dotnet fsi, and start piping. Once you've got it parsing your own logs, you'll find F# pulls double duty as both a quick analysis tool and a foundation for a more serious log pipeline when the time comes.
Karl's blog collects pieces on a wide range of operational and infrastructure topics — and if your interests wander further afield than HTTP status codes, you can also find a curious look at live craps payouts sitting alongside the technical writing.
Karl Katzke