diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/LogUtils.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/LogUtils.java
new file mode 100644
index 00000000..01ba9009
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/LogUtils.java
@@ -0,0 +1,170 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2022. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.core.utils;
+
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+
+/*******************************************************************************
+ **
+ *******************************************************************************/
+public class LogUtils
+{
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static String jsonLog(LogPair... logPairs)
+ {
+ if(logPairs == null || logPairs.length == 0)
+ {
+ return ("{}");
+ }
+
+ List filteredList = Arrays.stream(logPairs).filter(Objects::nonNull).toList();
+ if(filteredList.isEmpty())
+ {
+ return ("{}");
+ }
+
+ return ('{' + filteredList.stream().map(LogPair::toString).collect(Collectors.joining(",")) + '}');
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static LogPair logPair(String key, Object value)
+ {
+ return (new LogPair(key, value));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static LogPair logPair(String key, LogPair... values)
+ {
+ return (new LogPair(key, values));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static class LogPair
+ {
+ private String key;
+ private Object value;
+
+
+
+ /*******************************************************************************
+ ** Constructor
+ **
+ *******************************************************************************/
+ public LogPair(String key, Object value)
+ {
+ this.key = key;
+ this.value = value;
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public String toString()
+ {
+ String valueString = getValueString(value);
+
+ return "\"" + Objects.requireNonNullElse(key, "null").replace('"', '.') + "\":" + valueString;
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private String getValueString(Object value)
+ {
+ String valueString;
+ if(value == null)
+ {
+ valueString = "null";
+ }
+ else if(value instanceof LogPair subLogPair)
+ {
+ valueString = '{' + subLogPair.toString() + '}';
+ }
+ else if(value instanceof LogPair[] subLogPairs)
+ {
+ String subLogPairsString = Arrays.stream(subLogPairs).map(LogPair::toString).collect(Collectors.joining(","));
+ valueString = '{' + subLogPairsString + '}';
+ }
+ else if(value instanceof UnsafeSupplier us)
+ {
+ try
+ {
+ Object o = us.get();
+ return getValueString(o);
+ }
+ catch(Exception e)
+ {
+ valueString = "LogValueError";
+ }
+ }
+ else if(value instanceof Number n)
+ {
+ valueString = String.valueOf(n);
+ }
+ else
+ {
+ valueString = '"' + String.valueOf(value).replace("\"", "\\\"") + '"';
+ }
+ return valueString;
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @FunctionalInterface
+ public interface UnsafeSupplier
+ {
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ Object get() throws Exception;
+ }
+
+}
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/LogUtilsTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/LogUtilsTest.java
new file mode 100644
index 00000000..63396d3c
--- /dev/null
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/LogUtilsTest.java
@@ -0,0 +1,122 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2022. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.core.utils;
+
+
+import java.math.BigDecimal;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.junit.jupiter.api.Test;
+import static com.kingsrook.qqq.backend.core.utils.LogUtils.jsonLog;
+import static com.kingsrook.qqq.backend.core.utils.LogUtils.logPair;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+
+/*******************************************************************************
+ ** Unit test for com.kingsrook.qqq.backend.core.utils.LogUtils
+ *******************************************************************************/
+class LogUtilsTest
+{
+ private static final Logger LOG = LogManager.getLogger(LogUtilsTest.class);
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void test() throws Exception
+ {
+ ////////////////
+ // null cases //
+ ////////////////
+ assertEquals("{}", jsonLog());
+ assertEquals("{}", jsonLog((LogUtils.LogPair) null));
+ assertEquals("{}", jsonLog((LogUtils.LogPair[]) null));
+ assertEquals("""
+ {"null":null}""", jsonLog(logPair(null, (LogUtils.LogPair) null)));
+ assertEquals("""
+ {"null":null}""", jsonLog(logPair(null, (LogUtils.LogPair[]) null)));
+
+ //////////////
+ // escaping //
+ //////////////
+ assertEquals("""
+ {"f.o.o":"b\\"a\\"r"}""", jsonLog(logPair("f\"o\"o", "b\"a\"r")));
+
+ //////////////////
+ // normal stuff //
+ //////////////////
+ assertEquals("""
+ {"foo":"bar"}""", jsonLog(logPair("foo", "bar")));
+
+ assertEquals("""
+ {"bar":1}""", jsonLog(logPair("bar", 1)));
+
+ assertEquals("""
+ {"baz":3.50}""", jsonLog(logPair("baz", new BigDecimal("3.50"))));
+
+ ////////////////
+ // many pairs //
+ ////////////////
+ assertEquals("""
+ {"foo":"bar","bar":1,"baz":3.50}""", jsonLog(logPair("foo", "bar"), logPair("bar", 1), logPair("baz", new BigDecimal("3.50"))));
+
+ //////////////////
+ // nested pairs //
+ //////////////////
+ assertEquals("""
+ {"foo":{"bar":1,"baz":2}}""", jsonLog(logPair("foo", logPair("bar", 1), logPair("baz", 2))));
+
+ assertEquals("""
+ {
+ "foo":
+ {
+ "bar":1,
+ "baz":2
+ }
+ }""".replaceAll("\\s", ""), jsonLog(logPair("foo", logPair("bar", 1), logPair("baz", 2))));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testLog2()
+ {
+ LOG.info(jsonLog(logPair("message", "Doing a thing"), logPair("trackingNo", "1Z123123123"), logPair("Order", logPair("id", 89101324), logPair("client", "ACME"))));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testLogging()
+ {
+ LOG.info(jsonLog(logPair("message", "Doing a thing"), logPair("trackingNo", "1Z123123123"), logPair("Order", logPair("id", 89101324), logPair("client", "ACME"))));
+ }
+
+}
\ No newline at end of file