Log inGet started

logs

Updated 5 September 2026

The logs namespace — 30 functions.

globals/logs/clear

logs.clear() -> boolean

Drop all buffered log entries. Lifetime per-level counts (logs.count) are preserved.

Returns boolean — True on success.

logs.clear()

globals/logs/count

logs.count(opts: LogQueryOpts?) -> LogCounts

Aggregate counters for the log ring. Lifetime counts survive eviction, so errors reflects the total seen even if the lines have scrolled out of the buffer. opts takes the same filter table as logs.query, and matched is how many held entries it selects, counted without materialising them — limit and newest_first bound and order what a query RETURNS, so they leave matched alone. mcp is how many held entries record your own tool traffic; a query leaves those out, so with no opts, matched + mcp is everything held. last_seq is the cursor for incremental polling: read it before an action, then pass it as logs.query({ since = <that> }) afterwards to see only what the action logged.

Parameters

  • opts LogQueryOpts (optional) — Filter options, as logs.query takes.

Returns LogCounts — Counts summary table.

print("errors:", logs.count().errors)
local before = logs.count().last_seq

globals/logs/errors

logs.errors(limit: number?) -> { LogEntry }

Most-recent ERROR-level entries (newest first). limit defaults to 100.

Parameters

  • limit number (optional) — Maximum entries to return.

Returns { LogEntry } — Array of ERROR log-entry tables.

for _, e in ipairs(logs.errors(20)) do print(e.message) end

globals/logs/find

logs.find(text: string, limit: number?) -> { LogEntry }

Case-insensitive substring search over log messages. limit defaults to 200 (keeps the most recent matches). Searches what the engine logged, so looking for a marker cannot return the call that looked for it; logs.query({ contains = ..., include_mcp = true }) searches your own tool traffic too.

Parameters

  • text string — Substring to search for.
  • limit number (optional) — Maximum entries to return.

Returns { LogEntry } — Array of matching log-entry tables in chronological order.

local hits = logs.find("MY_MARKER")

globals/logs/query

logs.query(opts: LogQueryOpts?) -> { LogEntry }

Query the engine's in-memory log ring — the filtered view of what also reads as plain text at /zero/runtime/logs/engine. Answers about what the engine logged: the MCP record of your own tool traffic is left out, because the call carrying the query is one of those records and an unqualified search would match itself. type = "MCP" selects them; include_mcp = true mixes them in with everything else. On a world several sessions share, origin = "local" narrows the answer to the lines this session's own authoring caused.

Parameters

  • opts LogQueryOpts (optional) — Filter options.

Returns { LogEntry } — Array of matching log-entry tables.

logs.query({ entity = "guard-1", limit = 20 })
logs.query({ level = "error", context = 3 })
for _, e in ipairs(logs.query({ level = "warn", limit = 50 })) do print(e.message) end

globals/logs/tail

logs.tail(limit: number?) -> { LogEntry }

Most-recent entries of any level in chronological order. limit defaults to 100.

Parameters

  • limit number (optional) — Maximum entries to return.

Returns { LogEntry } — Array of the most recent log-entry tables.

for _, e in ipairs(logs.tail(20)) do print(e.level, e.message) end

globals/logs/template

logs.template(message: string) -> string

Normalize a message to its template — the same line with the parts that vary between occurrences (numbers, hashes, entity ids) masked out. Two messages that differ only in those parts share a template, which is what turns "this error repeated 400 times" into one row instead of 400. The engine keys its own error retention by the same normalization, so grouping built on this agrees with what survives ring eviction.

Parameters

  • message string — Log message to normalize.

Returns string — The message template.

local key = logs.template(entry.message)

globals/logs/warnings

logs.warnings(limit: number?) -> { LogEntry }

Most-recent WARN+ entries (newest first). limit defaults to 100.

Parameters

  • limit number (optional) — Maximum entries to return.

Returns { LogEntry } — Array of WARN+ log-entry tables.

print(#logs.warnings(), "warnings")

modules/logs/README

require("@builtin/modules/api/engine/logs") -- logs (also available as global 'logs')

Read-only query surface over the engine's in-memory log ring buffer. Public Luau surface over the __logs Internal FFI namespace.

Usage: local logs = require("@builtin/modules/api/engine/logs") Also available as global: logs

modules/logs/clear

clear(): boolean

Drop all buffered log entries. Lifetime per-level counts (logs.count) are preserved.

logs.clear()

modules/logs/count

count(opts: LogQueryOpts?): LogCounts

Aggregate counters for the log ring. Lifetime counts survive eviction, so errors reflects the total seen even if the lines have scrolled out of the buffer. opts takes the same filter table as logs.query, and matched is how many held entries it selects, counted without materialising them — limit and newest_first bound and order what a query RETURNS, so they leave matched alone. mcp is how many held entries record your own tool traffic; a query leaves those out, so with no opts, matched + mcp is everything held. last_seq is the cursor for incremental polling: read it before an action, then pass it as logs.query({ since = <that> }) afterwards to see only what the action logged.

Parameters

  • opts LogQueryOpts? (optional) — Filter options, as logs.query takes.
print("errors:", logs.count().errors)
local before = logs.count().last_seq

modules/logs/errors

errors(limit: number?): { LogEntry }

Most-recent ERROR-level entries (newest first). limit defaults to 100.

Parameters

  • limit number? (optional) — Maximum entries to return.
for _, e in ipairs(logs.errors(20)) do print(e.message) end

modules/logs/find

find(text: string, limit: number?): { LogEntry }

Case-insensitive substring search over log messages. limit defaults to 200 (keeps the most recent matches). Searches what the engine logged, so looking for a marker cannot return the call that looked for it; logs.query({ contains = ..., include_mcp = true }) searches your own tool traffic too.

Parameters

  • text string — Substring to search for.
  • limit number? (optional) — Maximum entries to return.
local hits = logs.find("MY_MARKER")

modules/logs/query

query(opts: LogQueryOpts?): { LogEntry }

Query the engine's in-memory log ring — the filtered view of what also reads as plain text at /zero/runtime/logs/engine. Answers about what the engine logged: the MCP record of your own tool traffic is left out, because the call carrying the query is one of those records and an unqualified search would match itself. type = "MCP" selects them; include_mcp = true mixes them in with everything else. On a world several sessions share, origin = "local" narrows the answer to the lines this session's own authoring caused.

Parameters

  • opts LogQueryOpts? (optional) — Filter options.
logs.query({ entity = "guard-1", limit = 20 })
logs.query({ level = "error", context = 3 })
for _, e in ipairs(logs.query({ level = "warn", limit = 50 })) do print(e.message) end

modules/logs/tail

tail(limit: number?): { LogEntry }

Most-recent entries of any level in chronological order. limit defaults to 100.

Parameters

  • limit number? (optional) — Maximum entries to return.
for _, e in ipairs(logs.tail(20)) do print(e.level, e.message) end

modules/logs/template

template(message: string): string

Normalize a message to its template — the same line with the parts that vary between occurrences (numbers, hashes, entity ids) masked out. Two messages that differ only in those parts share a template, which is what turns "this error repeated 400 times" into one row instead of 400. The engine keys its own error retention by the same normalization, so grouping built on this agrees with what survives ring eviction.

Parameters

  • message string — Log message to normalize.
local key = logs.template(entry.message)

modules/logs/warnings

warnings(limit: number?): { LogEntry }

Most-recent WARN+ entries (newest first). limit defaults to 100.

Parameters

  • limit number? (optional) — Maximum entries to return.
print(#logs.warnings(), "warnings")

tools/logs/clear

logs.clear() -> ClearResult

Drop the buffered log entries, so a following search sees only what happened after the clear. Lifetime per-level counts are preserved, so "were there ever any errors" stays answerable. Rarely the right move: summary returns a cursor which, passed back to search as since, isolates what an action logged without discarding lines anybody else may want.

Returns ClearResult

tools/logs/errors

logs.errors(opts?: (string | ErrorsOpts)) -> ErrorsPage

Recent errors and warnings, newest first. A snapshot — the same lines however many times it is called. That is the difference from the problems tool in the debug toolbox, which drains: it advances a read-cursor and reports only what has not been seen, so that one answers "what is new" while this answers "what is there". Accepts the same filters as search.

Parameters

  • opts (string | ErrorsOpts) (optional)

Returns ErrorsPage

{ level = "error", limit = 10 }
{ within = 120 }
"shader"
logs.search(opts?: (string | SearchQuery)) -> SearchResult

Search what the engine logged. Filter by severity, subsystem, substring or regex, the script or entity that logged it, and a time window — every filter is optional and they narrow together. This is the general view over the same ring the problems tool in the debug toolbox drains: reach for it when you need a specific message, a specific entity, a specific minute, or the lines that led up to a failure. The same ring reads as plain text at /zero/runtime/logs/engine, which is the shorter reach for a string you can already name; this is the view that filters it.

Parameters

  • opts (string | SearchQuery) (optional)

Returns SearchResult

{ level = "error", within = 60 }
{ entity = "ent_9f3c", limit = 20 }
{ level = "error", context = 5 }
{ level = "warn", group = true }
{ contains = "shader", type = "RENDERER" }
"shader"

tools/logs/summary

logs.summary(opts?: (string | SummaryOpts)) -> SummaryReport

How many lines the engine logged, of what severity, from which subsystems and entities, and which messages repeat — the shape of the noise before you go looking inside it. Also the cheapest "did anything go wrong" check, and the source of the cursor for incremental polling: read cursor before an action, pass it back to search as since afterwards, and you see only what that action logged. Accepts the same filters as search, so it can describe a slice as well as the whole.

Parameters

  • opts (string | SummaryOpts) (optional)

Returns SummaryReport

{ level = "error" }
{ within = 300 }

tools/logs/tail

logs.tail(opts?: (string | TailOpts)) -> TailPage

The most recent lines the engine logged, any level, oldest first — the last line is the newest. The view for when you do not yet know what you are looking for; once you do, search narrows it. Accepts the same filters as search.

Parameters

  • opts (string | TailOpts) (optional)

Returns TailPage

{ limit = 40 }
{ type = "SCRIPT" }
"shader"

typed/builtin//modules/api/engine/logs/logs/clear

logs.clear() -> boolean

Drop all buffered log entries. Lifetime per-level counts (logs.count) are preserved.

Returns boolean — True on success.

logs.clear()

typed/builtin//modules/api/engine/logs/logs/count

logs.count(opts: LogQueryOpts?) -> LogCounts

Aggregate counters for the log ring. Lifetime counts survive eviction, so errors reflects the total seen even if the lines have scrolled out of the buffer. opts takes the same filter table as logs.query, and matched is how many held entries it selects, counted without materialising them — limit and newest_first bound and order what a query RETURNS, so they leave matched alone. mcp is how many held entries record your own tool traffic; a query leaves those out, so with no opts, matched + mcp is everything held. last_seq is the cursor for incremental polling: read it before an action, then pass it as logs.query({ since = <that> }) afterwards to see only what the action logged.

Parameters

  • opts LogQueryOpts (optional) — Filter options, as logs.query takes.

Returns LogCounts — Counts summary table.

print("errors:", logs.count().errors)
local before = logs.count().last_seq

typed/builtin//modules/api/engine/logs/logs/errors

logs.errors(limit: number?) -> { LogEntry }

Most-recent ERROR-level entries (newest first). limit defaults to 100.

Parameters

  • limit number (optional) — Maximum entries to return.

Returns { LogEntry } — Array of ERROR log-entry tables.

for _, e in ipairs(logs.errors(20)) do print(e.message) end

typed/builtin//modules/api/engine/logs/logs/find

logs.find(text: string, limit: number?) -> { LogEntry }

Case-insensitive substring search over log messages. limit defaults to 200 (keeps the most recent matches). Searches what the engine logged, so looking for a marker cannot return the call that looked for it; logs.query({ contains = ..., include_mcp = true }) searches your own tool traffic too.

Parameters

  • text string — Substring to search for.
  • limit number (optional) — Maximum entries to return.

Returns { LogEntry } — Array of matching log-entry tables in chronological order.

local hits = logs.find("MY_MARKER")

typed/builtin//modules/api/engine/logs/logs/query

logs.query(opts: LogQueryOpts?) -> { LogEntry }

Query the engine's in-memory log ring — the filtered view of what also reads as plain text at /zero/runtime/logs/engine. Answers about what the engine logged: the MCP record of your own tool traffic is left out, because the call carrying the query is one of those records and an unqualified search would match itself. type = "MCP" selects them; include_mcp = true mixes them in with everything else. On a world several sessions share, origin = "local" narrows the answer to the lines this session's own authoring caused.

Parameters

  • opts LogQueryOpts (optional) — Filter options.

Returns { LogEntry } — Array of matching log-entry tables.

logs.query({ entity = "guard-1", limit = 20 })
logs.query({ level = "error", context = 3 })
for _, e in ipairs(logs.query({ level = "warn", limit = 50 })) do print(e.message) end

typed/builtin//modules/api/engine/logs/logs/tail

logs.tail(limit: number?) -> { LogEntry }

Most-recent entries of any level in chronological order. limit defaults to 100.

Parameters

  • limit number (optional) — Maximum entries to return.

Returns { LogEntry } — Array of the most recent log-entry tables.

for _, e in ipairs(logs.tail(20)) do print(e.level, e.message) end

typed/builtin//modules/api/engine/logs/logs/template

logs.template(message: string) -> string

Normalize a message to its template — the same line with the parts that vary between occurrences (numbers, hashes, entity ids) masked out. Two messages that differ only in those parts share a template, which is what turns "this error repeated 400 times" into one row instead of 400. The engine keys its own error retention by the same normalization, so grouping built on this agrees with what survives ring eviction.

Parameters

  • message string — Log message to normalize.

Returns string — The message template.

local key = logs.template(entry.message)

typed/builtin//modules/api/engine/logs/logs/warnings

logs.warnings(limit: number?) -> { LogEntry }

Most-recent WARN+ entries (newest first). limit defaults to 100.

Parameters

  • limit number (optional) — Maximum entries to return.

Returns { LogEntry } — Array of WARN+ log-entry tables.

print(#logs.warnings(), "warnings")
  • api
  • reference