Обычной галочки «Я согласен на обработку персональных данных» в форме иногда недостаточно. Может потребоваться технически фиксировать, что именно происходило с формой: когда пользователь поставил галочку, когда снял её, была ли форма после этого действительно отправлена и закончилась ли отправка успешно.
Такая задача возникла на одном из сайтов на MODX Revolution 2.8.x с SendIt 2.8.x.
Нужно было добавить аудит, практически не меняя существующую систему форм.
В результате получилось четыре типа событий:
consent_checked
consent_unchecked
form_submit_success
form_submit_error
При этом для каждого события сохранялись данные формы, URL страницы, идентификатор формы, IP, User-Agent и общий audit_session_id, позволяющий связать несколько действий одного пользователя в единую последовательность.
Разберём архитектуру и наиболее интересные проблемы, которые пришлось решить.
Задача
На сайте уже существовали рабочие формы SendIt.
Упрощённо одна из них выглядела примерно так:
<form
data-si-form="callbackForm"
>
<legend>Заказать обратный звонок</legend>
<input type="hidden" name="formId" value="6">
<input type="text" name="name">
<input type="tel" name="phone">
<label>
<input
type="checkbox"
name="privacy"
>
Я согласен на обработку персональных данных
</label>
<button type="submit">
Отправить
</button>
</form>
Code language: HTML, XML (xml)Требовалось получить примерно такую историю:
14:03:12 consent_checked
14:03:18 consent_unchecked
14:03:24 consent_checked
14:03:31 form_submit_success
Code language: CSS (css)Причём недостаточно было просто знать время события. Нужно было сохранить состояние формы именно в этот момент:
event
datetime
form_id
form_key
form_name
name
phone
email
address
page_url
ip
user_agent
audit_session_id
И здесь появляется первая важная проблема.
Почему нельзя просто читать форму непосредственно перед AJAX-запросом
Предположим, пользователь быстро выполняет три действия:
checked
unchecked
checked
Если каждое событие запускает асинхронный запрос, нет гарантии, что они закончатся на сервере в том же порядке. Более того, пользователь может изменить имя или телефон, пока предыдущий audit-запрос ещё ожидает отправки.
Поэтому важны две вещи:
- данные необходимо зафиксировать сразу в момент события;
- сами запросы желательно отправлять последовательно.
Получается схема:
Событие
↓
Снимок FormData
↓
Очередь
↓
SendIt
↓
Audit preset
↓
PHP logger
↓
JSONL
Добавляем специальные data-атрибуты
Не хотелось привязывать JavaScript к конкретному имени поля вроде:
form.querySelector('[name="phone"]')
Code language: JavaScript (javascript)Потому что на другой форме поле может называться:
phone
telephone
tel
contact
user_phone
Поэтому конфигурацию удобнее разместить непосредственно на форме:
<form
data-consent-audit
data-si-form="callbackForm"
data-audit-name-field="name"
data-audit-phone-field="phone"
data-audit-email-field="email"
data-audit-address-field="address"
>
Code language: HTML, XML (xml)Теперь один JS может обслуживать множество разных форм.
Для формы с универсальным полем «Телефон или Email» можно, например, использовать:
data-audit-contact-field="contact"
Code language: JavaScript (javascript)и определить тип значения программно:
function getAuditContacts(form, getValue) {
let phone = '';
let email = '';
if (form.dataset.auditPhoneField) {
phone = getValue(form.dataset.auditPhoneField);
}
if (form.dataset.auditEmailField) {
email = getValue(form.dataset.auditEmailField);
}
if (form.dataset.auditContactField) {
const contact = getValue(
form.dataset.auditContactField
).trim();
if (contact.includes('@')) {
email = contact;
phone = '';
} else {
phone = contact;
email = '';
}
}
return { phone, email };
}
Code language: JavaScript (javascript)Так аудит не зависит от структуры конкретной формы.
Делегирование событий вместо обработчика на каждой форме
<input
type="checkbox"
name="privacy"
data-consent-checkbox
>
Code language: HTML, XML (xml)Формы на сайте могут загружаться динамически — например, появляться в модальном окне после загрузки страницы.
Поэтому вместо:
document
.querySelectorAll('[data-consent-checkbox]')
.forEach(...);
Code language: JavaScript (javascript)удобнее использовать делегирование:
document.addEventListener('change', (event) => {
const checkbox = event.target;
if (
!checkbox.matches?.(
'input[type="checkbox"][data-consent-checkbox]'
)
) {
return;
}
const form = checkbox.form;
if (
!form ||
!form.hasAttribute('data-consent-audit')
) {
return;
}
sendAudit(
form,
checkbox.checked
? 'consent_checked'
: 'consent_unchecked'
);
});
Code language: JavaScript (javascript)Теперь обработчик автоматически работает и для форм, появившихся позже.
Снимаем состояние формы немедленно
Важный принцип: FormData создаётся не внутри будущего AJAX-запроса, а непосредственно при возникновении события.
Например:
function getFieldValue(form, fieldName) {
if (!fieldName) {
return '';
}
const formData = new FormData(form);
const value = formData.get(fieldName);
if (
value === null ||
value instanceof File
) {
return '';
}
return String(value);
}
Code language: JavaScript (javascript)Затем строим audit payload:
function buildAuditData(form, eventName) {
const params = new FormData();
params.set('audit_event', eventName);
params.set(
'form_id',
getFieldValue(form, 'formId')
);
params.set(
'form_key',
form.dataset.siForm ||
form.dataset.siPreset ||
''
);
const legend = form.querySelector('legend');
params.set(
'form_name',
legend
? legend.textContent.trim()
: ''
);
params.set(
'audit_name',
getFieldValue(
form,
form.dataset.auditNameField
)
);
const contacts = getAuditContacts(
form,
(name) => getFieldValue(form, name)
);
params.set(
'audit_phone',
contacts.phone
);
params.set(
'audit_email',
contacts.email
);
params.set(
'audit_address',
getFieldValue(
form,
form.dataset.auditAddressField
)
);
params.set(
'page_url',
window.location.href
);
return params;
}
Code language: JavaScript (javascript)То есть после:
const params = buildAuditData(form, eventName);
Code language: JavaScript (javascript)мы имеем уже зафиксированное состояние.
Если пользователь через 100 мс изменит телефон, предыдущая запись от этого не изменится.
Зачем понадобилась очередь Promise
Следующая проблема — порядок событий.
Если просто делать:
sendRequest(...);
sendRequest(...);
sendRequest(...);
то сетевые запросы могут закончиться в другом порядке.
Поэтому была добавлена простая очередь:
let auditQueue = Promise.resolve();
Code language: JavaScript (javascript)Отправка события:
function sendAudit(form, eventName) {
const params = buildAuditData(
form,
eventName
);
auditQueue = auditQueue
.catch(() => null)
.then(async () => {
const SendIt = await getSendIt();
return SendIt.Sending.sendRequest(
form,
'consentAudit',
params,
'send'
);
})
.catch((error) => {
console.error(
'[ConsentAudit]',
error
);
});
}
Code language: JavaScript (javascript)Здесь есть два важных момента.
Первый:
const params = buildAuditData(...);
Code language: JavaScript (javascript)– находится до очереди. Именно поэтому данные снимаются немедленно.
Второй:
.catch(() => null)
Code language: JavaScript (javascript)– перед следующим запросом не позволяет одной ошибке уничтожить всю цепочку.
Без этого получится:
request #1 → reject
↓
request #2 → уже не выполняется
request #3 → уже не выполняется
Code language: CSS (css)А нам нужно:
request #1 → ERROR
request #2 → SEND
request #3 → SEND
Code language: CSS (css)Аудит не должен ломать основную форму
Это один из главных архитектурных принципов такого решения.
Audit — дополнительная система.
Если по какой-то причине:
consentAudit preset не отвечает
PHP logger сломан
каталог логов недоступен
произошла JS-ошибка
пользователь всё равно должен иметь возможность отправить основную форму.
Поэтому ошибка audit-запроса только логируется:
.catch((error) => {
console.error(
'[ConsentAudit]',
error
);
});
Code language: JavaScript (javascript)но не пробрасывается в основной workflow формы.
Как определить успешную отправку формы
С consent_checked всё просто — это браузерное событие. С успешной отправкой ситуация сложнее.
Нельзя считать нажатиe <button type=”submit”> успешной отправкой. Даже само событие submit означает только попытку отправки.
После него сервер может вернуть:
ошибка валидации
ошибка CAPTCHA
ошибка PHP
ошибка hook
ошибка отправки письма
Поэтому form_submit_success логичнее фиксировать на сервере — в hook, который запускается только после успешной обработки основной формы. В списке хуков лучше поставить его последним.
Перед отправкой браузер добавляет в форму технические hidden-поля:
function setHiddenField(form, name, value) {
let field = form.querySelector(
`input[type="hidden"][name="${name}"]`
);
if (!field) {
field = document.createElement('input');
field.type = 'hidden';
field.name = name;
form.appendChild(field);
}
field.value = value || '';
}
Code language: JavaScript (javascript)Например:
audit_form_key
audit_form_name
audit_page_url
audit_name
audit_phone
audit_email
audit_address
audit_session_id
Обработчик:
document.addEventListener(
'submit',
(event) => {
const form = event.target;
if (
!form.matches?.(
'form[data-consent-audit]'
)
) {
return;
}
prepareSubmitAuditMetadata(form);
},
true
);
Code language: JavaScript (javascript)После успешной серверной обработки hook записывает:
form_submit_success
Таким образом success означает не «пользователь нажал кнопку», а фактический успешный результат SendIt. То что нам нужно!
Как определить неуспешную отправку
Для ошибки можно воспользоваться событием SendIt после получения результата.
Например:
document.addEventListener(
'si:send:after',
(event) => {
const detail = event.detail;
if (
!detail ||
!detail.result ||
detail.result.success !== false
) {
return;
}
const target = detail.target;
if (!target) {
return;
}
const form = target.tagName === 'FORM'
? target
: target.closest?.('form');
if (
!form ||
!form.matches(
'form[data-consent-audit]'
)
) {
return;
}
sendAudit(
form,
'form_submit_error'
);
}
);
Code language: JavaScript (javascript)Однако здесь скрывается довольно опасная проблема.
Защита от бесконечной рекурсии
Audit-события сами отправляются через SendIt.
То есть:
основная форма
↓
SendIt
↓
ошибка
↓
form_submit_error
↓
SendIt consentAudit
А что если сам consentAudit вернёт ошибку?
Если обработчик ловит вообще все ошибки SendIt, получится:
consentAudit ERROR
↓
form_submit_error
↓
consentAudit
↓
ERROR
↓
form_submit_error
↓
...
То есть потенциальный бесконечный цикл.
Поэтому audit-запросы необходимо отличать от обычных SendIt-запросов.
В SendIt для этого можно проверить preset:
const AUDIT_PRESET = 'consentAudit';
function isConsentAuditEvent(event) {
return (
event.detail?.headers?.['X-SIPRESET']
=== AUDIT_PRESET
);
}
Code language: JavaScript (javascript)И первой проверкой в обработчике сделать:
if (isConsentAuditEvent(event)) {
return;
}
Code language: JavaScript (javascript)Это небольшая деталь, но без неё дополнительный механизм логирования способен сам создать серьёзную проблему.
Не доверяем событию чужой формы
На странице может существовать несколько SendIt-форм.
Поэтому недостаточно найти:
event.detail.target
Code language: CSS (css)Желательно также удостовериться, что событие пришло именно от ожидаемого preset.
Например:
const requestPreset =
event.detail?.headers?.['X-SIPRESET']
|| '';
const formPreset =
form.dataset.siPreset
|| form.dataset.siForm
|| '';
if (
!formPreset ||
requestPreset !== formPreset
) {
return;
}
Code language: JavaScript (javascript)Это защищает аудит от ложных совпадений, если несколько SendIt-запросов происходят одновременно.
Один audit_session_id для всей последовательности
Для анализа отдельных событий один audit_session_id недостаточно.
Представим журнал:
12:04 consent_checked +79990001122
12:05 consent_checked +79990002233
12:06 form_submit_success +79990001122
Code language: CSS (css)Как точно понять, какие события относятся друг к другу?
Для этого каждому взаимодействию формы назначается audit_session_id.
В современном браузере его можно получить так:
function createAuditSessionId() {
if (crypto.randomUUID) {
return crypto.randomUUID();
}
return (
Date.now().toString(36)
+ '-'
+ Math.random().toString(36).slice(2)
);
}
Code language: JavaScript (javascript)После этого идентификатор нужно сохранить для формы и добавлять ко всем событиям:
params.set(
'audit_session_id',
getAuditSessionId(form)
);
Code language: JavaScript (javascript)В журнале появляется цепочка:
session: 8c52...
14:03:12 consent_checked
14:03:18 consent_unchecked
14:03:24 consent_checked
14:03:31 form_submit_success
Code language: CSS (css)Это гораздо информативнее отдельных независимых записей.
Почему для журнала был выбран JSONL
Следующий вопрос: куда всё сохранять? Можно создать отдельную таблицу MySQL. Но для сравнительно небольшого audit-журнала это не всегда необходимо.
Был выбран формат JSON Lines. В отличие от обычного JSON-файла:
[
{...},
{...},
{...}
]
в JSONL каждая строка является самостоятельным JSON-объектом:
{"event":"consent_checked","datetime":"2026-09-17T15:23:10+03:00"}
{"event":"consent_unchecked","datetime":"2026-09-17T15:23:13+03:00"}
{"event":"form_submit_success","datetime":"2026-09-17T15:23:18+03:00"}
Code language: JSON / JSON with Comments (json)Преимущество очевидно: чтобы добавить запись, не нужно читать и перезаписывать весь файл.
Достаточно:
file_put_contents(
$filename,
json_encode(
$record,
JSON_UNESCAPED_UNICODE |
JSON_UNESCAPED_SLASHES
) . PHP_EOL,
FILE_APPEND | LOCK_EX
);
Code language: PHP (php)LOCK_EX здесь важен, потому что одновременно могут прийти несколько запросов.
Разделяем журналы по месяцам
Хранить всё в одном бесконечно растущем файле тоже неудобно.
Поэтому журнал можно разбить:
2026-09-consent.log
2026-10-consent.log
2026-11-consent.log
Code language: CSS (css)Упрощённый logger:
class ConsentAuditLogger
{
protected modX $modx;
protected string $logDir;
protected DateTimeZone $timezone;
public function __construct(
modX $modx,
string $logDir
) {
$this->modx = $modx;
$this->logDir = rtrim(
$logDir,
'/\\'
);
$this->timezone =
new DateTimeZone('Europe/Moscow');
}
public function write(array $data): bool
{
$now = new DateTime(
'now',
$this->timezone
);
$file = sprintf(
'%s/%s-consent.log',
$this->logDir,
$now->format('Y-m')
);
$record = array_merge(
[
'datetime' =>
$now->format(DateTime::ATOM),
],
$data
);
$json = json_encode(
$record,
JSON_UNESCAPED_UNICODE |
JSON_UNESCAPED_SLASHES
);
if ($json === false) {
return false;
}
return file_put_contents(
$file,
$json . PHP_EOL,
FILE_APPEND | LOCK_EX
) !== false;
}
}
Code language: PHP (php)Путь к журналу лучше не прописывать непосредственно в snippet:
/core/cache/...
а вынести в системную настройку MODX:
consent_audit_log_pathТогда:
$logPath = $modx->getOption(
'consent_audit_log_path'
);
Code language: PHP (php)Что записывать на сервере
Упрощённая запись может выглядеть так:
$logger->write([
'event' => $event,
'form_id' => $formId,
'form_key' => $formKey,
'form_name' => $formName,
'name' => $name,
'phone' => $phone,
'email' => $email,
'address' => $address,
'url' => $pageUrl,
'ip' =>
$_SERVER['REMOTE_ADDR'] ?? '',
'user_agent' =>
$_SERVER['HTTP_USER_AGENT'] ?? '',
'audit_session_id' =>
$auditSessionId,
]);
Code language: PHP (php)При этом данные, пришедшие от браузера, всё равно нужно считать недоверенными.
Например:
page_url
form_name
form_id
audit_session_idклиент технически может изменить самостоятельно.
Audit-журнал фиксирует полученные события, но не превращает браузерные значения в криптографически достоверные данные.
Это важно понимать, если журнал используется не только для отладки, но и для более серьёзного аудита.
Неожиданная проблема: SendIt изменял значения полей
Во время тестирования обнаружилась ещё одна интересная проблема.
В audit-журнал нужно было записывать то значение, которое реально ввёл пользователь.
Но SendIt пропускает поля через собственную обработку и sanitization.
Для основной формы это нормальное и полезное поведение.
Для audit snapshot оно может быть нежелательным.
Например, если пользователь ввёл некорректный email, именно этот некорректный email представляет интерес для события:
<code>form_submit_error</code>Code language: HTML, XML (xml)Но после обработки SendIt значение могло оказаться изменённым.
Получалась ситуация:
Пользователь ввёл:
test@@example.com
Валидация:
ERROR
В audit:
уже преобразованное значениеCode language: CSS (css)А для аудита важно: Что пользователь ввёл в момент ошибки?
Решением стало сохранение исходных audit_* значений до того, как стандартная обработка SendIt их изменит.
Для этого можно использовать событие обработки значений SendIt и отдельно защищать технические поля:
audit_name
audit_phone
audit_email
audit_addressТо есть концептуально:
if (
strpos($fieldName, 'audit_') === 0
) {
// Для audit-полей сохраняем исходное
// значение, а не преобразованное.
}Code language: PHP (php)Это хороший пример того, почему audit-система не должна слепо использовать уже обработанные значения основной бизнес-логики.
Валидация отвечает на вопрос «можно ли принять эти данные».
Аудит отвечает на вопрос «что произошло».
Это разные задачи.
Почему form_submit_success лучше фиксировать на сервере
Можно было сделать:
if (result.success === true) {
sendAudit(
form,
'form_submit_success'
);
}Code language: JavaScript (javascript)Но серверный hook надёжнее.
Он позволяет связать success с фактической обработкой формы:
browser
↓
SendIt
↓
validation
↓
hooks
↓
основная операция
↓
ConsentAuditSuccessHook
↓
JSONLТогда запись form_submit_success создаётся внутри той же серверной логики, которая уже знает результат формы.
А form_submit_error, наоборот, удобно фиксировать после ответа SendIt, поскольку именно браузер получает success:false и сохраняет snapshot неудачной попытки.
В итоге система получается асимметричной:
consent_checked → JS → audit preset
consent_unchecked → JS → audit preset
form_submit_error → JS → audit preset
form_submit_success → server hookИ это в данном случае скорее преимущество, чем недостаток.
Отдельный Viewer вместо просмотра файлов вручную
JSONL удобно писать, но не очень удобно ежедневно читать.
Поэтому был сделан небольшой read-only Viewer.
Он:
- находит доступные файлы журналов;
- позволяет выбирать год и месяц;
- фильтрует по событию;
- ищет по форме;
- ищет по контакту;
- фильтрует по IP;
- фильтрует по URL;
- поддерживает общий поиск;
- сортирует колонки;
- показывает записи постранично.
При открытии файл читается построчно:
$handle = fopen(
$logFile,
'rb'
);
while (
($line = fgets($handle)) !== false
) {
$line = trim($line);
if ($line === '') {
continue;
}
$row = json_decode(
$line,
true
);
if (!is_array($row)) {
continue;
}
// Фильтрация...
$rows[] = $row;
}
fclose($handle);
Code language: PHP (php)Для небольших месячных файлов этого вполне достаточно. Фильтрация идет по месяцам, и годам, по умолчанию можно выводить все записи текущего года. Кроме того можно сделать простой экспорт выбранного периода в csv формат
Если объём вырастет до миллионов событий, логичнее будет перейти на базу данных или специализированное log storage.
Самое важное в Viewer — не интерфейс, а доступ
Журнал содержит:
имя
телефон
email
IP
URL
User-AgentПоэтому оставлять PHP-файл Viewer публично доступным нельзя.
При standalone Viewer можно загрузить MODX в manager-контексте:
require_once $siteRoot . '/config.core.php';
require_once MODX_CORE_PATH
. 'model/modx/modx.class.php';
$modx = new modX();
$modx->initialize('mgr');
Code language: PHP (php)После чего проверить manager session:
if (
!$modx->user ||
!$modx->user->hasSessionContext('mgr')
) {
http_response_code(403);
exit('Доступ запрещён');
}Code language: PHP (php)В нашем случае доступ дополнительно был ограничен sudo:
if (!(bool)$modx->user->get('sudo')) {
http_response_code(403);
exit('Доступ запрещён');
}Code language: PHP (php)Таким образом знание URL Viewer само по себе ничего не даёт.
HTML обязательно экранируем
Это особенно важно для audit viewer.
В журнал может попасть пользовательское значение вроде:
<script>alert(1)</script>Code language: HTML, XML (xml)Поэтому выводить:
echo $row['name'];Code language: PHP (php)нельзя.
Используем:
function h($value): string
{
return htmlspecialchars(
(string)$value,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
}
Code language: PHP (php)и затем:
<?= h($row['name'] ?? '') ?>
Code language: HTML, XML (xml)Audit Viewer должен рассматривать содержимое журнала как недоверенные данные.
Что получилось в итоге
Архитектура стала выглядеть следующим образом:
Что обязательно нужно протестировать перед запуском
После реализации я проверял систему не одним успешным submit, а последовательностью сценариев.
Сценарий 1. Только согласие
поставить checkboxОжидаем:
consent_checkedСценарий 2. Отмена согласия
checked
unchecked
Ожидаем:
consent_checked
consent_unchecked
с одним audit_session_id.
Сценарий 3. Быстрое переключение
checked
unchecked
checked
В журнале порядок должен сохраниться:
1 consent_checked
2 consent_unchecked
3 consent_checked
Сценарий 4. Успешная форма
checked
submit
Ожидаем:
consent_checked
form_submit_success
Сценарий 5. Ошибка валидации
Например, некорректный email:
checked
submit
Ожидаем:
consent_checked
form_submit_error
а в audit_email — именно значение, которое пользователь пытался отправить.
Сценарий 6. Ошибка самого audit preset
Искусственно ломаем consentAudit.
Основная форма всё равно должна продолжать работать.
И самое важное — не должно появиться бесконечного:
form_submit_error
form_submit_error
form_submit_error
...
Основные выводы
На первый взгляд задача выглядела просто: «записать, когда пользователь поставил галочку».
Но полноценный аудит формы требует учитывать асинхронность браузера, динамические формы, порядок событий, серверную валидацию, sanitization, ошибки самого audit-механизма и безопасность просмотра журнала.
Наиболее важными решениями оказались:
- Снимать состояние формы непосредственно в момент события, а не позже во время фактической отправки AJAX-запроса.
- Отправлять audit-события через последовательную Promise-очередь, чтобы сохранить их порядок.
- Не позволять сбою аудита ломать основную форму.
- Исключать собственный audit preset из обработки ошибок, иначе легко получить рекурсивный цикл.
- Фиксировать успешную отправку на сервере, а не считать событие
submitдоказательством успеха. - Сохранять исходные значения отдельно от обычной sanitization SendIt, если журнал должен показывать фактический пользовательский ввод.
- Связывать действия через
audit_session_id, чтобы видеть не набор отдельных записей, а историю одной попытки. - Хранить журнал отдельно и защищать Viewer авторизацией, поскольку лог содержит персональные данные.
В итоге из небольшого обработчика checkbox получилась полноценная событийная система аудита, которая при этом практически не вмешивается в существующую архитектуру SendIt и может постепенно подключаться к разным формам через несколько data-* атрибутов.

