The Art of Logging What Matters
Developing and testing without good logs is like watching CCTV that’s recording pure darkness. You know something happened, you just can’t see what.
The Art of Logging What Matters Developing and testing without good logs is like watching CCTV that’s recording pure darkness. You know something happened, you just can’t see what. Logging is often treated as an afterthought. Something you sprinkle in once things start going wrong. A few console.log() calls here, a print() there, and suddenly your terminal is flooded with noise that tells you everything and nothing at once. But logs aren’t just debugging breadcrumbs. They’re a narrative, a story of what your system did, when, and why. When done well, logs become one of the most powerful tools you have: they accelerate debugging, reveal hidden behavior, expose flaky tests, and even act as living documentation. This post is about logging done intentionally, not reactively. What “Good Logging” Actually Means A log should tell you something. Not everything, just the right thing. Too little logging, and you’re blind. Too much logging, and you’re buried. The sweet spot is when every log line answers a question you’ll likely ask later: What happened? When? Where? Why? If a log can’t answer at least one of those, it’s noise. The moment you start caring about searchability, analytics, dashboards, or CI integration, you need structure. Structured logs (JSON, key-value fields, consistent formatting) turn logs from text into data. Machines can parse them. Tools can index them. Humans can skim them without getting mad. Yes, logs are technically machine output, but they’re written for people. Good logs don’t assume context. They don’t hide information behind cryptic abbreviations or 200-character stack traces. Think of logs as a user interface for your system’s behavior. If no one can interpret them, they failed, even if they’re technically “there”. Common Logging Mistakes If logs were people, most of them would either never speak… or never shut up. Neither is helpful. Here are some classic ways logging goes wrong. Logging Everything vs Logging Nothing: some developers log every breath their app takes. Others treat logging like it’s a crime. Logs With No Context: “Error occurred”, cool. Where? When? Doing what? With which data? A log that only gives you the event and not the context forces you to open a debugger every single time. Include identifiers, state, parameters. Make every log line self-contained enough that you can understand it in isolation. Logs That Lie: misleading logs are worse than no logs. A log saying “Operation complete” when it failed is sabotage. If your code throws before the log, or logs success before confirming anything, you’re gaslighting yourself. Make logs reflect reality, not intent. Logging Through Abstraction: Decorators Most developers log like this: console.log("starting login"); await login(); console.log("finished login"); It works until you have 100 functions, each with its own copy-pasted logs. Then someone renames a method and forgets to update the log. Then someone else adds another console.log inside and suddenly your output reads like a stream-of-consciousness. This becomes too hard to maintain very quickly. The problem isn’t that we log, it’s where we log. Why Log at the Call-Site When You Can Attach Behavior? Instead of manually writing logs everywhere, what if you could wrap the function with logging behavior? That way, the intent stays in one place, and the code stays clean. That’s what abstractions like decorators are for. You stop thinking “log here, here, and here” and instead say: “Every time this function runs, log it. I don’t care where it’s called.” A decorator is like a wrapper around a function, a little layer that can run code before and after the original function, without modifying its internals. Here is an example. function decorator(target: any, key: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; descriptor.value = async function (...args: any[]) { // do something before calling the decorated method // actually call the method const result = await originalMethod.apply(this, args); // do something after calling the decorated method return result; }; return descriptor; } You can use it to do a lot of things: log function start/end, measure duration, catch and log errors, add metadata, enforce behavior (retries, screenshots, cleanup, etc.). Once you see logging as behavior instead of boilerplate, a whole new set of opportunities shows up. Decorators… Let’s explore an example use case. In automation testing, a single test can involve dozens of steps: clicking buttons, filling forms, waiting for elements, or navigating pages. Keeping track of what succeeded, what failed, and in what order can quickly become overwhelming if you rely on manual logging. Applying the principles I’ve mentioned before, I designed the @step decorator to keep track of what is happening in the test. It lets me understand precisely which steps succeded, and what specific step didn’t come to an end. export function step<This, Args extends never[], Return>() { return function actualDecorator< T extends (this: This, ...args: Args) => Promise<Return> >( target: T, context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Promise<Return>> ): T { async function replacementMethod(this: This, ...args: Args): Promise<Return> { const methodName = context.name as string; const timestamp = getCurrentTimestamp(); console.log(`[ ${timestamp} ] step start -> ${methodName}`); try { const result = await target.call(this, ...args); console.log(`[ ${timestamp} ] step done -> ${methodName}`); return result; } catch (error) { console.log(`[ ${timestamp} ] ‼️ step error -> ${methodName}: ${error}`); throw error; } } return replacementMethod as T; }; } This decorator has a lot to recommend it: Each test method annotated with @step() automatically logs when it starts, succeeds, or fails. You no longer need to sprinkle console.log statements inside each function. By leveraging context.name, the decorator dynamically captures the method’s name. If you rename the method, the logs update automatically. No potential human error, no forgotten renamings, no misinterpreted details. The decorator wraps the original function in a try/catch block, logging any errors and then rethrowing them. This keeps the error flow intact while giving you instant visibility in the logs. Here’s how you can use it in practice: class CheckoutFlow { @step() async acceptPrivacyIfVisible() { if (await this.privacyConsentButton.isVisible()) { await this.privacyConsentButton.click(); } } @step() async submitOrder() { await this.paymentButton.click(); await this.confirmation.waitForVisible(); } } Running these methods produces a clear, timestamped log of the test execution: [ 2025-11-16T11:12:30 ] step start -> acceptPrivacyIfVisible [ 2025-11-16T11:12:31 ] step done -> acceptPrivacyIfVisible [ 2025-11-16T11:12:32 ] step start -> submitOrder [ 2025-11-16T11:12:34 ] step done -> submitOrder …& Beyond: A Logging Domain Specific Language Once you’ve mastered a simple decorator like @step(), the real power of logging emerges when you treat it as a first-class, extensible tool rather than just a convenience. At this level, logging isn’t just about “seeing what happened”, it’s about building a readable, actionable transcript of your programs. In complex software, a single workflow might include multiple steps, retries, and side effects. Simple start/stop logging isn’t enough. That’s where a logging DSL (Domain-Specific Language) of decorators becomes really useful. Examples of useful decorators: @retry(): automatically re-runs flaky steps a set number of times, logging each attempt clearly. @measure(): captures execution time for a method, helping identify performance bottlenecks. @validate(): automatically validates method arguments against a schema or rules, logging or throwing errors if invalid. Here is an example of how these can be used for general purpose payment software, something very common in production-level software engineering. class PaymentService { @step() @measure() @retry(2) @validate() async processPayment(userId: string, amount: number) { await externalPaymentGateway.charge(userId, amount); } } Now, a single function produces a rich, structured narrative. [ 2025-11-16T11:12:32 ] processPayment start -> processPayment (Attempt 1) [ 2025-11-16T11:12:34 ] ‼️ processPayment error -> processPayment (Attempt 1): Amount must be positive [ 2025-11-16T11:12:35 ] processPayment start -> processPayment (Attempt 2) [ 2025-11-16T11:12:37 ] ✓ processPayment done -> processPayment (Attempt 2) | Duration: 200ms This approach transforms raw console logs into self-explanatory transcripts that anyone (developer, tester or manager) can follow. Integrating Logging with Tools & Frameworks Structured, extensible logs aren’t just for local debugging. The best software modules integrate them into the broader CI/CD ecosystem: Log aggregation & dashboards: tools like Elasticsearch Kibana Datadog let you index, filter, and visualize logs across multiple runs and environments. Continuous integration (CI): capture logs as artifacts in Jenkins, GitHub Actions, or GitLab pipelines for later analysis. Alerting & monitoring: tie specific log events (like repeated failures) into alerts so you know immediately when something is wrong. Final Thoughts Developing and testing without good logs leaves you in the dark, guessing what happened and why. Good logging, on the other hand, flips the switch. It illuminates your system’s behavior, turning confusion into clarity. Each well-placed log is a beam of insight, revealing the flow of your code, the state of your data, and the root of failures. Logs are more than just debugging aids, they are documentation and narrative. They tell the story of your application’s execution in a way that is structured, readable, and actionable. Ultimately, good logs don’t just inform, they empower. They help you move faster, understand deeper, and troubleshoot smarter. With well-designed logging, what was once a black box becomes a visible, navigable system. Darkness gives way to light.

