Skip to content

Feat/structured logging and request limits - #772

Merged
Abd-Standard merged 3 commits into
Core-Foundry:mainfrom
Ike-Nathan:feat/structured-logging-and-request-limits
Sep 1, 2026
Merged

Abd-Standard merged 3 commits into
Core-Foundry:mainfrom
Ike-Nathan:feat/structured-logging-and-request-limits

Conversation

@Ike-Nathan

Copy link
Copy Markdown
Contributor

Overview

Related Issue

Closes #684
closes #685
closes #687
closes #688

Changes

Verification

# e.g.
cd dashboard && npm test
cd listener && npm run typecheck && npm test
cd contract/contracts/hello-world && cargo test

How to Test

Checklist

  • Branch is up to date with main
  • Tests added/updated and all pass locally
  • cargo fmt --all run (if Rust changes)
  • npm run lint passes (if TypeScript changes)
  • Documentation updated if behavior changed

JSON output was tied to NODE_ENV === production, so structured logs could
not be enabled anywhere else — reproducing an aggregator problem locally
meant pretending to be production. LOG_FORMAT (json | pretty) now selects it
explicitly and wins over the environment; with LOG_FORMAT unset the previous
behaviour is preserved exactly.

Records keep a stable field set in both formats — timestamp, level, message,
plus whatever structured context the call site attached — and error
information still goes through formatError, so name/message/stack/cause stay
machine-readable rather than collapsing to a string.

Secret redaction is new. Log metadata is walked before emission and any field
whose name looks like a credential (password, secret, token, apiKey,
authorization, signature, cookie, privateKey, plus case and underscore
variants) has its value replaced with [REDACTED]. The field name is kept so
record shape stays stable for aggregator indexing.

Redaction runs on the way into the logger rather than being left to callers:
a secret only has to be forgotten once to sit permanently in an aggregator,
and the call site is the party most likely to forget. Recursion is depth-
bounded so a deeply nested or cyclic object cannot hang the logging path —
logging must never be what takes the service down.

sanitizeUrl strips credential-bearing query parameters from a URL while
keeping the path, which is the part that identifies an endpoint. It builds
the query string by hand rather than via URLSearchParams.toString(), which
would percent-encode the marker into %5BREDACTED%5D — still redacted, but no
longer greppable, which defeats the point of a fixed placeholder.

parseLogLevel/parseLogFormat are strict counterparts to resolveLogLevel:
they return null instead of downgrading, so config validation can reject.
resolveLogLevel keeps its non-throwing fallback, since a bad value must not
crash a process that is already running.

41 tests.
Seven POST/PUT handlers in the events API accumulate the request body into a
string (body += chunk) with no ceiling, so a single large upload grew the
process heap unbounded. API_MAX_BODY_BYTES now caps it, defaulting to 1 MiB.

The guard runs once at dispatch rather than in each handler, which is what
makes "does not attempt to process rejected payloads" true: by the time a
route attaches its own data listener the request has already been screened,
and an oversized one has been answered with 413 and had its socket
destroyed. Fixing this per-handler would have been seven chances to miss one
and no protection for routes added later.

Two checks, because either alone is insufficient:

  * Content-Length, when present and over the limit, is refused before a
    single byte of body is read.
  * A streaming byte counter, because Content-Length is client-supplied and
    absent entirely on chunked transfers. A client can understate it or omit
    it; the counter is what actually bounds memory.

The socket is destroyed on rejection — without it the client keeps sending a
body nobody will read and the bytes still transit the process. The 413
carries code PAYLOAD_TOO_LARGE with maxBytes and observedBytes, and echoing
the observed size leaks nothing the client did not itself send.

Chunks are measured with Buffer.byteLength, not string length: a multi-byte
character counted as one would let several times the intended payload
through.

GET/DELETE/HEAD/OPTIONS pay only a set lookup.

27 tests, concentrated on boundaries — exactly at the limit accepted, one
byte over rejected, one under accepted — plus understated Content-Length,
chunked transfers, multi-byte counting, repeated chunks after an overrun
responding only once, and the headers-already-sent case.
LOG_LEVEL, LOG_FORMAT and API_MAX_BODY_BYTES now go through the same
loadConfig/validateConfig path as every other setting, so operators change
verbosity the same way they change anything else.

Invalid values are rejected rather than silently downgraded. A typo in
LOG_LEVEL that quietly resolved to "info" hid the debug output an operator
explicitly asked for, and gave them no signal the setting had not taken.
Errors are collected with the rest, so a deployment with several bad values
sees all of them in one message instead of fixing and restarting repeatedly.
The message names the accepted values.

Documentation correction: ENVIRONMENT_VARIABLES_AND_SECRETS.md listed http,
verbose and silly as valid log levels. The service has only ever implemented
error | warn | info | debug — the others were silently downgraded to info.
Anyone who followed the table got behaviour that did not match it, and would
now get a startup failure instead, so the table is corrected and the change
called out in a note. Also documents LOG_FORMAT, API_MAX_BODY_BYTES and the
redaction behaviour.

response-time middleware now logs a sanitized URL. It already measured
duration and included method/status/durationMs, but wrote req.url verbatim —
and query strings routinely carry ?token= or ?api_key=, so every request was
putting credentials into the aggregator. The path is kept, since that is what
identifies a slow endpoint; only parameter values are withheld. Sanitization
is a string scan on an already-parsed value, so per-request cost is
unchanged in any way a timing measurement would show.

Production defaults are unchanged: info verbosity, JSON output under
NODE_ENV=production, 1 MiB body limit.

17 tests.
@drips-wave

drips-wave Bot commented Aug 31, 2026

Copy link
Copy Markdown

@Ike-Nathan Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Abd-Standard
Abd-Standard merged commit 852c990 into Core-Foundry:main Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add API Request Size Protection Add API Request Duration Logging Add Structured JSON Logging Option Add Configurable Log Level

2 participants