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