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 {