Functional Weave
Code in TypeScript

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

  • parseAccessLog(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
  • parseAccessLog(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
  • parseAccessLog(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.

export function parseAccessLog(line: string): AccessLogEntry | null
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

/** One request, as the web server logged it. A "-" field is null. */
export interface AccessLogEntry {
  /** %h, the client address (or name) */
  readonly remoteHost: string;
  /** %l, identd answer, almost always null */
  readonly ident: string | null;
  /** %u, the authenticated user */
  readonly user: string | null;
  /** %t as Unix seconds */
  readonly at: number;
  /** from %r; null when the request line is "-" or malformed */
  readonly method: string | null;
  /** from %r, as logged (still percent-encoded) */
  readonly path: string | null;
  /** from %r, e.g. HTTP/1.1; null for an HTTP/0.9 request */
  readonly protocol: string | null;
  /** %>s */
  readonly status: number;
  /** %b, body bytes; null for "-", which Apache writes for no body */
  readonly bytes: number | null;
  /** Combined only */
  readonly referer: string | null;
  /** Combined only */
  readonly userAgent: string | null;
}

Your code names it in one line, in the file that uses it

import { parseAccessLog } from "#fune/monitor.parse-access-log@^1";
impl/typescript.ts · 120 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.

import { isoToUnix } from "./time_iso_to_unix.ts";  ← from time.iso-to-unix ^1.0.0 · built alongside by fune
import { type AccessLogEntry } from "./monitor_parse_access_log_types.ts";

const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

const isDigit = (c: string | undefined): boolean => c !== undefined && c >= "0" && c <= "9";

function digits(text: string, from: number, count: number): number | null {
  let n = 0;
  for (let i = from; i < from + count; i++) {
    if (!isDigit(text[i])) return null;
    n = n * 10 + (text.charCodeAt(i) - 48);
  }
  return n;
}

const dash = (field: string): string | null => (field === "-" ? null : field);

/** "[10/Oct/2000:13:55:36 -0700]" starting at `i` to Unix seconds, or null. */
function parseTime(t: string, i: number): number | null {
  if (t[i] !== "[" || t[i + 3] !== "/" || t[i + 7] !== "/" || t[i + 12] !== ":" || t[i + 15] !== ":" || t[i + 18] !== ":" || t[i + 21] !== " " || t[i + 27] !== "]") return null;
  const day = digits(t, i + 1, 2);
  const month = MONTHS.indexOf(t.slice(i + 4, i + 7)) + 1;
  const year = digits(t, i + 8, 4);
  const hour = digits(t, i + 13, 2);
  const minute = digits(t, i + 16, 2);
  const second = digits(t, i + 19, 2);
  const sign = t[i + 22];
  const oh = digits(t, i + 23, 2);
  const om = digits(t, i + 25, 2);
  if (day === null || year === null || hour === null || minute === null || second === null || oh === null || om === null) return null;
  if (month === 0 || year < 1 || hour > 23 || minute > 59 || second > 59 || oh > 23 || om > 59) return null;
  if (sign !== "+" && sign !== "-") return null;
  const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
  const monthDays = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
  if (day < 1 || day > monthDays) return null;
  // Every field is checked above, so isoToUnix cannot throw here.
  return isoToUnix(`${t.slice(i + 8, i + 12)}-${String(month).padStart(2, "0")}-${t.slice(i + 1, i + 3)}T${t.slice(i + 13, i + 21)}${sign}${t.slice(i + 23, i + 25)}:${t.slice(i + 25, i + 27)}`);
}

/** The end (index of the closing quote) of a quoted field opening at `i`, honouring \" escapes; -1 if none. */
function closingQuote(t: string, i: number): number {
  if (t[i] !== '"') return -1;
  for (let j = i + 1; j < t.length; j++) {
    if (t[j] === "\\") j++;
    else if (t[j] === '"') return j;
  }
  return -1;
}

const isMethod = (m: string): boolean => m.length > 0 && [...m].every((c) => c >= "A" && c <= "Z");

/**
 * One Common or Combined Log Format line, or null when the line is in neither
 * format: logs hold junk, and a reader should skip it rather than stop.
 */
export function parseAccessLog(line: string): AccessLogEntry | null {
  let t = line;
  while (t.endsWith("\n") || t.endsWith("\r")) t = t.slice(0, -1);
  const hostEnd = t.indexOf(" ");
  if (hostEnd < 1) return null;
  const identEnd = t.indexOf(" ", hostEnd + 1);
  if (identEnd < hostEnd + 2) return null;
  const userEnd = t.indexOf(" [", identEnd + 1);
  if (userEnd < identEnd + 2) return null;
  const at = parseTime(t, userEnd + 1);
  if (at === null) return null;
  let i = userEnd + 29;
  if (t[i] !== " ") return null;
  const reqEnd = closingQuote(t, i + 1);
  if (reqEnd < 0) return null;
  const request = t.slice(i + 2, reqEnd);
  i = reqEnd + 1;
  // A three-digit status, then the byte count: both are required.
  if (t[i] !== " " || t[i + 4] !== " ") return null;
  const status = digits(t, i + 1, 3);
  if (status === null || status < 100 || status > 599) return null;
  i += 4;
  let bytesEnd = t.indexOf(" ", i + 1);
  if (bytesEnd < 0) bytesEnd = t.length;
  const bytesText = t.slice(i + 1, bytesEnd);
  let bytes: number | null = null;
  if (bytesText !== "-") {
    if (bytesText.length < 1 || bytesText.length > 15) return null;
    bytes = digits(bytesText, 0, bytesText.length);
    if (bytes === null) return null;
  }
  let referer: string | null = null;
  let userAgent: string | null = null;
  if (bytesEnd < t.length) {
    const refEnd = closingQuote(t, bytesEnd + 1);
    if (refEnd < 0 || t[refEnd + 1] !== " ") return null;
    const uaEnd = closingQuote(t, refEnd + 2);
    if (uaEnd < 0 || (uaEnd + 1 < t.length && t[uaEnd + 1] !== " ")) return null;
    referer = dash(t.slice(bytesEnd + 2, refEnd));
    userAgent = dash(t.slice(refEnd + 3, uaEnd));
  }
  let method: string | null = null;
  let path: string | null = null;
  let protocol: string | null = null;
  const parts = request.split(" ");
  if (parts.length === 3 && isMethod(parts[0]) && parts[1] !== "" && parts[2].startsWith("HTTP/")) {
    [method, path, protocol] = parts;
  } else if (parts.length === 2 && isMethod(parts[0]) && parts[1] !== "") {
    [method, path] = parts;
  }
  return {
    remoteHost: t.slice(0, hostEnd),
    ident: dash(t.slice(hostEnd + 1, identEnd)),
    user: dash(t.slice(identEnd + 1, userEnd)),
    at,
    method,
    path,
    protocol,
    status,
    bytes,
    referer,
    userAgent,
  };
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the TypeScript 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 TypeScript monitor.parse-access-log-1.0.0-typescript.fune · 17,798 bytes sha256 7fe3f775a6afa759d27cf1f469f77e130b2c71cef3312e479236020202956021

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./monitor.parse-access-log-1.0.0-typescript.fune, or fetch it from a terminal with fune pull monitor.parse-access-log@1.0.0:typescript.

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