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 traceId
, partnerRefNo
, 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
Post a Comment