diff --git a/api/Application.cfm b/api/Application.cfm
index 3fef6e3..358f9c1 100644
--- a/api/Application.cfm
+++ b/api/Application.cfm
@@ -56,7 +56,9 @@
function apiAbort(payload) {
- writeOutput(serializeJSON(payload));
+ var json = serializeJSON(payload);
+ try { logPerf(len(json)); } catch (any e) {}
+ writeOutput(json);
abort;
}
@@ -83,6 +85,11 @@ if (structKeyExists(cgi, "SCRIPT_NAME")) {
}
request._api_path = lcase(request._api_scriptName);
+// Performance tracking - start timer
+request._perf_start = getTickCount();
+request._perf_queryCount = 0;
+request._perf_queryTimeMs = 0;
+
// Public allowlist
request._api_isPublic = false;
if (len(request._api_path)) {
diff --git a/api/auth/login.cfm b/api/auth/login.cfm
index c5c4926..82bf4a3 100644
--- a/api/auth/login.cfm
+++ b/api/auth/login.cfm
@@ -96,6 +96,7 @@ try {
}
request.UserID = q.ID;
+ try{logPerf(0);}catch(any e){}
writeOutput(serializeJSON({
"OK": true,
"ERROR": "",
diff --git a/api/auth/loginOTP.cfm b/api/auth/loginOTP.cfm
index 5151238..3175285 100644
--- a/api/auth/loginOTP.cfm
+++ b/api/auth/loginOTP.cfm
@@ -103,6 +103,7 @@ try {
}
}
+ try{logPerf(0);}catch(any e){}
writeOutput(serializeJSON({
"OK": true,
"UUID": userUUID,
diff --git a/api/auth/verifyOTP.cfm b/api/auth/verifyOTP.cfm
index c9154b8..47bed7c 100644
--- a/api/auth/verifyOTP.cfm
+++ b/api/auth/verifyOTP.cfm
@@ -105,6 +105,7 @@ try {
// For new signups, this will always be true
needsProfile = !len(trim(qUser.FirstName));
+ try{logPerf(0);}catch(any e){}
writeOutput(serializeJSON({
"OK": true,
"UserID": qUser.ID,
diff --git a/api/beacons/getBusinessFromBeacon.cfm b/api/beacons/getBusinessFromBeacon.cfm
index 25a8b3a..2a08d3a 100644
--- a/api/beacons/getBusinessFromBeacon.cfm
+++ b/api/beacons/getBusinessFromBeacon.cfm
@@ -161,4 +161,5 @@ beaconId = int(data.BeaconID);
}>
+try{logPerf(0);}catch(any e){}
#serializeJSON(response)#
diff --git a/api/beacons/list.cfm b/api/beacons/list.cfm
index 22a1623..6b85bad 100644
--- a/api/beacons/list.cfm
+++ b/api/beacons/list.cfm
@@ -87,4 +87,5 @@ if (structKeyExists(data, "onlyActive")) {
})>
+try{logPerf(0);}catch(any e){}
#serializeJSON({ OK=true, ERROR="", BusinessID=bizId, COUNT=arrayLen(beacons), BEACONS=beacons })#
diff --git a/api/businesses/get.cfm b/api/businesses/get.cfm
index 7bc4e08..e94215a 100644
--- a/api/businesses/get.cfm
+++ b/api/businesses/get.cfm
@@ -156,5 +156,6 @@ try {
response["ERROR"] = e.message;
}
+try{logPerf(0);}catch(any e){}
writeOutput(serializeJSON(response));
diff --git a/api/businesses/getChildren.cfm b/api/businesses/getChildren.cfm
index 4e11011..39d8541 100644
--- a/api/businesses/getChildren.cfm
+++ b/api/businesses/getChildren.cfm
@@ -5,11 +5,6 @@
-function apiAbort(payload) {
- writeOutput(serializeJSON(payload));
- abort;
-}
-
function readJsonBody() {
raw = toString(getHttpRequestData().content);
if (isNull(raw) || len(trim(raw)) EQ 0) return {};
diff --git a/api/businesses/list.cfm b/api/businesses/list.cfm
index 86d2ca3..401c62b 100644
--- a/api/businesses/list.cfm
+++ b/api/businesses/list.cfm
@@ -5,11 +5,6 @@
-function apiAbort(payload) {
- writeOutput(serializeJSON(payload));
- abort;
-}
-
// Read JSON body for user location
function readJsonBody() {
var raw = getHttpRequestData().content;
@@ -100,6 +95,7 @@ try {
}
// Provide BOTH keys to satisfy any Flutter casing expectation
+ try{logPerf(0);}catch(any e){}
writeOutput(serializeJSON({
"OK": true,
"ERROR": "",
diff --git a/api/businesses/saveBrandColor.cfm b/api/businesses/saveBrandColor.cfm
index 12b278c..cd744be 100644
--- a/api/businesses/saveBrandColor.cfm
+++ b/api/businesses/saveBrandColor.cfm
@@ -10,11 +10,6 @@
* POST JSON: { "BusinessID": 37, "BrandColor": "#1B4D3E" }
*/
-function apiAbort(payload) {
- writeOutput(serializeJSON(payload));
- abort;
-}
-
requestBody = toString(getHttpRequestData().content);
if (!len(requestBody)) {
apiAbort({ "OK": false, "ERROR": "no_body", "MESSAGE": "No request body provided" });
diff --git a/api/config/environment.cfm b/api/config/environment.cfm
index c9c325d..4de9b76 100644
--- a/api/config/environment.cfm
+++ b/api/config/environment.cfm
@@ -116,9 +116,152 @@ function apiError(message, detail = "", statusCode = 500) {
return response;
}
+// ============================================
+// PERFORMANCE PROFILING
+// ============================================
+application.perfEnabled = true;
+
+if (!structKeyExists(application, "perfBuffer")) {
+ application.perfBuffer = [];
+}
+
+/**
+ * Drop-in replacement for queryExecute() that tracks query count and time.
+ * Opt-in: use in endpoints where you want accurate DB time breakdown.
+ */
+function queryTimed(required string sql, any params = [], struct options = {}) {
+ if (!structKeyExists(request, "_perf_queryCount")) {
+ request._perf_queryCount = 0;
+ request._perf_queryTimeMs = 0;
+ }
+
+ var t = getTickCount();
+ var result = queryExecute(arguments.sql, arguments.params, arguments.options);
+ var elapsed = getTickCount() - t;
+
+ request._perf_queryCount++;
+ request._perf_queryTimeMs += elapsed;
+
+ return result;
+}
+
+/**
+ * Flush the in-memory perf buffer to the ApiPerfLogs MySQL table.
+ * Thread-safe: duplicates buffer under lock, then inserts outside lock.
+ */
+function flushPerfBuffer() {
+ // Auto-create table if it doesn't exist
+ if (!structKeyExists(application, "_perfTableChecked")) {
+ try {
+ queryExecute("
+ CREATE TABLE IF NOT EXISTS ApiPerfLogs (
+ ID INT AUTO_INCREMENT PRIMARY KEY,
+ Endpoint VARCHAR(255) NOT NULL,
+ TotalMs INT NOT NULL DEFAULT 0,
+ DbMs INT NOT NULL DEFAULT 0,
+ AppMs INT NOT NULL DEFAULT 0,
+ QueryCount INT NOT NULL DEFAULT 0,
+ ResponseBytes INT NOT NULL DEFAULT 0,
+ BusinessID INT NOT NULL DEFAULT 0,
+ UserID INT NOT NULL DEFAULT 0,
+ LoggedAt DATETIME NOT NULL,
+ INDEX idx_loggedat (LoggedAt),
+ INDEX idx_endpoint (Endpoint)
+ ) ENGINE=InnoDB
+ ", {}, { datasource: "payfrit" });
+ application._perfTableChecked = true;
+ } catch (any e) {}
+ }
+
+ var batch = [];
+
+ lock name="payfrit_perfBuffer" timeout="2" type="exclusive" {
+ if (structKeyExists(application, "perfBuffer")) {
+ batch = duplicate(application.perfBuffer);
+ application.perfBuffer = [];
+ }
+ }
+
+ if (arrayLen(batch) == 0) return;
+
+ try {
+
+ var sql = "INSERT INTO ApiPerfLogs (Endpoint, TotalMs, DbMs, AppMs, QueryCount, ResponseBytes, BusinessID, UserID, LoggedAt) VALUES ";
+ var rows = [];
+
+ for (var m in batch) {
+ arrayAppend(rows,
+ "('" & replace(m.endpoint, "'", "''", "all") & "',"
+ & val(m.totalMs) & "," & val(m.dbMs) & "," & val(m.appMs) & ","
+ & val(m.queryCount) & "," & val(m.responseBytes) & ","
+ & val(m.businessId) & "," & val(m.userId) & ",'"
+ & m.loggedAt & "')"
+ );
+ }
+
+ sql &= arrayToList(rows, ",");
+ queryExecute(sql, {}, { datasource: "payfrit" });
+
+ // Cleanup old data (1% chance per flush)
+ if (randRange(1, 100) == 1) {
+ queryExecute(
+ "DELETE FROM ApiPerfLogs WHERE LoggedAt < DATE_SUB(NOW(), INTERVAL 30 DAY)",
+ {}, { datasource: "payfrit" }
+ );
+ }
+ } catch (any e) {
+ // Silent fail - never break the app for profiling
+ }
+}
+
+/**
+ * Log performance metrics for the current request.
+ * Called automatically from apiAbort().
+ */
+function logPerf(numeric responseBytes = 0) {
+ if (!structKeyExists(application, "perfEnabled") || !application.perfEnabled) return;
+ if (!structKeyExists(request, "_perf_start")) return;
+
+ // Safety valve: don't let buffer grow unbounded if flush is failing
+ if (structKeyExists(application, "perfBuffer") && arrayLen(application.perfBuffer) > 1000) return;
+
+ var totalMs = getTickCount() - request._perf_start;
+ var dbMs = structKeyExists(request, "_perf_queryTimeMs") ? request._perf_queryTimeMs : 0;
+
+ var metric = {
+ endpoint: structKeyExists(request, "_api_path") ? request._api_path : "unknown",
+ totalMs: totalMs,
+ dbMs: dbMs,
+ appMs: totalMs - dbMs,
+ queryCount: structKeyExists(request, "_perf_queryCount") ? request._perf_queryCount : 0,
+ responseBytes: arguments.responseBytes,
+ businessId: structKeyExists(request, "BusinessID") ? val(request.BusinessID) : 0,
+ userId: structKeyExists(request, "UserID") ? val(request.UserID) : 0,
+ loggedAt: dateTimeFormat(now(), "yyyy-mm-dd HH:nn:ss")
+ };
+
+ var shouldFlush = false;
+
+ lock name="payfrit_perfBuffer" timeout="1" type="exclusive" {
+ arrayAppend(application.perfBuffer, metric);
+ if (arrayLen(application.perfBuffer) >= 100) {
+ shouldFlush = true;
+ }
+ }
+
+ if (shouldFlush) {
+ thread name="perfFlush_#createUUID()#" {
+ flushPerfBuffer();
+ }
+ }
+}
+
// Store in application scope
application.isDevEnvironment = isDevEnvironment;
application.isDev = isDev;
application.logDebug = logDebug;
application.apiError = apiError;
+application.queryTimed = queryTimed;
+application.logPerf = logPerf;
+application.flushPerfBuffer = flushPerfBuffer;
diff --git a/api/menu/getForBuilder.cfm b/api/menu/getForBuilder.cfm
index 83e84ec..121fe8c 100644
--- a/api/menu/getForBuilder.cfm
+++ b/api/menu/getForBuilder.cfm
@@ -474,5 +474,6 @@ try {
response["DETAIL"] = e.detail ?: "";
}
+try{logPerf(0);}catch(any e){}
writeOutput(serializeJSON(response));
diff --git a/api/menu/menus.cfm b/api/menu/menus.cfm
index 856cd67..9191e6c 100644
--- a/api/menu/menus.cfm
+++ b/api/menu/menus.cfm
@@ -16,11 +16,6 @@
response = { "OK": false };
-function apiAbort(payload) {
- writeOutput(serializeJSON(payload));
- abort;
-}
-
try {
requestBody = toString(getHttpRequestData().content);
requestData = {};
@@ -250,5 +245,6 @@ try {
}
+try{logPerf(0);}catch(any e){}
writeOutput(serializeJSON(response));
diff --git a/api/menu/uploadHeader.cfm b/api/menu/uploadHeader.cfm
index e673f7c..d3aad08 100644
--- a/api/menu/uploadHeader.cfm
+++ b/api/menu/uploadHeader.cfm
@@ -7,11 +7,6 @@
-function apiAbort(payload) {
- writeOutput(serializeJSON(payload));
- abort;
-}
-
// Get BusinessID from form, request scope, or header
bizId = 0;
if (structKeyExists(form, "BusinessID") && isNumeric(form.BusinessID) && form.BusinessID GT 0) {
diff --git a/api/menu/uploadItemPhoto.cfm b/api/menu/uploadItemPhoto.cfm
index fece49b..e17c340 100644
--- a/api/menu/uploadItemPhoto.cfm
+++ b/api/menu/uploadItemPhoto.cfm
@@ -6,11 +6,6 @@
-function apiAbort(payload) {
- writeOutput(serializeJSON(payload));
- abort;
-}
-
// Get ItemID from form
itemId = 0;
if (structKeyExists(form, "ItemID") && isNumeric(form.ItemID) && form.ItemID GT 0) {
diff --git a/api/orders/history.cfm b/api/orders/history.cfm
index 1cc10bc..f48b491 100644
--- a/api/orders/history.cfm
+++ b/api/orders/history.cfm
@@ -156,6 +156,7 @@ try {
});
}
+ try{logPerf(0);}catch(any e){}
writeOutput(serializeJSON({
"OK": true,
"ORDERS": orders,
diff --git a/api/portal/stats.cfm b/api/portal/stats.cfm
index 43c7f30..479bf66 100644
--- a/api/portal/stats.cfm
+++ b/api/portal/stats.cfm
@@ -94,5 +94,6 @@ try {
response["ERROR"] = e.message;
}
+try{logPerf(0);}catch(any e){}
writeOutput(serializeJSON(response));
diff --git a/api/portal/team.cfm b/api/portal/team.cfm
index 5b057f2..64e55e1 100644
--- a/api/portal/team.cfm
+++ b/api/portal/team.cfm
@@ -81,6 +81,7 @@ try {
});
}
+ try{logPerf(0);}catch(any e){}
writeOutput(serializeJSON({
"OK": true,
"TEAM": team,
diff --git a/api/servicepoints/list.cfm b/api/servicepoints/list.cfm
index eb13235..43fc89e 100644
--- a/api/servicepoints/list.cfm
+++ b/api/servicepoints/list.cfm
@@ -5,11 +5,6 @@
-function apiAbort(payload) {
- writeOutput(serializeJSON(payload));
- abort;
-}
-
// Read JSON body once
data = {};
try {