Skip to main content

Simplifying Logging for Payment Gateway Systems

Photo by Sarah Worth on Unsplash

Logging is one of the most important parts of any backend system — especially in payment gateways where traceability is key. Recently, I built a custom logger to improve visibility in API requests, responses, and errors.

In this guide, I’ll break down how it works, how you can customize it, and I’ll also share the full source code at the end.

Why Build a Custom Logger?

While frameworks like SLF4J and Logback are excellent, I wanted something:

  • Easy to reuse
  • With consistent formatting (traceId, partnerRefNo, etc.)
  • Ready for structured JSON logs in the future
  • Clean enough for junior developers to understand and use quickly

My primary use case: logging all payment requests and responses, along with errors, warnings, and system exceptions, with a focus on traceability.

Walkthrough: Building the Logger

1. 🔧 Setting Up the Logger and Mapper

We start with setting up the logger and a Jackson ObjectMapper to serialize data.

private val logger: Logger = Logger.getLogger(CustomLogger::class.java.name)
private val mapper: ObjectMapper = ObjectMapper()

There’s also a flag to switch between plain-text and JSON-style logs:

private val IS_JSON_FORMAT: Boolean = false

2. 🧩 Logging Entry Points

The logger provides methods like info()error()warn(), etc. Each one wraps the logging logic and adds structured details like traceIdpartnerRefNo, and refNo.

fun info(traceId: String?, partnerRefNo: String?, refNo: String?, message: String, data: Map<String, Any?>?) {
log(LogLevel.INFO) {
formatLogMessage(
type = LogLevel.INFO.name,
traceId = traceId,
partnerRefNo = partnerRefNo,
refNo = refNo,
message = message,
data = data,
)
}
}

This makes sure logs are uniform and include context-critical info.

3. 💥 Logging Exceptions

The exception() function is a special one. It logs errors and extracts useful stack trace info.

fun exception(traceId: String?, message: String?, exception: Throwable?, data: Map<String, Any?>?, verbose: Boolean?) {
log(LogLevel.ERROR) {
formatLogMessage(
type = LogLevel.ERROR.name,
traceId = traceId,
message = message ?: "Service failure occurred, see details.",
data = data,
exceptionData = exception?.let { extractExceptionData(it, verbose) }
)
}
}

fun extractExceptionData(exception: Throwable, verbose: Boolean?): Map<String, Any?> {
val stackTraceElement = exception.stackTrace.firstOrNull()

return buildMap {
stackTraceElement?.let {
put("location", "${it.className}:${it.lineNumber}")
}

put("type", exception::class.java.name)

if (!exception.message.isNullOrBlank()) {
put("message", exception.message)
}

exception.cause?.let {
put("causeType", it::class.java.name)
put("causeMessage", it.message)
}

if (verbose == true) {
put("stackTrace", trim(exception.stackTraceToString()))
}
}
}

It pulls out:

  • Class and line of error
  • Exception type and message
  • Cause (if any)
  • Full stack trace if verbose mode is on

4. 🧼 Consistent Formatting

The formatLogMessage() function builds your final log string. It supports both plain-text and JSON.

Plain-text log looks like:

[traceId][partnerRefNo / refNo] Message, data: {...}, exception: {...}

If you ever want to switch to JSON logs for ELK stack or Cloud logging tools, just toggle:

private val IS_JSON_FORMAT = true

5. 🧹 MDC for Trace Context

We’re using MDC.put() to set temporary data like trace IDs for each log. This is helpful when logs are gathered in external systems and you want to filter them by trace ID or reference number.

private fun putToMDC(key: String, value: String?) {
if (!value.isNullOrBlank()) {
MDC.put(key, value)
}
}

Pros and Cons

Pros:

  • Reusable and consistent log formatting
  • Easy to integrate into APIs
  • Good for tracing issues in payment systems
  • Supports future switch to structured JSON

⚠️ Cons:

  • Relies on manual inputs (traceId, refNo)
  • Could grow bulky with more formats
  • Not as dynamic as some logging frameworks (e.g. Logback with XML config)
  • JSON toggle is static on this version

Sample Usage

Here’s how you’d use it in a controller or service:

CustomLogger.request(traceId = "abc-123", partnerRefNo = "P001", refNo = "TX999", data = mapOf("amount" to 1500))
CustomLogger.response(traceId = "abc-123", partnerRefNo = "P001", refNo = "TX999", data = mapOf("status" to "success"))
CustomLogger.error(traceId = "abc-123", message = "Payment failed", data = mapOf("reason" to "Timeout"))

🔁 Full Code

Here’s the complete source code of the custom logger:

import com.fasterxml.jackson.databind.ObjectMapper
import org.jboss.logging.Logger
import org.jboss.logging.MDC
import javax.inject.Inject

object CustomLogger {
private enum class LogLevel {
INFO, WARN, DEBUG, ERROR, FATAL, TRACE
}

private val IS_JSON_FORMAT: Boolean = false

private val logger: Logger = Logger.getLogger(CustomLogger::class.java.name)
private val mapper: ObjectMapper = ObjectMapper()

fun request(
traceId: String? = null,
partnerRefNo: String? = null,
refNo: String? = null,
data: Map<String, Any?>? = null
)
{
log(LogLevel.INFO) {
formatLogMessage(
type = LogLevel.INFO.name,
traceId = traceId,
partnerRefNo = partnerRefNo,
refNo = refNo,
message = "Received API request.",
data = data,
)
}
}

fun response(
traceId: String? = null,
partnerRefNo: String? = null,
refNo: String? = null,
data: Map<String, Any?>? = null
)
{
log(LogLevel.INFO) {
formatLogMessage(
type = LogLevel.INFO.name,
traceId = traceId,
partnerRefNo = partnerRefNo,
refNo = refNo,
message = "Returning API response.",
data = data,
)
}
}

fun info(
traceId: String? = null,
partnerRefNo: String? = null,
refNo: String? = null,
message: String,
data: Map<String, Any?>? = null
)
{
log(LogLevel.INFO) {
formatLogMessage(
type = LogLevel.INFO.name,
traceId = traceId,
partnerRefNo = partnerRefNo,
refNo = refNo,
message = message,
data = data,
)
}
}

fun warn(
traceId: String? = null,
partnerRefNo: String? = null,
refNo: String? = null,
message: String,
data: Map<String, Any?>? = null
)
{
log(LogLevel.WARN) {
formatLogMessage(
type = LogLevel.WARN.name,
traceId = traceId,
partnerRefNo = partnerRefNo,
refNo = refNo,
message = message,
data = data,
)
}
}

fun debug(
traceId: String? = null,
partnerRefNo: String? = null,
refNo: String? = null,
message: String,
data: Map<String, Any?>? = null
)
{
log(LogLevel.DEBUG) {
formatLogMessage(
type = LogLevel.DEBUG.name,
traceId = traceId,
partnerRefNo = partnerRefNo,
refNo = refNo,
message = message,
data = data,
)
}
}

fun trace(
traceId: String? = null,
partnerRefNo: String? = null,
refNo: String? = null,
message: String,
data: Map<String, Any?>? = null
)
{
log(LogLevel.TRACE) {
formatLogMessage(
type = LogLevel.TRACE.name,
traceId = traceId,
partnerRefNo = partnerRefNo,
refNo = refNo,
message = message,
data = data,
)
}
}

fun error(
traceId: String? = null,
partnerRefNo: String? = null,
refNo: String? = null,
message: String,
data: Map<String, Any?>? = null
)
{
log(LogLevel.ERROR) {
formatLogMessage(
type = LogLevel.ERROR.name,
traceId = traceId,
partnerRefNo = partnerRefNo,
refNo = refNo,
message = message,
data = data,
)
}
}

fun fatal(
traceId: String? = null,
partnerRefNo: String? = null,
refNo: String? = null,
message: String,
data: Map<String, Any?>? = null
)
{
log(LogLevel.FATAL) {
formatLogMessage(
type = LogLevel.FATAL.name,
traceId = traceId,
partnerRefNo = partnerRefNo,
refNo = refNo,
message = message,
data = data,
)
}
}

fun exception(
traceId: String? = null,
partnerRefNo: String? = null,
refNo: String? = null,
message: String? = null,
exception: Throwable? = null,
data: Map<String, Any?>? = null,
verbose: Boolean? = false
)
{
log(LogLevel.ERROR) {
formatLogMessage(
type = LogLevel.ERROR.name,
traceId = traceId,
partnerRefNo = partnerRefNo,
refNo = refNo,
message = message ?: "Service failure occurred, see details.",
data = data,
exceptionData = exception?.let { extractExceptionData(it, verbose) }
)
}
}

fun extractExceptionData(exception: Throwable, verbose: Boolean?): Map<String, Any?> {
val stackTraceElement = exception.stackTrace.firstOrNull()

return buildMap {
stackTraceElement?.let {
put("location", "${it.className}:${it.lineNumber}")
}

put("type", exception::class.java.name)

if (!exception.message.isNullOrBlank()) {
put("message", exception.message)
}

exception.cause?.let {
put("causeType", it::class.java.name)
put("causeMessage", it.message)
}

if (verbose == true) {
put("stackTrace", trim(exception.stackTraceToString()))
}
}
}

private fun log(level: LogLevel, message: () -> String) {
try {
when (level) {
LogLevel.INFO -> if (logger.isInfoEnabled) logger.info(message())
LogLevel.WARN -> if(logger.isEnabled(Logger.Level.WARN)) logger.warn(message())
LogLevel.DEBUG -> if (logger.isDebugEnabled) logger.debug(message())
LogLevel.TRACE -> if (logger.isTraceEnabled) logger.trace(message())
LogLevel.ERROR -> logger.error(message())
LogLevel.FATAL -> logger.fatal(message())
}
} finally {
MDC.clear()
}
}

private fun formatLogMessage(
type: String,
message: String,
traceId: String? = null,
partnerRefNo: String? = null,
refNo: String? = null,
data: Map<String, Any?>? = null,
exceptionData: Map<String, Any?>? = null,
)
: String {
putToMDC("txnTraceId", traceId)
putToMDC("txnPartnerRefNo", partnerRefNo)
putToMDC("txnRefNo", refNo)

return if (IS_JSON_FORMAT) {
try {
mapper.writeValueAsString(
mutableMapOf(
"traceId" to traceId,
"partnerRefNo" to partnerRefNo,
"refNo" to refNo,
"message" to message,
"data" to data,
"exception" to exceptionData
).filterValues { it != null }
)
} catch (e: Exception) {
mapper.writeValueAsString(
mutableMapOf(
"traceId" to traceId,
"partnerRefNo" to partnerRefNo,
"refNo" to refNo,
"message" to message
).filterValues { it != null }
)
}
} else {
val traceIdContent =
when {
!traceId.isNullOrBlank() -> "[$traceId]"
else -> ""
}

val referenceNoContent =
when {
!partnerRefNo.isNullOrBlank() && !refNo.isNullOrBlank() -> "[$partnerRefNo / $refNo]"
!partnerRefNo.isNullOrBlank() && refNo.isNullOrBlank() -> "[$partnerRefNo]"
partnerRefNo.isNullOrBlank() && !refNo.isNullOrBlank() -> "[$refNo]"
else -> ""
}

val dataContent =
data?.takeIf {
it.isNotEmpty()
}?.let {
try {
", data: ${mapper.writeValueAsString(it)}"
} catch (e: Exception) {
", data: unable to parse data"
}
} ?: ""

val exceptionContent =
exceptionData?.takeIf {
it.isNotEmpty()
}?.let {
try {
", exception: ${mapper.writeValueAsString(it)}"
} catch (e: Exception) {
", exception: unable to parse exception"
}
} ?: ""

"$traceIdContent$referenceNoContent $message$dataContent$exceptionContent"
}
}

private fun putToMDC(key: String, value: String?) {
if (!value.isNullOrBlank()) {
MDC.put(key, value)
}
}

private fun trim(value: String): String {
return try {
value
.replace(Regex("\\s+"), " ")
.replace(Regex("\\r?\\n+"), " ")
.trim()
} catch (exception: Exception) {
value
}
}
}

📌 Final Thoughts

Logging is often treated as an afterthought — but in systems where traceability is crucial, like payment gateways, it quickly becomes your first line of defense. While this custom logger doesn’t aim to replace enterprise-grade logging frameworks, it fills a practical need for teams handling sensitive, high-volume transactions.

By building this, my goals were simple:

  • Eliminate repetitive logging patterns
  • Ensure consistent, structured output
  • Speed up debugging and issue resolution

If you’re working on backend systems — especially in finance, integrations, or anything mission-critical — having a contextual, structured logger isn’t just a nice-to-have. It’s a time-saver, a safety net, and sometimes, your fastest path to the root cause.

Comments

Popular posts from this blog

Understanding Number Systems: Decimal, Binary, and Hexadecimal

In everyday life, we use numbers all the time, whether for counting, telling time, or handling money. The number system we’re most familiar with is the   decimal system , but computers use other systems, such as   binary   and   hexadecimal . Let’s break down these number systems to understand how they work. What is a Number System? A number system is a way of representing numbers using a set of symbols and rules. The most common number systems are: Decimal (Base 10) Binary (Base 2) Hexadecimal (Base 16) Each system has a different “base” that tells us how many unique digits (symbols) are used to represent numbers. Decimal Number System (Base 10) This is the system we use daily. It has  10 digits , ranging from  0 to 9 . Example: The number  529  in decimal means: 5 × 1⁰² + 2 × 1⁰¹ + 9 × 1⁰⁰ =  500 + 20 + 9 = 529 Each position represents a power of 10, starting from the rightmost digit. Why Base 10? Decimal is base 10 because it has 10 digits...

How to Monetize Your API as an Individual Developer While Hosting on Your Own Server?

In the API economy, cloud services like AWS, Google Cloud, and Azure offer many conveniences, such as scaling and infrastructure management. However, some developers prefer more control and autonomy, opting to host their APIs on personal servers. Whether for cost efficiency, data privacy, or customization, hosting your own API comes with both advantages and challenges. But, even without cloud platforms, there are effective ways to monetize your API. This guide will explore how individual developers can successfully monetize their APIs while hosting them on their own servers. Why Host Your API on Your Own Server? Hosting your own API gives you full control over the infrastructure and potentially lower long-term costs. Here’s why some developers choose this approach: Cost Control : Instead of paying ongoing cloud fees, you may opt for a one-time or lower-cost hosting solution that fits your budget and resource needs. Data Ownership : You have full control over data, which is critical if ...

The Weight of Responsibility: A Developer’s Journey to Balance Passion and Reality

For the past several years, Eddie has been on a steady climb in his career as a developer, but recently, he found himself at a crossroads — caught between the weight of his responsibilities and the desire to pursue his true passions. His journey began with a three-month internship as a web developer, which led to nearly four years in an application developer role. After that, he spent almost a year as a systems associate, managing tasks across systems analysis, quality assurance, and business analysis. Eventually, he returned to full-time software development for another two years before transitioning into more complex roles. For over a year, he worked as a multi-role software developer and database administrator before stepping into his current position as a senior software developer, database administrator, and cloud administrator — occasionally handling security tasks as well. Now, with over 8 years of professional experience, he also leads a small team of developers, which has been...

The Hidden Costs of Overdesign and Bad Practices in API Systems

In software development, simplicity and clarity are often sacrificed in favor of overly complex solutions. While it can be tempting to add more features and intricate designs to ensure robustness, overdesign and poor practices can have significant consequences. They frustrate developers, lead to inefficiencies, increase costs, and put unnecessary strain on system resources.  A recent example involving a team that has faced challenges with complexity highlights the pitfalls of such an approach. Overdesign: The Problem of Too Much Complexity Overdesign occurs when systems are built with more complexity than necessary. This might manifest in bloated APIs, convoluted data flows, or excessive checks and processes that don’t add substantial value. The goal is often to anticipate future problems, but this approach typically results in cumbersome systems that are difficult to maintain and scale. In one case, a company found itself paying a hefty price just to host two API services and a po...

Selenium for Beginners: What, Where, When, and Why to Use It in Automated Testing

In today’s software development landscape, automated testing has become essential for delivering robust applications efficiently. Among various automated testing tools,   Selenium   stands out as one of the most widely used and beginner-friendly options. As you embark on your journey into automated testing, it’s crucial to understand the   what, where, when, and why   of using Selenium. In this guide we will run through these essentials and help you decide if Selenium is the right tool for you. What is Selenium? Selenium  is an open-source framework used primarily for automating web browsers. It enables developers and testers to write scripts that interact with websites, simulating actions like clicking buttons, filling out forms, and navigating pages, which allows for comprehensive automated testing. Selenium supports multiple programming languages, including Python, Java, C#, and JavaScript, making it flexible for teams with different coding preferences. Key C...