countsForProcess = processStats.stats();
+
+ if(countsForProcess != null)
+ {
+ Stats statsForKey = countsForProcess.computeIfAbsent(key, (k) -> new Stats(0, 0, 0));
+ countsForProcess.put(key, new Stats(statsForKey.calls + addCalls, statsForKey.records + addRecords, statsForKey.millis + addMillis));
+ }
+ }
+
+
+
+ /***************************************************************************
+ * sets the cleanup job's scheduled interval. Also reschedules the job.
+ *
+ * @param cleanupIntervalMillis milliseconds between runs of the cleanup job
+ ***************************************************************************/
+ public void setCleanupIntervalMillis(long cleanupIntervalMillis)
+ {
+ CLEANUP_INTERVAL_MILLIS = cleanupIntervalMillis;
+ scheduledExecutorService.shutdown();
+ scheduleCleanupJob();
+ }
+
+
+
+ /***************************************************************************
+ * @param cleanupMaxAge max age (in millis) that a process is allowed to be
+ * before it gets cleaned up when a cleanup job runs.
+ ***************************************************************************/
+ public void setCleanupMaxAge(long cleanupMaxAge)
+ {
+ CLEANUP_MAX_AGE = cleanupMaxAge;
+ }
+
+}
diff --git a/src/main/java/com/kingsrook/qbits/standardprocesstrace/utils/StandardProcessTraceQueryStatConsumer.java b/src/main/java/com/kingsrook/qbits/standardprocesstrace/utils/StandardProcessTraceQueryStatConsumer.java
new file mode 100644
index 0000000..e60acf2
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/standardprocesstrace/utils/StandardProcessTraceQueryStatConsumer.java
@@ -0,0 +1,67 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. 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.qbits.standardprocesstrace.utils;
+
+
+import com.kingsrook.qqq.backend.core.actions.tables.helpers.QueryStatConsumerInterface;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.model.querystats.QueryStat;
+
+
+/*******************************************************************************
+ * Standard Process Trace's implementation of a QueryStat consumer.
+ *
+ * That is, to accumulate them in the {@link ProcessTraceBackendActivityStatsManager},
+ * associated with the processTraceId in the user's session, so they can be stored
+ * when the process trace is stored.
+ *******************************************************************************/
+public class StandardProcessTraceQueryStatConsumer implements QueryStatConsumerInterface
+{
+ private static final QLogger LOG = QLogger.getLogger(StandardProcessTraceQueryStatConsumer.class);
+
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ @Override
+ public void accept(QueryStat queryStat)
+ {
+ try
+ {
+ String processTraceId = QContext.getQSession().getValue(StandardProcessTracer.PROCESS_TRACE_ID_SESSION_KEY);
+ if(processTraceId == null)
+ {
+ return;
+ }
+
+ String backendName = QContext.getQInstance().getBackendForTable(queryStat.getTableName()).getName();
+ String backendAction = queryStat.getBackendAction();
+
+ ProcessTraceBackendActivityStatsManager.Key key = new ProcessTraceBackendActivityStatsManager.Key(backendName, queryStat.getTableName(), backendAction);
+ ProcessTraceBackendActivityStatsManager.getInstance().add(Long.valueOf(processTraceId), key, 1, queryStat.getRecordCount(), queryStat.getFirstResultMillis());
+ }
+ catch(Exception e)
+ {
+ LOG.warn("Error accepting query stats for process trace", e);
+ }
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/standardprocesstrace/utils/StandardProcessTracer.java b/src/main/java/com/kingsrook/qbits/standardprocesstrace/utils/StandardProcessTracer.java
index f43096c..dd6bcf3 100644
--- a/src/main/java/com/kingsrook/qbits/standardprocesstrace/utils/StandardProcessTracer.java
+++ b/src/main/java/com/kingsrook/qbits/standardprocesstrace/utils/StandardProcessTracer.java
@@ -30,6 +30,7 @@ import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.standardprocesstrace.StandardProcessTraceQBitConfig;
import com.kingsrook.qbits.standardprocesstrace.model.ProcessTrace;
+import com.kingsrook.qbits.standardprocesstrace.model.ProcessTraceBackendActivityStats;
import com.kingsrook.qbits.standardprocesstrace.model.ProcessTraceSummaryLine;
import com.kingsrook.qbits.standardprocesstrace.model.ProcessTraceSummaryLineRecordInt;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
@@ -75,6 +76,8 @@ public class StandardProcessTracer implements ProcessTracerInterface
private static StandardProcessTraceQBitConfig standardProcessTraceQBitConfig;
+ public static final String PROCESS_TRACE_ID_SESSION_KEY = StandardProcessTracer.class.getName() + ".processTraceId";
+
private Instant startTime;
private Long processTraceId;
@@ -98,6 +101,17 @@ public class StandardProcessTracer implements ProcessTracerInterface
.withProcessUUID(runProcessInput.getProcessUUID())));
processTraceId = insertOutput.getRecords().get(0).getValueLong("id");
+
+ ////////////////////////////////////////////////////////////////////////////////////
+ // store the processTraceId in the session. this will be used by the //
+ // ProcessTraceQueryStatConsumer to associated QueryStats with this process trace //
+ ////////////////////////////////////////////////////////////////////////////////////
+ if(QContext.getQSession() != null)
+ {
+ QContext.getQSession().setValue(PROCESS_TRACE_ID_SESSION_KEY, String.valueOf(processTraceId));
+ }
+
+ ProcessTraceBackendActivityStatsManager.getInstance().initProcess(processTraceId);
}
catch(QException e)
{
@@ -113,9 +127,29 @@ public class StandardProcessTracer implements ProcessTracerInterface
@Override
public void handleProcessResume(RunProcessInput runProcessInput)
{
- /////////////////////////////////
- // noop in this implementation //
- /////////////////////////////////
+ try
+ {
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // if a user is resuming a process, e.g., from a frontend, then we want to put that processTraceId in their session //
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ if(processTraceId == null)
+ {
+ QRecord processTrace = GetAction.execute(ProcessTrace.TABLE_NAME, Map.of("processUUID", runProcessInput.getProcessUUID()));
+ if(processTrace != null)
+ {
+ processTraceId = processTrace.getValueLong("id");
+ }
+ }
+
+ if(processTraceId != null && QContext.getQSession() != null)
+ {
+ QContext.getQSession().setValue(PROCESS_TRACE_ID_SESSION_KEY, String.valueOf(processTraceId));
+ }
+ }
+ catch(Exception e)
+ {
+ LOG.warn("Error resuming process trace", e, logPair("processUUID", () -> runProcessInput.getProcessUUID()));
+ }
}
@@ -328,12 +362,28 @@ public class StandardProcessTracer implements ProcessTracerInterface
new InsertAction().execute(new InsertInput(ProcessTraceSummaryLine.TABLE_NAME).withRecordEntities(summaryLines));
}
+
+ //////////////////////////////////////////
+ // build and store backend stat records //
+ //////////////////////////////////////////
+ if(QContext.getQInstance().getTable(ProcessTraceBackendActivityStats.TABLE_NAME) != null)
+ {
+ List stats = ProcessTraceBackendActivityStatsManager.getInstance().getAndRemoveStats(processTraceId);
+ new InsertAction().execute(new InsertInput(ProcessTraceBackendActivityStats.TABLE_NAME).withRecordEntities(stats));
+ }
}
}
catch(Exception e)
{
LOG.warn("Error completing store of processTrace records.", e);
}
+ finally
+ {
+ if(QContext.getQSession() != null)
+ {
+ QContext.getQSession().removeValue(PROCESS_TRACE_ID_SESSION_KEY);
+ }
+ }
}
diff --git a/src/test/java/com/kingsrook/qbits/standardprocesstrace/BaseTest.java b/src/test/java/com/kingsrook/qbits/standardprocesstrace/BaseTest.java
new file mode 100644
index 0000000..e033216
--- /dev/null
+++ b/src/test/java/com/kingsrook/qbits/standardprocesstrace/BaseTest.java
@@ -0,0 +1,86 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. 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.qbits.standardprocesstrace;
+
+
+import java.util.List;
+import com.kingsrook.qbits.standardprocesstrace.utils.StandardProcessTraceQueryStatConsumer;
+import com.kingsrook.qqq.backend.core.actions.tables.helpers.QueryStatManager;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
+import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+
+
+/*******************************************************************************
+ * base class for tests for this QBit.
+ *******************************************************************************/
+public class BaseTest
+{
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @BeforeEach
+ void baseBeforeEach() throws Exception
+ {
+ QInstance qInstance = defineQInstance();
+ new QInstanceValidator().validate(qInstance);
+ QContext.init(qInstance, new QSystemUserSession());
+
+ QueryStatManager queryStatManager = QueryStatManager.getInstance();
+ queryStatManager.start(qInstance, () -> new QSystemUserSession());
+ queryStatManager.setQueryStatConsumers(List.of(new StandardProcessTraceQueryStatConsumer()));
+
+ MemoryRecordStore.fullReset();
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @AfterEach
+ void afterEach()
+ {
+ MemoryRecordStore.fullReset();
+
+ QueryStatManager.getInstance().stop();
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ private QInstance defineQInstance() throws QException
+ {
+ return new StandardProcessTraceTestApplication().defineQInstance();
+ }
+
+
+
+}
diff --git a/src/test/java/com/kingsrook/qbits/standardprocesstrace/StandardProcessTraceTestApplication.java b/src/test/java/com/kingsrook/qbits/standardprocesstrace/StandardProcessTraceTestApplication.java
new file mode 100644
index 0000000..70ac661
--- /dev/null
+++ b/src/test/java/com/kingsrook/qbits/standardprocesstrace/StandardProcessTraceTestApplication.java
@@ -0,0 +1,234 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. 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.qbits.standardprocesstrace;
+
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import com.kingsrook.qbits.standardprocesstrace.utils.StandardProcessTracer;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
+import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
+import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
+import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
+import com.kingsrook.qqq.backend.core.model.backends.QQQBackendsMetaDataProvider;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
+import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
+import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
+import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
+import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
+import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
+import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.model.processes.QQQProcessesMetaDataProvider;
+import com.kingsrook.qqq.backend.core.model.tables.QQQTablesMetaDataProvider;
+import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
+import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
+import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
+import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.LoadViaInsertStep;
+import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
+
+
+/*******************************************************************************
+ * test application (e.g., QInstance) for this QBit's tests.
+ *******************************************************************************/
+public class StandardProcessTraceTestApplication extends AbstractQQQApplication
+{
+ public static final String MEMORY_BACKEND_NAME = "memory";
+
+ public static final String TABLE_NAME_PERSON = "person";
+ public static final String TABLE_NAME_ORDER = "order";
+
+ public static final String PROCESS_NAME_PLACE_ORDERS = "placeOrders";
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ public QInstance defineQInstance() throws QException
+ {
+ QInstance qInstance = new QInstance();
+
+ qInstance.setAuthentication(new QAuthenticationMetaData().withType(QAuthenticationType.FULLY_ANONYMOUS));
+
+ qInstance.addBackend(new QBackendMetaData()
+ .withName(MEMORY_BACKEND_NAME)
+ .withBackendType(MemoryBackendModule.class));
+
+ //////////////////////////////
+ // add tables table and PVS //
+ //////////////////////////////
+ new QQQTablesMetaDataProvider().defineAll(qInstance, MEMORY_BACKEND_NAME, MEMORY_BACKEND_NAME, null);
+ new QQQProcessesMetaDataProvider().defineAll(qInstance, MEMORY_BACKEND_NAME, MEMORY_BACKEND_NAME, null);
+ new QQQBackendsMetaDataProvider().defineAll(qInstance, MEMORY_BACKEND_NAME, MEMORY_BACKEND_NAME, null);
+
+ //////////////////////
+ // produce our qbit //
+ //////////////////////
+ StandardProcessTraceQBitProducer producer = new StandardProcessTraceQBitProducer()
+ .withStandardProcessTraceQBitConfig(new StandardProcessTraceQBitConfig()
+ .withTableMetaDataCustomizer((QInstance instance, QTableMetaData table) ->
+ {
+ table.setBackendName(MEMORY_BACKEND_NAME);
+ return (table);
+ })
+ .withIncludeBackendActivityStats(true));
+
+ MetaDataProducerMultiOutput allQBitMetaData = producer.produce(qInstance);
+ allQBitMetaData.addSelfToInstance(qInstance);
+
+ ////////////////////////////////////////
+ // produce some test tables //
+ // note - nice to be a qbit this too? //
+ ////////////////////////////////////////
+ qInstance.addTable(new QTableMetaData()
+ .withName(TABLE_NAME_PERSON)
+ .withBackendName(MEMORY_BACKEND_NAME)
+ .withPrimaryKeyField("id")
+ .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
+ .withField(new QFieldMetaData("firstName", QFieldType.STRING).withIsEditable(true))
+ .withField(new QFieldMetaData("lastName", QFieldType.STRING).withIsEditable(true))
+ .withCapability(Capability.QUERY_STATS)
+ );
+
+ qInstance.addTable(new QTableMetaData()
+ .withName(TABLE_NAME_ORDER)
+ .withBackendName(MEMORY_BACKEND_NAME)
+ .withPrimaryKeyField("id")
+ .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
+ .withField(new QFieldMetaData("orderNo", QFieldType.STRING).withIsEditable(true))
+ .withField(new QFieldMetaData("storeId", QFieldType.INTEGER).withIsEditable(true))
+ .withField(new QFieldMetaData("shipTo", QFieldType.STRING).withIsEditable(true))
+ .withCapability(Capability.QUERY_STATS)
+ );
+
+ qInstance.addProcess(StreamedETLWithFrontendProcess.processMetaDataBuilder()
+ .withName(PROCESS_NAME_PLACE_ORDERS)
+ .withTableName(TABLE_NAME_PERSON)
+ .withSourceTable(TABLE_NAME_PERSON)
+ .withDestinationTable(TABLE_NAME_ORDER)
+ .withExtractStepClass(ExtractViaQueryStep.class)
+ .withTransformStepClass(PlaceOrdersProcessTransformStep.class)
+ .withLoadStepClass(LoadViaInsertStep.class)
+ .getProcessMetaData()
+ .withProcessTracerCodeReference(new QCodeReference(StandardProcessTracer.class))
+ );
+
+ ///////////////////////////////////////////
+ // turn off audits (why on by default??) //
+ ///////////////////////////////////////////
+ qInstance.getTables().values().forEach(t -> t.setAuditRules(new QAuditRules().withAuditLevel(AuditLevel.NONE)));
+
+ /////////////////
+ // create apps //
+ /////////////////
+ qInstance.addApp(new QAppMetaData()
+ .withName("logs")
+ .withIcon(new QIcon("history_edu"))
+ .withSection(StandardProcessTraceQBitProducer.getAppSection(qInstance)));
+
+ qInstance.addApp(new QAppMetaData()
+ .withName("testData")
+ .withIcon(new QIcon("dataset"))
+ .withSection(new QAppSection()
+ .withName("people")
+ .withTables(List.of(TABLE_NAME_PERSON))));
+
+ return qInstance;
+ }
+
+
+
+ /***************************************************************************
+ * test process transformer - for each input person, creates an output order (to insert)
+ ***************************************************************************/
+ public static class PlaceOrdersProcessTransformStep extends AbstractTransformStep
+ {
+ private ProcessSummaryLine okLine = new ProcessSummaryLine(Status.OK)
+ .withMessageSuffix("")
+ .withSingularFutureMessage("will get a new order")
+ .withPluralFutureMessage("will get new orders")
+ .withSingularPastMessage("got a new order")
+ .withPluralPastMessage("got new orders");
+
+ private ProcessSummaryLine errorLine = new ProcessSummaryLine(Status.ERROR)
+ .withMessageSuffix("")
+ .withSingularFutureMessage("will not get a new order")
+ .withPluralFutureMessage("will not get new orders")
+ .withSingularPastMessage("did not get a new order")
+ .withPluralPastMessage("did not get new orders");
+
+
+
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ @Override
+ public ArrayList getProcessSummary(RunBackendStepOutput runBackendStepOutput, boolean isForResultScreen)
+ {
+ ArrayList rs = new ArrayList<>();
+ okLine.addSelfToListIfAnyCount(rs);
+ errorLine.addSelfToListIfAnyCount(rs);
+ return rs;
+ }
+
+
+
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ @Override
+ public void runOnePage(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException
+ {
+ for(QRecord personRecord : runBackendStepInput.getRecords())
+ {
+ ////////////////////////////////////////
+ // don't let Mr. Burns order anything //
+ ////////////////////////////////////////
+ if("Burns".equals(personRecord.getValueString("lastName")))
+ {
+ errorLine.incrementCountAndAddPrimaryKey(personRecord.getValue("id"));
+ continue;
+ }
+
+ QRecord orderRecord = new QRecord()
+ .withValue("orderNo", UUID.randomUUID().toString())
+ .withValue("shipTo", personRecord.getValueString("firstName") + " " + personRecord.getValueString("lastName"));
+ runBackendStepOutput.addRecord(orderRecord);
+ okLine.incrementCountAndAddPrimaryKey(personRecord.getValue("id"));
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/kingsrook/qbits/standardprocesstrace/utils/ProcessTraceBackendActivityStatsManagerTest.java b/src/test/java/com/kingsrook/qbits/standardprocesstrace/utils/ProcessTraceBackendActivityStatsManagerTest.java
new file mode 100644
index 0000000..415c105
--- /dev/null
+++ b/src/test/java/com/kingsrook/qbits/standardprocesstrace/utils/ProcessTraceBackendActivityStatsManagerTest.java
@@ -0,0 +1,115 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. 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.qbits.standardprocesstrace.utils;
+
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import com.kingsrook.qbits.standardprocesstrace.model.ProcessTraceBackendActivityStats;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.logging.QCollectingLogger;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.utils.SleepUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+
+/*******************************************************************************
+ ** Unit test for ProcessTraceBackendActivityStatsManager
+ *******************************************************************************/
+class ProcessTraceBackendActivityStatsManagerTest
+{
+ ProcessTraceBackendActivityStatsManager.Key key = new ProcessTraceBackendActivityStatsManager.Key("b", "t", "a");
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @AfterEach
+ void afterEach()
+ {
+ ProcessTraceBackendActivityStatsManager manager = ProcessTraceBackendActivityStatsManager.getInstance();
+ manager.shutdown();
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testMaxAgeLarger() throws QException
+ {
+ long processTraceId = 1L;
+ QCollectingLogger collectingLogger = QLogger.activateCollectingLoggerForClass(ProcessTraceBackendActivityStatsManager.class);
+
+ ProcessTraceBackendActivityStatsManager manager = ProcessTraceBackendActivityStatsManager.getInstance();
+ manager.initProcess(processTraceId);
+ manager.add(processTraceId, key, 1, 2, 3);
+ manager.setCleanupIntervalMillis(10);
+ manager.setCleanupMaxAge(100);
+ SleepUtils.sleep(50, TimeUnit.MILLISECONDS);
+
+ QLogger.deactivateCollectingLoggerForClass(ProcessTraceBackendActivityStatsManager.class);
+ assertThat(collectingLogger.getCollectedMessages()).allMatch(clm -> clm.getMessage().contains("\"size\":1"));
+
+ List stats = manager.getAndRemoveStats(processTraceId);
+ assertEquals(1, stats.size());
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testMaxAgeSmaller() throws QException
+ {
+ long processTraceId = 2L;
+ QCollectingLogger collectingLogger = QLogger.activateCollectingLoggerForClass(ProcessTraceBackendActivityStatsManager.class);
+
+ ProcessTraceBackendActivityStatsManager manager = ProcessTraceBackendActivityStatsManager.getInstance();
+ manager.initProcess(processTraceId);
+ manager.add(processTraceId, key, 1, 2, 3);
+ manager.setCleanupIntervalMillis(5);
+ manager.setCleanupMaxAge(20);
+ SleepUtils.sleep(50, TimeUnit.MILLISECONDS);
+
+ QLogger.deactivateCollectingLoggerForClass(ProcessTraceBackendActivityStatsManager.class);
+ assertThat(collectingLogger.getCollectedMessages()).anyMatch(clm -> clm.getMessage().contains("\"size\":1"));
+ assertThat(collectingLogger.getCollectedMessages()).anyMatch(clm -> clm.getMessage().contains("\"size\":0"));
+
+ List stats = manager.getAndRemoveStats(processTraceId);
+ assertEquals(0, stats.size());
+
+ ////////////////////////////////////////////////////////////////////////////////////////////
+ // make sure even after a cleanup, we don't fail if we do try to add values for a process //
+ ////////////////////////////////////////////////////////////////////////////////////////////
+ manager.add(processTraceId, key, 1, 2, 3);
+ stats = manager.getAndRemoveStats(processTraceId);
+ assertEquals(1, stats.size());
+ }
+
+}
\ No newline at end of file
diff --git a/src/test/java/com/kingsrook/qbits/standardprocesstrace/utils/StandardProcessTracerTest.java b/src/test/java/com/kingsrook/qbits/standardprocesstrace/utils/StandardProcessTracerTest.java
new file mode 100644
index 0000000..3944203
--- /dev/null
+++ b/src/test/java/com/kingsrook/qbits/standardprocesstrace/utils/StandardProcessTracerTest.java
@@ -0,0 +1,177 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. 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.qbits.standardprocesstrace.utils;
+
+
+import java.util.List;
+import com.kingsrook.qbits.standardprocesstrace.BaseTest;
+import com.kingsrook.qbits.standardprocesstrace.StandardProcessTraceTestApplication;
+import com.kingsrook.qbits.standardprocesstrace.model.ProcessTrace;
+import com.kingsrook.qbits.standardprocesstrace.model.ProcessTraceBackendActivityStats;
+import com.kingsrook.qbits.standardprocesstrace.model.ProcessTraceSummaryLine;
+import com.kingsrook.qbits.standardprocesstrace.model.ProcessTraceSummaryLineRecordInt;
+import com.kingsrook.qbits.standardprocesstrace.model.ProcessTraceSummaryStatusEnum;
+import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
+import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
+import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
+import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.tables.QQQTableTableManager;
+import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+
+/*******************************************************************************
+ ** Unit test for StandardProcessTracer
+ *******************************************************************************/
+class StandardProcessTracerTest extends BaseTest
+{
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void test() throws QException
+ {
+ insertHomerMargeAndMrBurns();
+
+ ////////////////////////////////////////
+ // run process skipping backend steps //
+ ////////////////////////////////////////
+ RunProcessInput input = new RunProcessInput();
+ input.setProcessName(StandardProcessTraceTestApplication.PROCESS_NAME_PLACE_ORDERS);
+ input.setCallback(QProcessCallbackFactory.forFilter(new QQueryFilter()));
+ input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
+ new RunProcessAction().execute(input);
+
+ baseAssertionsAfterProcess();
+
+ Integer personTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), StandardProcessTraceTestApplication.TABLE_NAME_PERSON);
+ Integer orderTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), StandardProcessTraceTestApplication.TABLE_NAME_ORDER);
+
+ List processTraceBackendActivityStatRecords = QueryAction.execute(ProcessTraceBackendActivityStats.TABLE_NAME, new QQueryFilter());
+ assertThat(processTraceBackendActivityStatRecords)
+ .hasSizeGreaterThanOrEqualTo(2)
+ .anyMatch(r -> r.getValue("qqqTableId").equals(personTableId) && r.getValue("actionName").equals(QueryAction.class.getSimpleName()) && r.getValueInteger("callCount").equals(1) && r.getValueInteger("recordCount").equals(3))
+ .anyMatch(r -> r.getValue("qqqTableId").equals(orderTableId) && r.getValue("actionName").equals(InsertAction.class.getSimpleName()) && r.getValueInteger("callCount").equals(1) && r.getValueInteger("recordCount").equals(2));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testResumingFrontendProcess() throws QException
+ {
+ insertHomerMargeAndMrBurns();
+
+ //////////////////////////////////////
+ // start process - break at preview //
+ //////////////////////////////////////
+ RunProcessInput input = new RunProcessInput();
+ input.setProcessName(StandardProcessTraceTestApplication.PROCESS_NAME_PLACE_ORDERS);
+ input.setCallback(QProcessCallbackFactory.forFilter(new QQueryFilter()));
+ input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK);
+ RunProcessOutput output = new RunProcessAction().execute(input);
+
+ /////////////////////////////////////
+ // continue to execute the process //
+ /////////////////////////////////////
+ input.setProcessUUID(output.getProcessUUID());
+ input.setStartAfterStep(output.getProcessState().getNextStepName().get());
+ new RunProcessAction().execute(input);
+
+ baseAssertionsAfterProcess();
+
+ Integer personTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), StandardProcessTraceTestApplication.TABLE_NAME_PERSON);
+ Integer orderTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), StandardProcessTraceTestApplication.TABLE_NAME_ORDER);
+
+ List processTraceBackendActivityStatRecords = QueryAction.execute(ProcessTraceBackendActivityStats.TABLE_NAME, new QQueryFilter());
+ assertThat(processTraceBackendActivityStatRecords)
+ .hasSizeGreaterThanOrEqualTo(2)
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // this time, there'll be 2 calls to the query (for preview, and execute steps), for twice as many records total //
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ .anyMatch(r -> r.getValue("qqqTableId").equals(personTableId) && r.getValue("actionName").equals(QueryAction.class.getSimpleName()) && r.getValueInteger("callCount").equals(2) && r.getValueInteger("recordCount").equals(6))
+ .anyMatch(r -> r.getValue("qqqTableId").equals(orderTableId) && r.getValue("actionName").equals(InsertAction.class.getSimpleName()) && r.getValueInteger("callCount").equals(1) && r.getValueInteger("recordCount").equals(2));
+ }
+
+
+
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ private static void insertHomerMargeAndMrBurns() throws QException
+ {
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // insert 3 people to run through the process. //
+ // the process will insert an order for Homer & Marge, but is hard-coded to make an error for Mr. Burns. //
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////
+ new InsertAction().execute(new InsertInput(StandardProcessTraceTestApplication.TABLE_NAME_PERSON).withRecords(List.of(
+ new QRecord().withValue("id", 100).withValue("firstName", "Homer").withValue("lastName", "Simpson"),
+ new QRecord().withValue("id", 101).withValue("firstName", "Marge").withValue("lastName", "Simpson"),
+ new QRecord().withValue("id", 102).withValue("firstName", "Mr.").withValue("lastName", "Burns")
+ )));
+ }
+
+
+
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ private static void baseAssertionsAfterProcess() throws QException
+ {
+ List processTraceRecords = QueryAction.execute(ProcessTrace.TABLE_NAME, new QQueryFilter());
+ assertEquals(1, processTraceRecords.size());
+
+ List processTraceSummaryLineRecords = QueryAction.execute(ProcessTraceSummaryLine.TABLE_NAME, new QQueryFilter());
+ assertEquals(2, processTraceSummaryLineRecords.size());
+
+ QRecord okSummaryLineRecord = processTraceSummaryLineRecords.stream().filter(r -> r.getValueString("status").equals(ProcessTraceSummaryStatusEnum.OK.getPossibleValueId())).findFirst().get();
+ assertNotNull(okSummaryLineRecord);
+ assertEquals(2, okSummaryLineRecord.getValueInteger("recordCount"));
+
+ List okRecordInts = QueryAction.execute(ProcessTraceSummaryLineRecordInt.TABLE_NAME, new QQueryFilter(new QFilterCriteria("processTraceSummaryLineId", QCriteriaOperator.EQUALS, okSummaryLineRecord.getValueLong("id"))));
+ assertEquals(2, okRecordInts.size());
+ assertThat(okRecordInts).allMatch(r -> r.getValueInteger("recordId").equals(100) || r.getValueInteger("recordId").equals(101));
+
+ QRecord errorSummaryLineRecord = processTraceSummaryLineRecords.stream().filter(r -> r.getValueString("status").equals(ProcessTraceSummaryStatusEnum.ERROR.getPossibleValueId())).findFirst().get();
+ assertNotNull(errorSummaryLineRecord);
+ assertEquals(1, errorSummaryLineRecord.getValueInteger("recordCount"));
+
+ List errorRecordInts = QueryAction.execute(ProcessTraceSummaryLineRecordInt.TABLE_NAME, new QQueryFilter(new QFilterCriteria("processTraceSummaryLineId", QCriteriaOperator.EQUALS, errorSummaryLineRecord.getValueLong("id"))));
+ assertEquals(1, errorRecordInts.size());
+ assertEquals(102, errorRecordInts.get(0).getValueInteger("recordId"));
+ }
+
+}
\ No newline at end of file