How to structure a log
You’ve decided to step up your logging game and start sending more valuable, structured logs that you can query, aggregate, and use for debugging in production. Go, you!
Now, uh, how do you actually write them?
We’re not going to spend much time on what you should log. We’ve covered that already, a few times before.
TL;DR: collect useful debugging context as code executes, then emit wide-event logs at meaningful milestones. Always emit a final outcome event on both success and failure paths.
What we will be covering is how to actually write those logs, answering questions like:
- What shape should they actually be in?
- How do we make them useful for querying and filtering?
- How do we know the logs we write will be useful for debugging and understanding what happened?
What makes a log structured is not just pairing messages with arbitrary JSON objects.
Instead, we treat logs like real application data that we need to be able to search, filter, and aggregate to understand broad trends and debug specific incidents.
How we choose to actually write those logs will greatly impact how useful they are for debugging and understanding what happened.
Here is my very opinionated guide (as someone who spends time with Sentry logs users and helps them get more from their logging) to one way to write structured logs.
The shape of a structured log
The exact convention you decide to use matters less than whether it is predictable and applied consistently. Once you define a pattern and stick to it, you should be able to locate and understand any event in your system based on its log message and attributes.
The convention I use in my projects looks like this:
logger.warn('payment.capture', {
'payment.id': payment.id,
'payment.amount_cents': payment.amountCents,
'payment.currency': payment.currency,
'payment.result': 'failed',
'payment.failure.reason_code': reasonCode,
});A few patterns you’ll notice in the example above, and I’ll explain in more detail below:
- Event names use a predictable pattern of
domain.action. snake_casesegments, regardless of the programming language or framework you’re using. One casing convention across services just makes querying later so much easier.- Flattened attribute objects using dot notation, not nested objects.
- Predictable
resultattributes with low-cardinality values likesucceeded,failed,retried,canceled, orcompletedfor visualizing and grouping. - An expected or recoverable failure uses a warning log level.
- Attributes should only contain primitive values or arrays of primitive values. No objects, including shallow objects, or arrays of objects.
Enforce log patterns with ESLint
I use a personal ESLint plugin for this to help enforce these patterns consistently across my TypeScript projects.
This is not an official Sentry plugin; these are just my opinionated guidelines.
It is a logging-library-agnostic way to loosely enforce the shape of your logs.
This does not go as far as, for example, validating zod schemas or similar.
If you want to follow along with my patterns, use the prompts below to install and configure my rules for your project. If you want to do something a little different, I highly recommend creating your own ESLint plugin or equivalent linting/tooling for your language to enforce your own patterns. Your agent/assistant can help you create whatever you need. Try running this prompt to customize your own rules for whichever language or framework you’re using.
To install my ESLint plugin, give this prompt to your AI assistant:
Use stable event names
Let’s start with what a bad log might look like:
console.log(`${user.name} logged in`, {
userUuid: user.uuid,
userName: user.name,
});Technically, this is structured; it has a message and a data object. But the message is not stable. Every successful login creates a different log event for each user name, which makes the logs harder to query, group, and alert on.
A useful structured log needs a predictable event name. Dynamic data belongs in attributes, not in the message.
A better log message with a stable and predictable event name might look like this:
logger.info('auth.login', {
'auth.result': 'succeeded',
'user.uuid': user.uuid,
'user.plan.tier': user.plan.tier,
});This still communicates the event easily to humans and is easy to remember and query. Avoid logging usernames, and email addresses. Depending on your data policy you may or may not be able to include an identifier like a UUID.
For most application events, I like to segment the event name into two parts: the domain and the action.
domain.actionFor example: “auth.login”, “payment.capture”, “webhook.delivery”, or “cart.checkout”.
The “domain” is fairly arbitrary, but I think of it as the closest context object you would want associated with it. As you collect context through your application, there are natural boundaries where you typically scope attributes.
When the user signs in, you might add auth.* attributes to the context. On a cart page, you might add cart.* attributes.
At checkout, the log event itself can describe the operation that finally happened, like cart.checkout with event-specific attributes. The event attributes might be the result of the checkout, as well as all of the context leading up to it, creating a debugging paper trail.
To enforce that convention with ESLint, configure the event-name rules like this:
Use scoped attribute keys
Event names describe what happened: payment.capture, auth.login, cart.checkout.
Attribute keys describe the facts you want to query about that event: payment.result, payment.amount_cents, auth.org_id, retry.attempt.
Use the same scoped dot-notation style for attributes, but think of attribute keys differently than event names. Event names are actions. Attribute keys are dimensions.
Think of each scoped key as a future question you are making cheap to answer:
payment.result -> Did it succeed or fail?
payment.failure.reason_code -> Why did it fail?
payment.amount_cents -> How much money was involved?
user.id -> Which customer was affected?
retry.attempt -> Was this an early failure or repeated failure?If a field might become a filter, grouping, dashboard dimension, alert condition, or incident-debugging clue, it deserves a stable scoped key.
A nested object preserves how your application stores data. A flat log event exposes the small set of fields you expect to query, group, alert on, and trust later.
Dot notation gives you some of the organization of nested data while keeping each field directly addressable. payment.failure.reason_code still feels organized like JSON, but as a string key, the value is immediately accessible without further parsing.
Scoped keys also help enforce safer logging practices. By manually defining the keys we want to log, we can ensure that we are only logging the data we intend to log, and not any other data that may be present in an arbitrary object.
Event scoped attributes
Once you have a naming convention for attributes, the next question is where those attributes should be attached.
Throughout the application, you should be adding context to your logs at natural boundaries that create a useful timeline of application state.
I mentioned before adding auth.* attributes when the user was authenticated.
Different logging libraries handle context differently. For Sentry JavaScript SDK 10.32 or newer, use an isolation scope for request-specific context. Scope attributes must be strings, numbers, or booleans.
Sentry.getIsolationScope().setAttributes({
'auth.org_id': user.orgId,
'auth.user_tier': user.tier,
});Every log emitted while that isolation scope is active receives the same auth.* attributes. Add only deliberate, policy-approved values: shared context is propagated broadly and is not a safeguard against sensitive-data collection.
On the log itself, we finally log attributes that detail the “action” that happened, along with any other useful information we can capture that may be useful for debugging. This typically includes a result for the action, and any application state that led up to it.
logger.info('cart.checkout', {
'checkout.result': 'failed',
'checkout.failure.reason_code': reasonCode,
'cart.coupon_code': couponCode,
'cart.total_cents': cart.totalAmount,
'cart.total_items': cart.totalItems,
'cart.item_ids': cart.itemIds, // scalar array of id strings
});To enforce scoped attribute keys with ESLint, configure the attribute-key rule like this:
Keep event attributes inline
Spreads and helpers can make logs more consistent, but they usually belong at the context boundary, not inside every event log.
Shared contextual attributes like “auth.org_id”, “auth.user_tier”, or “flags.name” are often useful on many logs.
Rather than spreading them into every event log, set them through your logger’s context mechanism. For Sentry, use the isolation-scope pattern above. This keeps event attributes explicit, but every shared value still needs privacy and retention review before it is propagated.
Most attributes should describe the event itself and use its event namespace. Add a small number of surrounding contextual namespaces only when they materially help explain, filter, or investigate the event.
Keeping attributes inline makes it easier to review the log event where it happened, helps prevent unknown attributes from sneaking in, and ensures the log event is self-contained and easy to understand.
logger.warn('payment.capture', {
'payment.id': payment.id,
'payment.amount_cents': payment.amountCents,
'payment.currency': payment.currency,
'payment.result': 'failed',
'payment.failure.reason_code': reasonCode,
});Avoid this:
const paymentAttributes = getPaymentLogAttributes(payment);
logger.warn('payment.capture', {
...paymentAttributes,
'payment.result': 'failed',
'payment.failure.reason_code': reasonCode,
});I keep this rule to help enforce consistency, but there isn’t necessarily anything inherently wrong with using shared attributes or helpers to attach attributes to logs. You just need to remain consistent and be mindful about what you’re logging. If you were using a schema validation tool, this would be less of a concern.
To enforce explicit attributes with ESLint, configure the inline-attributes rule like this:
Use primitive attribute values
Structured logs are most useful when attributes are easy to query, filter, group, and aggregate. That starts with formatting values in ways your logging backend can index and search predictably.
Along with a flat attribute key structure, I prefer to limit attribute values to primitives: strings, numbers, booleans, and arrays of those primitives.
The plugin can always reject values that are visibly non-primitive in the source, such as inline object literals and arrays of objects. Its default, non-type-aware behavior allows expressions whose runtime type is unknown, including identifiers and member expressions. Turning on disallowUnknownAttributeValues rejects those unknown expressions too, so use that option only if your project intentionally accepts the resulting false positives for ordinary dynamic values.
Logging raw objects is tempting when debugging because they preserve the full detail, but they usually add noise and cost without much query value. Nested objects vary wildly across code paths. Arrays of objects are even worse: they can explode the size of a log line while still being awkward to search.
Avoid this:
logger.warn('webhook.delivery', {
webhook: webhook,
response: response,
error: error,
});Prefer this:
logger.warn('webhook.delivery', {
'webhook.id': webhook.id,
'webhook.destination.host': webhook.url.hostname,
'webhook.result': 'failed',
'http.status_code': response.status,
'retry.attempt': attempt,
'error.name': error.name,
'error.code': error.code,
});That gives you fields you can actually query:
message = "webhook.delivery"
webhook.result = "failed"
http.status_code >= 500
retry.attempt > 2When dealing with numeric values, it’s a good idea to include the unit in the attribute name. size is vague. size_bytes is useful. amount is vague. amount_cents is useful.
A lot of structured logging examples out there will show timing data on the log event. If you are using a tracing provider, like Sentry, you should avoid manually timing operations in the log and implement custom spans instead. Tracing is the proper domain for timing operations. Because in Sentry, logs are trace-connected, you can easily correlate your logs with the spans they are associated with.
A few value guidelines:
- Use strings, numbers, booleans, and arrays of those values.
- Flatten objects into the few fields you will query.
- Include units in numeric attribute names.
- Avoid logging duration or timing fields; put those on spans.
- Avoid logging full request, response, user, payment, or error objects.
- Be especially careful with arrays. Arrays of strings are often fine; arrays of objects usually are not.
To enforce primitive values with ESLint, configure the primitive-attributes rule like this:
Keeping consistent with a linter
Once you find a pattern, enforce it with a linter. Not only will this help you remain consistent so your logs remain useful, it also makes your AI agents smarter. You can use /goal with your linter as the validation step to help automate writing and migrating logs.
Start with warnings while you adopt the pattern. Turn them into errors when your team is ready to enforce it in CI.
If you are using different languages for your frontend and backend, make sure you enforce the same pattern on both sides. That’s another situation where I would ask AI to port my ESLint rules to Flake8 for python or other linters for other languages.
Linters can catch the shape of a log, but can’t tell whether payment.result should really be checkout.result, or whether the event is missing the one field you always need during an incident.
For that, we can use a prompt with our AI agents to review and audit the logs for us, with our rules and guidelines in mind.
This is again where we can use the linter with the /goal command to get even better results.
To review your logs with these rules, use this prompt:
A good log answers the next question
The point is not that every codebase needs exactly the same log schema. The point is that every codebase needs a shared idea of what a good log looks like.
Start with a boring convention: stable event names, scoped keys, event attributes written with intent, and primitive values your backend can search. Then enforce the parts a linter can understand, and review the parts that need domain judgment.
Good logs are not just breadcrumbs for the person who wrote the code. They are small, consistent records that help the next person answer: what happened, where did it happen, who or what was affected, and what should we look at next?
FAQs
What should a structured log include?
A structured log should capture the relevant application state for the event: queryable attributes that describe what happened and the outcome, plus the context collected leading up to it.
How should I name log events?
Name log events with stable, low-cardinality strings that describe what happened, such as auth.login, payment.capture, or webhook.delivery. Avoid putting dynamic values like usernames, IDs, amounts, or error messages in the event name. Those details belong in log attributes.
Why should structured logging use flat attribute keys?
Flat, scoped log attribute keys like payment.result and payment.failure.reason_code are easier to query and group than nested objects. Dot notation keeps attributes organized while making each field directly searchable by your logging backend.
How should I choose log attribute values?
Include useful application state that will help you understand what happened leading up to the event. Consider what primary dimensions you want to be able to query and group on. Prefer strings, numbers, booleans, and arrays of primitives. Avoid raw request, response, user, payment, or error objects unless a schema controls exactly what gets logged.
How should I enforce best practices for structured logging?
Use a linter for your language or framework with a set of enforceable policies to help you keep your logs consistent and useful. Use a capable logging library with context support to help keep your logs consistent.