Pick one log shape today and make every line in the app use it: a JSON object with a UTC timestamp, a level, a stable event name, the request id and the user’s id. That is most of how to do logging in a web app; the rest is carrying the request id across services and keeping secrets out.

What structured logging is, and how to do logging in a web app

Structured logging is writing each event as one JSON object with named fields instead of a sentence, so a log platform can filter by any field: the time, the level, a stable event name, the request id and the user’s id, plus the few fields that event needs.

Plenty of apps never get this far. In my June and July 2026 audits, 17 of the 21 third-party apps had no error tracking or alerting: when a user hits an error, nothing records it. Those 21 are a selected set of apps I audited, not a random sample and not a rate for AI-built apps in general. The wider set of controls, from these log lines to alerts, is in logging and monitoring for a small SaaS.

Logging in a web app works in three hops. The app writes each line to standard output, the hosting platform captures that stream, and a log store indexes the lines so you can search and filter them later. Console output can be enough as the way the app emits lines when the platform captures it reliably, but the lines still need structure, retention and search, and the error-logging guide linked below answers that question in full.

The one rule I use for logging calls: pass the logger an object with named fields, never a sentence with the values glued into it. A request event for a one-service SaaS looks like this:

{
  "time": "2026-09-28T14:03:12.481Z",
  "level": "info",
  "event": "http_request_completed",
  "request_id": "7f3c9a2e-5b1d-4e8a-9c0f-2d6b8a1e4f37",
  "user_id": "usr_1842",
  "route": "/api/orders/:id",
  "status": 200,
  "duration_ms": 184
}

That object, one per line, is the whole JSON log format this page recommends, and JSON logging simply means every line the app writes takes this shape. Write time in ISO 8601, in UTC. The event field is a stable snake_case name you can search for next month without guessing how a sentence was worded. For user_id, use your internal id, never an email address. The route field holds the route pattern, not the URL with real ids and query strings in it, and duration_ms carries its unit in its name, so nobody has to ask whether it means seconds.

Errors need more fields than a request does: release, environment, reason codes, the stack. That fuller schema is already written up under give every event a stable shape, so I won’t repeat it here. Which of these lines stay switched on in production is a separate choice: log severity levels in production.

Pick one log format and keep it

A web app writes one log format for its own events, JSON lines, and meets two it does not write: syslog, defined by RFC 5424, from the operating system and its daemons, and the Common Log Format from the web server’s access log.

FormatWhere it comes fromUse it for
JSON linesYour application’s own loggerEvery event your code writes: requests, jobs, errors, audit reads
SyslogRFC 5424, which obsoletes RFC 3164, the older BSD syslog descriptionHost messages; the RFC’s facility codes include kernel messages, the mail system and system daemons
Common Log Format and Combined Log FormatWeb servers such as Apache HTTP ServerThe access log: one line per HTTP request (next section)

No single log standard covers all three layers, which is why picking yours matters. RFC 5424 is the standards-track syslog protocol, and it defines the syslog message format as a header (priority, version, timestamp, hostname, app name, process id, message id), then a structured-data block, then an optional message. Here is the RFC’s own third example, a syslog format example with structured data, joined back onto one line (the RFC wraps it for print); the RFC writes the invisible byte-order mark as “BOM”:

<165>1 2003-10-11T22:14:15.003Z mymachine.example.com evntslog - ID47 [exampleSDID@32473 iut="3" eventSource="Application" eventID="1011"] BOMAn application event log entry...

The difference between the two syslog RFCs is short. RFC 3164 is an Informational document describing some implementations found in the field, and RFC 5424’s appendix says BSD syslog’s format “has never been formally standardized”; RFC 5424 obsoletes it and defines the header and structured data itself. Syslog logs belong to the host, so if a log platform collects them next to your app’s lines, keep them as a separate source with their own parser rather than bending your app into a syslog file format. Transport and ports are outside this page.

My rule: the app emits JSON, the platform can wrap it, and the parser at the other end never has to guess.

What goes wrong without it

The first failure shows up when a customer reports an error and there is nothing to search, and for most of the apps in the audit numbers above, nothing records the error at all. Picture a customer writing to support that checkout failed a few minutes ago. The app’s log lines are sentences with no request id and no user id, so the search for that minute returns every line from every user, and none of them can be tied to this customer. A request id written on every line and returned in the response header would have given support one string to search for. The lesson I take from it: the request id returned in the response is what turns a support ticket into a search.

The second failure is passwords showing up in application logs. My reading of how it happens: someone logs the whole request body to debug a form, and the sign-in request body carries the password in plain text, so every sign-in writes one to the log store. The full list of fields that must never be written, and how to redact personal data from logs at the application’s log boundary, sit in the same error-logging guide, under its “Remove sensitive data before the event leaves the process” section. This page’s part is the search test under “How to verify it”.

A log store that keeps everything forever turns one leaked line into a copy that outlives the bug. Decide how long to keep application logs and write the window into a data retention policy for a small SaaS. Personal data that leaves for a model provider in a prompt is its own control, with its own page in this series.

How to do it on the common stacks

Logging on any stack comes down to four decisions: one JSON event shape every line uses, a correlation id read or generated at the edge and carried on every call, a logger for the runtime that emits that shape, and a list of fields the logger never writes.

The five steps below are how to add correlation ids to logs on any runtime. The logger lines after them come from each project’s own documentation, not from a benchmark I ran, and each row names its source.

Correlation IDs: one id per request, on every line, across services

A correlation id is one identifier per request: read from an incoming X-Request-Id header or generated at the edge, stored in request context, sent on every outgoing call, written on every log line as request_id, and returned in the response so a support ticket can carry it.

  1. 01 Read X-Request-Id or X-Correlation-Id from the incoming request; if neither is there, generate a UUID at the edge.
  2. 02 Store the id in request context: async local storage, a request-scoped logger, or a middleware variable.
  3. 03 Send it in the same header on every outgoing call, background jobs and the webhooks you send included.
  4. 04 Write it on every log line as request_id.
  5. 05 Return it in a response header so a support ticket can carry it.

Step 5 is also how a user or support person looks up a correlation id: it sits in the response headers of the request that failed, and they paste it into the ticket.

Microsoft’s engineering playbook on correlation IDs describes the same pattern: an identifier “added to the very first interaction (incoming request)” and “passed to all components that are involved in the transaction flow”, assigned “as early as you can”, and for an HTTP request “typically passed in the header”. The playbook names no header. Laravel’s logging docs and zerolog’s README both use Request-Id in their examples, so the header names in step 1 are conventions rather than a standard; pick one and use it in every service. In Java the natural home for the id is SLF4J’s MDC (mapped diagnostic context), and Spring Boot’s structured formats add every MDC key to the JSON line, so name the key request_id rather than something like correlationId and the field matches every other service.

A correlation id is not a trace. Spans, parent links and sampling are a separate topic, trace IDs and correlation IDs across your app, and whether to adopt OpenTelemetry at all is a separate decision.

I audited a voice-AI SDK’s token server that logged with no request id, timestamp or structure, so a live issue could not be traced. The lesson I take from that one: a line with no request id and no timestamp can’t be joined to anything else the system wrote.

The request line: access logs and what belongs on one

An access log is the web server’s one line per HTTP request: client address, identity, user, time, request line, status and size in the Common Log Format, plus referrer and user agent in the Combined Log Format. The app’s own request event adds what the server cannot know.

Apache’s log files documentation says the server access log “records all requests processed by the server” and gives the Common Log Format string %h %l %u %t "%r" %>s %b. This is the sample access log line from that page, printed there across two lines:

127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326

The Combined Log Format is the same line with two quoted request headers on the end, the Referer and the User-Agent. On Apache HTTP Server the access log file format is set by the CustomLog directive, and the docs define two LogFormat nicknames for it, common and combined. So the Apache access logs format is whichever of those two the CustomLog line names, unless someone wrote a custom one, and the page notes that the Common Log Format “can be produced by many different web servers and read by many log analysis programs”.

FieldIn the access log (Apache CLF and Combined)In your request event
Client address%h, the client, or the proxy when one sits in betweenLeave it to the access log, or to the audit row when it matters
Authenticated user%u, from HTTP authenticationuser_id, your internal id
Time%t, the time the request was received, with a zone offsettime, ISO 8601 in UTC
Request line"%r": method, path with its query string, protocolroute: the pattern, no query string
Status%>sstatus
Size%b, bytes without the headersNot needed
Referrer and user agentCombined format onlyNot needed
Request idNot in either format string; a %L token can add a log entry IDrequest_id
DurationNot in either format stringduration_ms

Two rows in that table matter more than the rest. The request line carries the query string, since Apache documents "%m %U%q %H" as giving exactly the same output as %r, so a token in a URL lands in the access log whatever your app does. And the access log has no request id unless you add one: Apache’s %L token, placed in both the access log and the error log, produces a log entry ID to correlate the two, and with mod_unique_id loaded that ID is its unique request ID.

On a managed host, what the platform logs per request depends on the host: Vercel’s runtime logs are grouped per request, with the HTTP status and a RequestId on each row; Render’s log explorer shows HTTP request logs on a Pro workspace or higher; and Fly’s app logs are only the output of your app’s own processes. Which fields each one records is in that host’s docs, not here. Your request event adds the fields the platform cannot know: request_id, user_id, route and duration_ms. The query string and the body stay off it. The two lines join on the request id wherever the platform records one.

The logger for your stack: Java, C#, Go, Ruby, PHP

Each of these runtimes has a standard logger to build the event on: SLF4J with Logback or Log4j 2 for Java, Microsoft.Extensions.Logging with Serilog or NLog for .NET, log/slog for Go, Logger with tagged logging for Rails, and Monolog for PHP. The table says which setting writes JSON, where the docs name one.

RuntimeLoggerHow it emits JSONSource
Java and Kotlin with Spring BootSLF4J facade, Logback by default (Log4j 2 also supported)Set logging.structured.format.console to ecs, gelf or logstashSpring Boot’s logging docs; the SLF4J manual
Java, no frameworkjava.util.logging, configured by logging.propertiesNot stated in Oracle’s LogManager docsOracle’s Java SE 21 LogManager API docs
C# and .NETMicrosoft.Extensions.Logging (ILogger), with Serilog, NLog or log4net as providersBuilt-in console: AddJsonConsole; Serilog: JsonFormatter or CompactJsonFormatter; NLog: its JSON layoutMicrosoft Learn; Serilog’s wiki; NLog’s site
Golog/slog in the standard library; zerolog and zapslog: slog.NewJSONHandler; zerolog writes JSON by design; zap: zap.NewProduction()pkg.go.dev; zerolog’s README
Ruby and RailsActiveSupport::Logger wrapped in ActiveSupport::TaggedLoggingNot stated in the Rails debugging guideThe Rails debugging guide
PHP and LaravelMonolog, behind Laravel’s log channelsMonolog’s JsonFormatter, set with the channel’s formatter optionLaravel’s logging docs; Monolog’s formatter list

Java. Java logging frameworks come in two layers: a facade your code calls, and the framework behind it that writes the lines. The SLF4J manual says SLF4J “serves as a simple facade or abstraction for various logging frameworks” and “allows the end-user to plug in the desired logging framework at deployment time”. So log4j vs slf4j is not a choice between rivals: your code calls SLF4J, and Log4j 2 or Logback, both of which SLF4J supports, does the writing. That is also the reason to code against SLF4J over Log4j directly: the manual says that to switch logging frameworks, you “just replace slf4j bindings on your class path”. On Spring Boot logging, Spring Boot’s logging docs say that “if you use the starters, Logback is used for logging”, and the structured property in the table adds every MDC key to the JSON object, which is where the correlation id goes. If you are weighing the best Java logging framework for a new service, my answer is to keep what the framework ships, which on Spring Boot is Logback behind SLF4J; the docs themselves say that “generally, you do not need to change your logging dependencies and the Spring Boot defaults work just fine”. Kotlin logging on the JVM follows the same row, since Kotlin’s docs say existing Java code “can be called from Kotlin in a natural way”.

Log4j vs Log4j2 is a version question: the Log4j 1.x page says that on August 5, 2015 Apache’s Logging Services committee announced Log4j 1.x “had reached end of life”, and it recommends upgrading to Log4j 2. Java’s built-in logging utilities live in java.util.logging, and Oracle’s LogManager docs say the configuration “must be in the properties file format”, typically loaded from conf/logging.properties. A minimal logging properties example sets two keys the same docs describe: handlers, the handler classes for the root logger, and .level, the level for the root of the tree.

C# and .NET. .NET logging is built on the ILogger API, with providers that write logs to different destinations, and Microsoft’s providers page lists Log4Net, NLog and Serilog among the third-party options. So .NET Core and its successors handle logging and monitoring in two parts: the runtime supplies the logging API, and Microsoft’s docs say to add the providers you plan to use when console logs are not your sole production monitoring. The built-in console provider writes JSON once you call AddJsonConsole. For per-request fields, BeginScope means every log created as part of processing a transaction can include the transaction ID. Serilog says it “is built with powerful structured event data in mind”, and its wiki lists three JSON formatters, including JsonFormatter and CompactJsonFormatter. NLog says it “supports both structured and traditional logging” and offers layouts for JSON. On NLog vs Serilog, both write JSON, so in my reading the choice is mostly about which sinks and targets you need, and I have no benchmark to offer; the same goes for log4net vs Serilog. A C# logger used through ILogger keeps that choice reversible. My short list of C# logging best practices: log through ILogger rather than a library’s own API, use message templates with named values, and add a JSON output before launch.

Go. Go has log/slog in its standard library, and zerolog and zap are third-party JSON loggers with their own pages. Go’s log/slog package “provides structured logging”, arrived in Go 1.21, and its JSONHandler output “is line-delimited JSON”. For Golang services that want a dependency, zerolog calls itself a “Zero Allocation JSON Logger” whose chaining API “allows zerolog to write JSON (or CBOR) log events by avoiding allocations and reflection”. Its hlog helpers include a RequestIDHandler for net/http. zap, which zerolog’s README credits as “Uber’s zap library”, describes itself as “fast, structured, leveled logging”, and its NewProduction logger writes Info level and above to standard error as JSON. Any speed comparison between them is their own pages’ claim, not mine.

Ruby and Rails. Ruby logging in a Rails app goes through ActiveSupport::Logger, which the Rails debugging guide names as the class Rails uses to write log information. Rails tagged logging wraps that logger and, in the guide’s words, stamps “log lines with subdomains, request ids, and anything else to aid debugging”. The guide shows the tag as a bracketed prefix on a text line and names no JSON formatter, so JSON output needs a formatter from outside the guide.

PHP and Laravel. The PHP runtime’s own logs, its error log, are a different stream from the events your app writes; app events go through the app logger. Laravel’s logging docs build logging on “channels” and say Laravel “utilizes the Monolog library” underneath; Monolog’s own formatter list describes JsonFormatter as “Encodes a log record into json”. For Laravel API logging, the docs’ own example middleware generates a UUID with Str::uuid(), adds it to all later entries in that channel with Log::withContext(['request-id' => $requestId]), and returns it in a Request-Id response header, which covers the generate, write and return steps of the correlation convention.

Node and Python each need more room than this: logging in a Python web app has its own page, and logging in a Node app with pino or winston is a topic of its own.

The audit log table, and how to read an audit log

An audit log is a database table, not a log line: each row records who did what to which record, when, the values before and after, and the request id, so it keeps its own retention and joins back to the app log lines around it.

This is how to build an audit log table in Postgres, using a subset of the column names the sensitive-actions audit page linked below uses:

create table audit_log (
  id          bigint generated always as identity primary key,
  occurred_at timestamptz not null default now(),
  actor_id    text not null,
  action      text not null,
  target_type text not null,
  target_id   text not null,
  before      jsonb, after jsonb,
  request_id  text,
  ip          inet
);

That is all the audit trail code the table itself needs. Write each row from server code in the same transaction as the change it records, so a failed audit write rolls the change back.

App logAudit log
Where it livesThe log platformA table in your database
What one entry isOne event from the code, at any levelOne change to a record: who, what, which record, before and after
How long it staysShort, set on the log platformIts own retention, set by policy
Who reads itWhoever is debuggingSupport, the owner, a reviewer
How they connectrequest_idrequest_id

To check audit logs, query the table by actor, by target or by request id. The request_id column is the bridge: a suspicious row gives you the id, and the id finds the app log lines from the same request. A filled row reads like this: occurred_at 2026-09-28 09:12 UTC, actor_id usr_1842, action role.changed, target_type user, target_id usr_2210, before holding the member role, after holding the admin role, request_id matching the app’s request event, and ip the address the request came from. An audit alert is a scheduled query on the table that fires on a rule; my examples would be a role change to admin or a bulk export.

Supabase’s own audit logs sit at the platform and database level, and none of them replaces your table. Supabase’s platform audit logs record “Platform API or dashboard actions performed by organization members”, such as creating a project or changing project settings; they “are only available on the Team and Enterprise plans”, and each user account also has its own Account Audit logs. To view audit logs at that level, Supabase says Platform Audit Logs “can be found under your organization’s audit logs”. For database-level auditing, pgAudit on Supabase “extends Postgres’s built-in logging abilities”, is enabled from the Database page’s Extensions list, and writes its events to the dashboard’s Postgres Logs. They record platform and database activity; in my reading, neither is built to say which of your product’s users acted or why, and that context is what your own table holds.

Which audit log activities belong in the table, and the rules for writing them, are covered in audit logging best practices for sensitive actions.

How to verify it

Logging is verified with one test request: a known X-Request-Id sent through a flow that crosses every service and found on a line in each, every line parsing as JSON, and a search of recent logs for a test account’s password, token and email that finds nothing.

  1. 01 Send one test request with a known X-Request-Id through a flow that crosses every service (the API, a background job, a webhook you send) and find that id on a line in each. Evidence: the lines, exported, with the date.
  2. 02 Export recent lines and confirm every one parses as JSON with the jq check below. Evidence: the command output, which should be a count of zero.
  3. 03 Sign in with a test account whose password, token and email are known strings, search the last hour of the app's own log lines for each string, and in the same search window find that sign-in's own request event by its request id. Evidence: the empty searches and the found event, with the date.
  4. 04 Where the host shows a per-request log with an id, read it and your request event for the same request and confirm they join. Where the host shows no per-request log, write that down and skip the join. Evidence: both lines, or the note.
  5. 05 Write one audit row from a test action and read it back by actor and by request id. Evidence: the row.
  6. 06 Check that the log platform's retention setting matches the window you chose. Evidence: a screenshot of your settings page.

The JSON check uses jq, reading each line raw and printing a marker for any line that does not parse; the second command counts the markers, so the number it prints is the number of bad lines:

jq -R 'try (fromjson | empty) catch "not json"' logs-export.jsonl
jq -R 'try (fromjson | empty) catch "not json"' logs-export.jsonl | wc -l

In jq’s manual, -R passes “each line of text” to the filter “as a string”, fromjson parses it, and try ... catch runs the second expression when the first fails. The manual names no option that fails on invalid input, and -e sets the exit status from the last output value, so it cannot stand in for this check.

The secrets search finds the sign-in’s own event on purpose. An empty search on its own could mean the strings were never logged, or that the log was empty or had already expired; finding the event in the same window rules out the second reading. Even then, the result covers the strings you searched for, on the date you searched, and nothing more. The host join has a catch of its own: the host records its own request id, which is not always the header you sent, so my rule is that the request event also logs the host’s id wherever the request carries it.

Deliverable 8.1 of the Production Hardening Sprint is verified this way: trace a test request across services and check log content for sensitive fields. How to monitor logs once they exist, with alerts and dashboards on top, is covered in application monitoring best practices for a small SaaS.

Where the sprint does this

Deliverable 8.1 of the Production Hardening Sprint adds structured request logs with correlation IDs and appropriate user references, and excludes passwords, tokens and unnecessary personal data. We check it with the test request described in the section above. The result goes into the production readiness report, deliverable 13.1, which accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. Hosting, paid tools, and API usage remain in your accounts. The full list is at every deliverable in the sprint scope.

Common questions about logging in a web app

What are the best practices for JSON logging?

My working list: one event shape across the whole app, one JSON object per line, stable snake_case event names, internal ids instead of emails, units in field names such as duration_ms, and values passed as fields rather than interpolated into the message text. The field rules for error events, including what to redact, belong to the error-logging guide on this site.

What is the standard format for logs?

There is no one standard across layers. Your application should write JSON lines, syslog messages follow the format RFC 5424 defines, and web servers write access logs in the Common Log Format or its Combined extension, which adds the referrer and user agent.

What is an access log?

An access log is the file where a web server writes one line for every request it handles. In Apache’s Common Log Format each line holds the client address, two identity fields, the time, the quoted request line, the status code and the response size in bytes.

What are the five logging levels?

The five levels in SLF4J are TRACE, DEBUG, INFO, WARN and ERROR, the same five Spring Boot’s log output shows, though its level setting also accepts FATAL and OFF. TRACE is step-by-step detail, DEBUG is diagnostic detail, INFO is a normal event worth keeping, WARN is a problem the app recovered from, and ERROR is an action that failed. .NET counts differently: its LogLevel runs from Trace to Critical, with None as a seventh value. Which levels stay on in production has its own page in this series.

How can I use a correlation ID in logging?

Take the id from the incoming request header or create a UUID if there is none, keep it in request context, forward it in the same header on every call the request makes, print it on each log line as request_id, and send it back in the response. Microsoft’s engineering playbook describes the same propagation rule.

Can you provide an example of an audit log?

One audit row for a data export might read: occurred_at 2026-09-28 16:40 UTC, actor_id usr_0917, action customers.exported, target_type workspace, target_id ws_311, before empty, after holding the export’s row count, request_id matching the app log line for that request, and ip the caller’s address. The columns come from the audit table sketched above.