monitor.parse-access-log
Parse one Apache/NCSA Common or Combined Log Format access log line; null for a line in neither format.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 21 tests, run in TypeScript, Python and Rust.
What it does
Parses one line of a web server access log in the NCSA Common Log Format or the Combined Log Format, the defaults of Apache httpd and nginx (nginx's `combined` is the same layout):
Common %h %l %u %t "%r" %>s %b
Combined %h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-agent}i"
127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326 "http://www.example.com/start.html" "Mozilla/4.08 [en] (Win98; I ;Nav)"
For example
parse_access_log(127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326)→ remote host 127.0.0.1, ident —, user frank, at 971,211,336, method GET, path /apache_pb.gif, protocol HTTP/1.0, status 200, bytes 2,326, referer —, user agent — the Common Log Format example from the Apache docsparse_access_log(127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326 "http://www.example.com/start.html" "Mozilla/4.08 [en] (Win98; I ;Nav)")→ remote host 127.0.0.1, ident —, user frank, at 971,211,336, method GET, path /apache_pb.gif, protocol HTTP/1.0, status 200, bytes 2,326, referer http://www.example.com/start.html,… the Combined Log Format example from the Apache docsparse_access_log(192.168.1.5 - - [28/Sep/2026:09:15:00 +0000] "GET /index.html HTTP/1.1" 304 -)→ remote host 192.168.1.5, ident —, user —, at 1,790,586,900, method GET, path /index.html, protocol HTTP/1.1, status 304, bytes —, referer —, user agent — a 304 with no body logs bytes as -, which is null
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
def parse_access_log(line: str) -> Optional[AccessLogEntry]
| line | string | one log line; a trailing newline is ignored |
| returns | AccessLogEntry? | null when the line is not in Common or Combined Log Format |
The type it declares, generated into your project
@dataclass(frozen=True)
class AccessLogEntry:
"""One request, as the web server logged it. A "-" field is null."""
#: %h, the client address (or name)
remote_host: str
#: %l, identd answer, almost always null
ident: Optional[str]
#: %u, the authenticated user
user: Optional[str]
#: %t as Unix seconds
at: int
#: from %r; null when the request line is "-" or malformed
method: Optional[str]
#: from %r, as logged (still percent-encoded)
path: Optional[str]
#: from %r, e.g. HTTP/1.1; null for an HTTP/0.9 request
protocol: Optional[str]
#: %>s
status: int
#: %b, body bytes; null for "-", which Apache writes for no body
bytes: Optional[int]
#: Combined only
referer: Optional[str]
#: Combined only
user_agent: Optional[str]
Your code names it in one line, in the file that uses it
from fune.monitor.parse_access_log import parse_access_log # monitor.parse-access-log@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import Optional
from .time_iso_to_unix import iso_to_unix ← from time.iso-to-unix ^1.0.0 · built alongside by fune
from .monitor_parse_access_log_types import AccessLogEntry
_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
def _at(t: str, i: int) -> str:
return t[i] if 0 <= i < len(t) else ""
def _digits(text: str, start: int, count: int) -> Optional[int]:
n = 0
for i in range(start, start + count):
c = _at(text, i)
# Not str.isdigit(): that accepts other scripts' digits and superscripts.
if not ("0" <= c <= "9") or c == "":
return None
n = n * 10 + ord(c) - 48
return n
def _dash(field: str) -> Optional[str]:
return None if field == "-" else field
def _parse_time(t: str, i: int) -> Optional[int]:
"""The time field, e.g. [10/Oct/2000:13:55:36 -0700], starting at `i` to Unix seconds, or None."""
for off, ch in ((0, "["), (3, "/"), (7, "/"), (12, ":"), (15, ":"), (18, ":"), (21, " "), (27, "]")):
if _at(t, i + off) != ch:
return None
day = _digits(t, i + 1, 2)
name = t[i + 4:i + 7]
month = _MONTHS.index(name) + 1 if name in _MONTHS else 0
year = _digits(t, i + 8, 4)
hour = _digits(t, i + 13, 2)
minute = _digits(t, i + 16, 2)
second = _digits(t, i + 19, 2)
sign = _at(t, i + 22)
oh = _digits(t, i + 23, 2)
om = _digits(t, i + 25, 2)
if day is None or year is None or hour is None or minute is None or second is None or oh is None or om is None:
return None
if month == 0 or year < 1 or hour > 23 or minute > 59 or second > 59 or oh > 23 or om > 59:
return None
if sign != "+" and sign != "-":
return None
leap = (year % 4 == 0 and year % 100 != 0) or year % 400 == 0
month_days = [31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]
if day < 1 or day > month_days:
return None
# Every field is checked above, so iso_to_unix cannot raise here.
return iso_to_unix("%s-%02d-%sT%s%s%s:%s" % (t[i + 8:i + 12], month, t[i + 1:i + 3], t[i + 13:i + 21], sign, t[i + 23:i + 25], t[i + 25:i + 27]))
def _closing_quote(t: str, i: int) -> int:
"""The index of the quote closing a field that opens at `i`, honouring \\" escapes; -1 if none."""
if _at(t, i) != '"':
return -1
j = i + 1
while j < len(t):
if t[j] == "\\":
j += 1
elif t[j] == '"':
return j
j += 1
return -1
def _is_method(m: str) -> bool:
return len(m) > 0 and all("A" <= c <= "Z" for c in m)
def parse_access_log(line: str) -> Optional[AccessLogEntry]:
"""One Common or Combined Log Format line, or None when the line is in
neither format: logs hold junk, and a reader should skip it rather than stop."""
t = line
while t.endswith("\n") or t.endswith("\r"):
t = t[:-1]
host_end = t.find(" ")
if host_end < 1:
return None
ident_end = t.find(" ", host_end + 1)
if ident_end < host_end + 2:
return None
user_end = t.find(" [", ident_end + 1)
if user_end < ident_end + 2:
return None
at = _parse_time(t, user_end + 1)
if at is None:
return None
i = user_end + 29
if _at(t, i) != " ":
return None
req_end = _closing_quote(t, i + 1)
if req_end < 0:
return None
request = t[i + 2:req_end]
i = req_end + 1
# A three-digit status, then the byte count: both are required.
if _at(t, i) != " " or _at(t, i + 4) != " ":
return None
status = _digits(t, i + 1, 3)
if status is None or status < 100 or status > 599:
return None
i += 4
bytes_end = t.find(" ", i + 1)
if bytes_end < 0:
bytes_end = len(t)
bytes_text = t[i + 1:bytes_end]
size: Optional[int] = None
if bytes_text != "-":
if len(bytes_text) < 1 or len(bytes_text) > 15:
return None
size = _digits(bytes_text, 0, len(bytes_text))
if size is None:
return None
referer: Optional[str] = None
user_agent: Optional[str] = None
if bytes_end < len(t):
ref_end = _closing_quote(t, bytes_end + 1)
if ref_end < 0 or _at(t, ref_end + 1) != " ":
return None
ua_end = _closing_quote(t, ref_end + 2)
if ua_end < 0 or (ua_end + 1 < len(t) and t[ua_end + 1] != " "):
return None
referer = _dash(t[bytes_end + 2:ref_end])
user_agent = _dash(t[ref_end + 3:ua_end])
method: Optional[str] = None
path: Optional[str] = None
protocol: Optional[str] = None
parts = request.split(" ")
if len(parts) == 3 and _is_method(parts[0]) and parts[1] != "" and parts[2].startswith("HTTP/"):
method, path, protocol = parts
elif len(parts) == 2 and _is_method(parts[0]) and parts[1] != "":
method, path = parts
return AccessLogEntry(
remote_host=t[:host_end],
ident=_dash(t[host_end + 1:ident_end]),
user=_dash(t[ident_end + 1:user_end]),
at=at,
method=method,
path=path,
protocol=protocol,
status=status,
bytes=size,
referer=referer,
user_agent=user_agent,
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add monitor.parse-access-log
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./monitor.parse-access-log-1.0.0-python.fune, or fetch it from a terminal with fune pull monitor.parse-access-log@1.0.0:python.
The whole function, every language, is one file too: monitor.parse-access-log-1.0.0.fune, 30,251 bytes, sha256 5b2da1cd322e4f8cba9f69c62260dedb034c0c0fafa5c19b429c094b3dfe194d. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
# fune: before monitor.parse-access-log
after — your function gets the result and the arguments, and returns the final result.
# fune: after monitor.parse-access-log
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
# fune: replace time.iso-to-unix in monitor.parse-access-log
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show monitor.parse-access-log --steps.
# fune: step monitor.parse-access-log after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| the Common Log Format example from the Apache docs | 127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326 | → | remote host 127.0.0.1, ident —, user frank, at 971,211,336, method GET, path /apache_pb.gif, protocol HTTP/1.0, status 200, bytes 2,326, referer —, user agent — |
| the Combined Log Format example from the Apache docs | 127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326 "http://www.example.com/start.html" "Mozilla/4.08 [en] (Win98; I ;Nav)" | → | remote host 127.0.0.1, ident —, user frank, at 971,211,336, method GET, path /apache_pb.gif, protocol HTTP/1.0, status 200, bytes 2,326, referer http://www.example.com/start.html,… |
| a 304 with no body logs bytes as -, which is null | 192.168.1.5 - - [28/Sep/2026:09:15:00 +0000] "GET /index.html HTTP/1.1" 304 - | → | remote host 192.168.1.5, ident —, user —, at 1,790,586,900, method GET, path /index.html, protocol HTTP/1.1, status 304, bytes —, referer —, user agent — |
| a request line of - (client timed out) keeps the line with no method, path or protocol | 10.0.0.1 - - [28/Sep/2026:09:15:00 +0100] "-" 408 - | → | remote host 10.0.0.1, ident —, user —, at 1,790,583,300, method —, path —, protocol —, status 408, bytes —, referer —, user agent — |
| a TLS handshake sent to a plain HTTP port is a malformed request, still counted | 10.0.0.1 - - [28/Sep/2026:09:15:00 +0000] "\x16\x03\x01\x02\x00\x01\x00\x01\xfc\x03\x03" 400 226 | → | remote host 10.0.0.1, ident —, user —, at 1,790,586,900, method —, path —, protocol —, status 400, bytes 226, referer —, user agent — |
| an escaped quote inside a field does not end it, and is returned as logged | 203.0.113.9 - - [28/Sep/2026:23:59:59 +0000] "POST /api/v1/login?next=%2F HTTP/2.0" 201 17 "-" "curl \"test\"/8.4" | → | remote host 203.0.113.9, ident —, user —, at 1,790,639,999, method POST, path /api/v1/login?next=%2F, protocol HTTP/2.0, status 201, bytes 17, referer —, user agent curl \"test\"/… |
| an IPv6 client, an offset that crosses into the previous year, and an extra field after the user agent | 2001:db8::1 - - [01/Jan/2026:00:30:00 +0100] "GET / HTTP/1.1" 200 512 "https://example.com/" "Mozilla/5.0" 0.004 | → | remote host 2001:db8::1, ident —, user —, at 1,767,223,800, method GET, path /, protocol HTTP/1.1, status 200, bytes 512, referer https://example.com/, user agent Mozilla/5.0 |
| a half-hour negative offset | 10.2.3.4 ident42 alice [31/Dec/1999:23:59:59 -0530] "DELETE /items/7 HTTP/1.1" 204 0 | → | remote host 10.2.3.4, ident ident42, user alice, at 946,704,599, method DELETE, path /items/7, protocol HTTP/1.1, status 204, bytes 0, referer —, user agent — |
| a trailing CRLF from the file is ignored | 127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326 | → | remote host 127.0.0.1, ident —, user frank, at 971,211,336, method GET, path /apache_pb.gif, protocol HTTP/1.0, status 200, bytes 2,326, referer —, user agent — |
| an HTTP/0.9 request has no protocol | 127.0.0.1 - - [29/Feb/2024:12:00:00 +0000] "GET /" 200 10 | → | remote host 127.0.0.1, ident —, user —, at 1,709,208,000, method GET, path /, protocol —, status 200, bytes 10, referer —, user agent — |
Show the other 11 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a lowercase method is not a request line | 127.0.0.1 - - [29/Feb/2024:12:00:00 +0000] "get / HTTP/1.1" 400 0 | → | remote host 127.0.0.1, ident —, user —, at 1,709,208,000, method —, path —, protocol —, status 400, bytes 0, referer —, user agent — |
| 29 February in a non-leap year is not a date, so not a log line | 127.0.0.1 - - [29/Feb/2023:12:00:00 +0000] "GET / HTTP/1.1" 200 10 | → | — |
| a leap second is not a Unix time | 127.0.0.1 - - [31/Dec/2016:23:59:60 +0000] "GET / HTTP/1.1" 200 10 | → | — |
| an unknown month is junk | 127.0.0.1 - - [10/Foo/2000:13:55:36 -0700] "GET / HTTP/1.1" 200 10 | → | — |
| a status outside 100 to 599 is junk | 127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET / HTTP/1.1" 999 10 | → | — |
| text after the byte count that is not the Combined fields is junk | 127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326 extra | → | — |
| an unterminated request is junk | 127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET / HTTP/1.1 200 10 | → | — |
| a line with no status or bytes is junk | 127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET / HTTP/1.1" | → | — |
| non-ASCII digits in the status are not digits | 127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET / HTTP/1.1" ٢٠٠ 10 | → | — |
| an empty line is null | → | — | |
| some other log format is null | Sep 28 09:15:00 web1 sshd[123]: Accepted publickey for root | → | — |
More from the author
Feed it `monitor.status-code-summary` for error rates, or `monitor.rollup` for requests per minute.
## Decisions
- **Junk is null, not an error.** Real logs hold truncated lines, lines from another format and binary noise; a log reader should skip them and count them, not stop. So anything not in either format returns null: a bad month, 30 February, a status outside 100 to 599, stray text after the byte count. - **"-" is null** in every field that uses it (ident, user, bytes, referer, user agent). Apache's `%b` writes "-" rather than 0 when no body was sent (a 304, say): treat a null `bytes` as 0 if you are summing. - **The request line.** `"-"` (the client sent nothing before timing out) and anything that is not `METHOD path HTTP/x` (a TLS handshake sent to a plain HTTP port logs as `"\x16\x03\x01..."`) keep the line, with method, path and protocol null: the status (typically 400 or 408) is still worth counting. `METHOD path` with no protocol is an HTTP/0.9 request: protocol null. - **Escapes are kept as logged.** Apache writes `"` as `\"`, `\` as `\\` and non-printable bytes as `\xhh` inside quoted fields. The parser honours `\"` when finding the end of a field but returns the text as it appears in the log, so no information is lost and nothing is decoded twice. - **Extra fields after the user agent are ignored**, since many sites append response time or a request id to Combined. After a Common line's byte count nothing may follow except the two quoted Combined fields. - **Time** `[10/Oct/2000:13:55:36 -0700]` is checked field by field (English month abbreviations, real calendar dates, leap years, a numeric offset) and converted with `time.iso-to-unix`, so `at` is exact Unix seconds (UTC). A leap second (`:60`) is not a Unix time and makes the line null. - A trailing `\n` or `\r\n` is stripped, so lines straight from a file work.
## Sources
- Apache HTTP Server 2.4, mod_log_config: "Common Log Format" and "Combined Log Format", the `%b` "-" for no bytes, the `%t` format and the escaping of `%r`, `%i` and `%u`, https://httpd.apache.org/docs/2.4/mod/mod_log_config.html - Apache HTTP Server 2.4, Log Files (the example lines and field-by-field explanation), https://httpd.apache.org/docs/2.4/logs.html#accesslog
Files
| Path | Bytes |
|---|---|
| README.md | 2,769 |
| impl/python.py | 5,212 |
| impl/rust.rs | 6,668 |
| impl/typescript.ts | 4,989 |
| vectors.json | 5,903 |