Frontend Logging
Wallow's React apps log through @bc-solutions-coder/logger (packages/logger), never
through console. The package is both ends of one path: a browser core that buffers, filters and
posts batches to a same-origin route, and the app-server ingest handler that guards, stamps and
forwards them to an OpenTelemetry collector.
The transport is browser → app server → OTLP. The page never talks to a collector and never holds a collector credential — what it holds is a same-origin path. That is the same shape as the BFF pattern for API calls, and for the same reason: a secret the browser does not have is a secret the browser cannot leak.
page ──POST /bff/logs (wallow-web)──▶ app server ──OTLP/HTTP──▶ collector
──POST /logs (wallow-auth)──▶ │
└── no OTEL_EXPORTER_OTLP_ENDPOINT? ──▶ stdout JSON
Recording an event from a page
Each app owns one logger, a module singleton at src/shared/lib/log.ts:
import { log } from "@shared/lib/log";
log.info("form.submitted", { form: "signup" });
log.error("bff.logout.failed", {}, error);
The four methods (debug, info, warn, error) share one signature —
(event, attrs?, error?). child(attrs) returns a logger that stamps extra attributes on every
record while sharing the same buffer, so a child's records interleave with its parent's in one
batch rather than racing two buffers to the same route.
Nothing here throws into the page and nothing returns a value you must handle. A page's behaviour never changes because telemetry is down.
Events are named, not prose
event is a name: dotted, lowercase, low-cardinality, groupable.
| Good | Bad |
|---|---|
form.submitted |
"User submitted the signup form" |
bff.logout.failed |
"logout failed for user 91f2…" |
logger.dropped |
"dropped 14 events" |
The wire format enforces it with /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u, and a rejected name
fails the whole batch with a 400. Anything that varies per occurrence goes in attrs, where it
costs one attribute rather than one metric series.
Attributes are redacted before they are buffered
Attribute keys are matched case-insensitively as a substring against
DEFAULT_REDACT_KEYS — password, token, secret, authorization, cookie, email — and
replaced with [redacted]. So newPassword and password_confirmation are both covered. The
pass walks nested values, and a consumer can supply its own list via redact.
This is a floor, not a classifier. Do not put anything you would not want in a collector into
attrs and rely on the list to catch it.
What reaches the collector
The browser sends { ts, level, event, attrs, correlationId?, error? }. The server discards
nothing but adds everything a page could otherwise assert about itself:
| Field | Where it comes from |
|---|---|
ts |
Server receipt time |
clientTs |
What the browser claimed, kept for clock-skew analysis |
service |
The handler's own configuration — never the payload |
clientIp |
The host's clientAddress callback — never an inbound header |
correlationId |
The event's, or the request's x-request-id |
userId, tenantId |
The app's context callback, read from the session |
service, userId and tenantId are stamped server-side on purpose: a record that names its own
service or its own user is a record any page can forge. The validator rebuilds each accepted event
field by field rather than spreading the wire object, so a payload carrying an extra service key
cannot smuggle it into a record.
clientIp is the same argument one step further out. It comes from the clientAddress callback
the app supplies — both apps answer with request.ip, the peer address off the connection — and
the handler reads no inbound header for it. A header would arrive with the payload's validation
bypassed and would let a caller both forge the field and, since the same value keys the rate
limit, mint a fresh bucket per request.
Correlation uses the same x-request-id header the SDK's proxy writes, so a browser record and
the API records for the same request join on it. See
Request Correlation.
Server-side logging
App-server code (route handlers, server-only modules) uses createServerLogger from the
./server entry rather than the browser core. wallow-web holds one at
src/app/lib/log.server.ts:
import { serverLog } from "./log.server";
serverLog.info("bff.session_store.selected", { store: "redis", stateless: false });
serverLog.error("bff.redis.error", {}, error);
Same record shape, same event-naming rule, same redaction — it just writes straight to the sink instead of buffering.
Configuration
One environment variable, and it is the standard OpenTelemetry one:
| Variable | Effect |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
Where records are POSTed as OTLP/HTTP JSON — the collector's HTTP port, 4318. |
| (unset) | Records are written to stdout as JSON lines — what docker logs shows. |
The port matters and the variable name hides it. The .NET API exports over OTLP/gRPC and reads
the same variable pointed at 4317; this package has no gRPC transport at all. Given 4317 the
POST simply fails, and because a valid batch answers 204 regardless of collector health, nothing
in the app surfaces the loss.
There is no Wallow-specific logging variable to set, and no allowlist to configure: the ingest route derives the origin it accepts from the request it was handed (see below).
The ingest route
Both apps mount the same handler; neither reimplements a guard.
| App | Path | CSRF |
|---|---|---|
wallow-web |
/bff/logs |
Yes — it holds a session, so it verifies the session token |
wallow-auth |
/logs |
No — it holds no session, so there is no token to check |
CSRF is not the control this endpoint needs, and treating it as one is how an ingest route ends up unprotected in the app that has no session. The controls that actually apply are the same in both apps:
- An origin allowlist, and it is load-bearing rather than advisory.
Originis a forbidden header name, so page script cannot forge it, and it is the one guard that survives thesendBeaconpath where no header can be set. Both apps resolve it per request to the origin this request was addressed to — the classic Origin-versus-target check, which needs no logging-specific configuration and honours a trusted reverse proxy'sx-forwarded-proto(gated byWALLOW_TRUSTED_PROXIES, the same list that gatesx-forwarded-for). - Payload caps that reject rather than truncate. The default is 64 KiB, which is
sendBeacon's own quota: a batch the browser would refuse to queue is a batch the server refuses to read. - A per-IP rate limit, because the route is unauthenticated by design. Its key is the address the host supplies, never one off the wire — a caller who can choose the key has no limit.
A valid batch answers 204 regardless of collector health. The guards still return real
405/403/413/429/400; only a sink failure is swallowed.
When logs are sent
| Trigger | Transport |
|---|---|
| Buffer reaches 20 events | fetch(..., { keepalive: true }) |
| Every 5 s | fetch(..., { keepalive: true }) |
visibilitychange hidden |
fetch(..., { keepalive: true }) |
pagehide |
navigator.sendBeacon — the terminal flush |
log.flush() |
fetch(..., { keepalive: true }) |
visibilitychange → hidden carries most of the load: it fires on tab switch, app switch, and the
mobile discard path that never fires pagehide at all. pagehide is the last call and takes the
beacon, which is the only transport a browser promises to finish after the document is gone.
sendBeacon cannot set headers, so on that path a CSRF token rides in the body instead; the
handler accepts it from either place.
Three failure rules
A logger that fails loudly is worse than no logger.
- A transport error never calls the logger. It falls back to
console.warnand disables transport for a backoff window (30 s by default). The failed events are deliberately not requeued — a requeue turns one unreachable route into a buffer that refills itself every window and never drains, and the records are not lost to the person who can act on them: they are on the console, which is where a broken telemetry path belongs. - The buffer drops oldest on overflow. The ceiling is 200 events; past it the oldest are
discarded and the next batch is prepended with a
logger.droppedwarn carrying{ count }. A long-lived tab with a dead ingest route never grows without bound. - Nothing throws into the page. Every method returns
voidorPromise<void>.
Where console is still correct
console is not banned outright — it is the right call in two places, both outside the app
runtime:
- Inside the logger itself, for the transport-failure fallback. Rule 1 above.
- In Playwright setup and other build/test tooling, which has no app server to post to and whose output belongs on the terminal.
Everything rendered or served by an app goes through the logger.
How this fits the platform's observability
The Observability Guide documents the .NET API's three signals — structured logs, metrics and traces, all exported over OTLP/gRPC to the same collector. This page is the other half of that picture, and the two halves are not symmetric:
Backend (Wallow.Api) |
Frontend (React apps) | |
|---|---|---|
| Logs | Serilog → OTLP | @bc-solutions-coder/logger → app server → OTLP |
| Metrics | OpenTelemetry instruments | None — the browser emits no metrics |
| Traces | OpenTelemetry activities, sampled by OpenTelemetry:TraceSamplingRatio |
None — the browser starts no spans |
| Transport | OTLP/gRPC, port 4317 | OTLP/HTTP, port 4318 |
| Credential | Held by the API process | Held by the app server; never by the page |
So "frontend observability" in Wallow means logs only, joined to the backend's three signals by
the shared correlationId (the x-request-id the SDK's proxy writes). Both ends land in the same
collector and the same Grafana stack, so a browser record and the API records for the same request
sit in one query — which is the whole reason the correlation ID is stamped rather than generated
independently at each end.
Two practical consequences:
- The endpoint variable is shared but the port is not.
OTEL_EXPORTER_OTLP_ENDPOINTis read by both the API and the app servers; the API wants:4317and the app servers want:4318. In a deployment where the two run as separate processes this is fine. Where they share an environment, the app server's value has to be set per process — see the Configuration table above. - There is nothing to instrument in the page. If you want a frontend signal that is not a log record, it does not exist yet; add the log event and derive the metric in the collector.