feat(mods): add mod.postLog one-way log reporting to a manifest-declared URL

This commit is contained in:
Shane McGovern
2026-08-15 21:03:24 +01:00
parent 099a4266a8
commit cf335f67de
8 changed files with 378 additions and 0 deletions
+17
View File
@@ -1094,6 +1094,23 @@ function Loader:_api(mod)
return { available = function() return false end,
get = refuse, poll = refuse, release = refuse, cancel = refuse }
end)(),
-- One-way crash-log reporting to the https URL the manifest declares in
-- log_url. The destination is reviewed at load, not chosen per call, so
-- a mod cannot aim this at arbitrary hosts; the response body is never
-- returned, and the worker pool bounds the transfer. Same handle/poll/
-- release shape as mod.fetch, so mod.job's sibling patterns carry over.
postLog = (function()
if mod.manifest.permissionSet.network and mod.manifest.log_url then
return function(_, body, opts)
return Net.postLog(loader, modId, mod.manifest.log_url, body, opts)
end
end
local function refuse()
error(('[%s] mod.postLog needs the "network" permission and a '
.. "log_url in manifest.json"):format(modId), 2)
end
return refuse
end)(),
-- Background compute, behind the "background" permission. The worker
-- rebuilds this mod's sandbox before loading the script, so a job is the
-- one thing love.thread is not: off the main thread without a Lua state
+19
View File
@@ -308,6 +308,24 @@ function Manifest.validate(raw, path)
local github = Manifest.parseGithub(raw.github)
-- log_url: the mod's one-way crash-log reporting destination. https-only,
-- declared in the manifest so the engine reviews the target at load instead
-- of trusting per-call URLs from gameplay code, and gated on the `network`
-- permission the mod must also declare. api 1 mods never carry it: it is a
-- load violation, not a warning, because a postLog-capable mod that does not
-- opt in to networking is a bug in the manifest itself.
local logUrl = nil
if raw.log_url ~= nil then
if strict and not permissionSet.network then
violation(strict, raw.id, "log_url requires the network permission")
elseif strict and (type(raw.log_url) ~= "string"
or not raw.log_url:match("^https://")) then
violation(strict, raw.id, "log_url must be an https:// URL")
elseif strict then
logUrl = raw.log_url
end
end
assert(raw.experimental == nil or type(raw.experimental) == "boolean",
"experimental must be a boolean")
local experimental = raw.experimental == true
@@ -414,6 +432,7 @@ function Manifest.validate(raw, path)
affects_link = affectsLink,
permissions = permissions,
permissionSet = permissionSet,
log_url = logUrl,
options_schema = optionalFile(raw.options_schema, "options_schema"),
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
required_imports = requiredImports,
+59
View File
@@ -32,6 +32,9 @@ local Net = {}
Net.MAX_INFLIGHT = 4
-- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely.
Net.MAX_SECONDS = 30
-- A log body ceiling. Debug logs are kilobytes, and a server operator has no
-- reason to accept a mod uploading arbitrary megabytes to its endpoint.
Net.MAX_BODY = 65536
local function fetch()
return require("src.net.Fetch")
@@ -99,6 +102,62 @@ function Net.get(loader, modId, url, opts)
return handle
end
-- The closed list of postLog format switches. Anything outside it is a
-- caller bug, rejected before a job is submitted, so the surface stays
-- exactly two shapes on the wire.
local POST_FORMATS = { text = true, json = true }
-- A one-way log POST to the mod's manifest-declared log_url (https only,
-- validated in Manifest.lua). Same shape as get(): opaque handle, per-mod
-- in-flight ceiling, user agent naming the mod. The response body is never
-- returned -- a postLog is fire-and-forget reporting, and the engine has no
-- reason to hand a mod a server's reply.
function Net.postLog(loader, modId, logUrl, body, opts)
if type(body) ~= "string" or body == "" then
return nil, "log body must be a non-empty string"
end
if #body > Net.MAX_BODY then
return nil, ("log body too large (%d bytes, limit %d)"):format(#body, Net.MAX_BODY)
end
opts = type(opts) == "table" and opts or {}
for key in pairs(opts) do
if key ~= "format" then
return nil, ("unknown log option %q (format is the only switch)"):format(tostring(key))
end
end
local format = opts.format or "text"
if not POST_FORMATS[format] then
return nil, ("unknown log format %q (text and json only)"):format(tostring(format))
end
local denial = Net.urlDenial(logUrl)
if denial then return nil, denial end
local b = bucket(loader, modId)
if inflight(b) >= Net.MAX_INFLIGHT then
return nil, ("too many requests in flight (limit %d); poll and release "
.. "the ones you have"):format(Net.MAX_INFLIGHT)
end
local payload = body
local contentType = "text/plain"
if format == "json" then
local Json = require("src.link.Json")
payload = Json.encode({
ts = os.time(),
mod = modId,
format = "json",
body = body,
})
contentType = "application/json"
end
local id = fetch().post(logUrl, payload, {
userAgent = "gen1recomp-mod/" .. tostring(modId),
contentType = contentType,
maxSeconds = Net.MAX_SECONDS,
})
local handle = {}
b[handle] = id
return handle
end
-- A copy of the job's state, never the engine's own table. An unknown or
-- forged handle reads as an error rather than nil, so a mod that lost track of
-- one cannot spin waiting on it forever.