Functional Weave
Code in Python

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 docs
  • parse_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 docs
  • parse_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]
linestringone log line; a trailing newline is ignored
returnsAccessLogEntry?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
impl/python.py · 150 lines · open · raw

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
Download for Python monitor.parse-access-log-1.0.0-python.fune · 18,060 bytes sha256 ed516504155b5396748513f7c683c6997c2b82911d7852b030f26579d053e6a2

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md2,769
impl/python.py5,212
impl/rust.rs6,668
impl/typescript.ts4,989
vectors.json5,903