Коли інтеграція падає, odoo.log рідко відповідає на головне питання: що саме пішло на зовнішній сервер і що він відповів. Потрібен окремий журнал HTTP-запитів — з тілом, заголовками, кодом відповіді і часом обробки. Розглянемо, як він влаштований у модулі kw_http_request_log (Odoo 18.0) і що з ним роблять kw_api та kw_api_connector.
Де перехоплюється запит
Вхідні й вихідні запити перехоплюються в різних місцях, бо перехопити їх можна тільки там, де ви контролюєте виклик.
Вихідні, де ми самі клієнт, обгортає ApiCredential.api_request() у kw_api_connector: лог створюється до виклику requests.request() і дописується після.
# kw_api_connector/models/credential.py
if self.sudo().is_log_enabled:
safe_headers = self.env['kw.http.request.log'].sudo()._mask_headers(
kw['headers'])
log = self.kw_http_request_log_source_id.sudo().create_log({
'name': full_url, 'method': method,
'headers': safe_headers, 'request_body': body})
# ...
response = requests.request(
method=method, url=full_url, timeout=60,
allow_redirects=False, **kw)
# ...
if self.sudo().is_log_enabled and log:
self.kw_http_request_log_source_id.update_log(
log, {'code': response.status_code,
'response_body': response.text,
'process_time': fields.Datetime.now()})
Обгортка спрацьовує тільки там, де її викликали — прямий requests.request() в обхід api_request() у журнал не потрапить.
Вхідні запити ми не контролюємо, тому логування зашите не в контролер, а в диспетчер. kw_api реєструє власний Dispatcher:
# kw_api/core/dispatcher.py
class KwApiDispatcher(Dispatcher):
routing_type = 'kw_api'
def pre_dispatch(self, rule, args):
super().pre_dispatch(rule, args)
routing = rule.endpoint.routing
self.ctx = KwApiContext(self.request, routing)
if not routing.get('kw_defer_log'):
self.ctx.logger.begin(self.ctx.build_log_vals())
# ...
def post_dispatch(self, response):
# ...
ctx.logger.finish(code=response.status_code, response_body=body)
return super().post_dispatch(response)
def handle_error(self, exc):
# ...
ctx.logger.finish(code=500, error=repr(exc))
return responder.error('internal_error', 'Internal Server Error', 500)
Важливо! Це не middleware на всі запити Odoo — тільки на роути, оголошені декоратором kw_api_route() замість http.route(). Зате handle_error пише лог навіть при необробленому винятку — рядок у журналі залишиться незалежно від результату.
Що зберігається і як рахується час
Крім очевидних name (URL), method, code, headers, request_body, response_body, params, error — час обробки рахується не таймером, а різницею двох timestamp-ів:
log_process_time = fields.Integer(
compute='_compute_log_process_time', store=True, readonly=True)
process_time = fields.Datetime(readonly=True)
@api.depends('process_time', 'create_date')
def _compute_log_process_time(self):
for obj in self:
if obj.process_time and obj.create_date:
obj.log_process_time = (
obj.process_time - obj.create_date).seconds
else:
obj.log_process_time = 0
create_date — момент створення логу, process_time виставляється явно при завершенні обробки. Різниця рахується в секундах, не в мілісекундах — для швидкого API це майже завжди 0 або 1. І якщо обробка обірвалась так, що finish() не викликали, process_time лишається порожнім, а log_process_time тихо показує 0 — виглядає як миттєва відповідь, хоча відповіді не було взагалі.
Чутливі дані
Головне питання будь-якого журналу — чи потрапляють у нього паролі й токени. Тут — ні, і це промасковано в коді, не тільки в описі:
_SENSITIVE_HEADERS = frozenset({
'authorization', 'x_api_key', 'cookie', 'set_cookie',
'x_auth_token', 'x_access_token', 'proxy_authorization',
'x_token', 'x_secret_key', 'api_key', 'apikey',
})
_SENSITIVE_JSON_KEYS = frozenset({
'password', 'token', 'secret', 'api_key', 'apikey', 'access_token',
'refresh_token', 'private_key', 'client_secret',
})
Порівняння ключів нормалізоване — ApiKey, api-key, API_KEY це один і той самий ключ. Маскування спрацьовує в create()/write() моделі логу, незалежно від того, хто викликав — диспетчер, конектор чи кастомний ендпоінт. Неправильно ховати токен у контролері чи конекторі, який формує виклик, — рано чи пізно з'явиться ще один виклик, що про це забуде. Правильно — маскувати в create()/write() самої моделі логу: працює для всіх джерел одразу.
Важливо! Маскування працює тільки для розпізнаних форматів — JSON, XML, form-urlencoded, URL з query-параметрами. У вільному тексті воно шукає лише URL-и, а не ключі. Підтверджує це власний тест модуля:
def test_mask_body_urlencoded_false_negative_narrowed(self):
log_model = self.env['kw.http.request.log']
body = 'error=Invalid password=abc123'
result = log_model._mask_body(body)
self.assertEqual(result, body)
Пробіл замість & — і рядок уже не query string за регуляркою, password=abc123 лишається як є. Тому для найчутливіших ендпоінтів kw_api не покладається лише на маскування по ключу — тіло взагалі не пише в лог:
@kw_api_route(route=['/kw_api/auth/token'], methods=['POST'],
kw_log_body=False, )
def kw_api_auth_token_post(self, ctx, **kw):
login = ctx.get_data_param_by_name('login', str)
password = ctx.get_data_param_by_name('password', str)
# ...
/kw_api/auth/token приймає логін і пароль у тілі POST. kw_log_body=False підміняє і request, і response body на '***' ще до маскування по ключах — метадані (метод, код, час) логуються як завжди.
Щоб журнал не з'їв базу
Компроміс «записати все» проти «не покласти базу» — на трьох рівнях. Перший — розмір тіла на джерело:
size_limit = log_source.body_text_log_limit * 1024
if len(vals.get(x).encode('utf-8')) < size_limit:
continue
vals[f'{x}_file'] = base64.b64encode(str.encode(vals[x]))
vals[x] = ''
За замовчанням ліміт — 100 Кб. Тіло понад ліміт іде у request_body_file/response_body_file — звичайний fields.Binary без явного attachment=False, а дефолт цього поля в ядрі Odoo — attachment=True: значення осідає в ir.attachment, не в рядку таблиці, і не підтягується в list/search за замовчуванням (prefetch=False). Симетрії тут нема: error через ту саму функцію теж маскується, але свого _file-поля не має — великий трейсбек піде в TEXT-колонку без обмеження розміру.
Другий рівень — ретеншн. Раз на день крон видаляє все, для чого настала delete_by_date:
@api.model
def cron_delete_outdated_logs(self):
"""Delete logs older than log_retention_period"""
self.env['kw.http.request.log'].sudo().search([
('delete_by_date', '<', fields.Date.today()),
]).unlink()
delete_by_date рахується один раз при створенні як create_date плюс log_retention_period днів джерела (за замовчанням 14, 0 — зберігати без обмеження). Ретеншн — per-source: критичній інтеграції можна тримати місяць, галасливому вебхуку — три дні.
Третій рівень — уже не про розмір, а про поведінку. Rate limit на джерело рахується прямим SELECT COUNT по тій самій таблиці логів:
def enforce_rate_limit(self):
source = self.logger.source
if not source or not self.logger.enabled:
return
# ...
count = log_model.search_count(
[('log_source_id', '=', source.id),
('create_date', '>=', since)] + extra_domain)
if count > limit:
raise KwApiError('rate_limit', _('Too Many Requests'), http_status=429)
self.logger.enabled — це active and is_log_enabled джерела. Вимкнули логування, щоб розвантажити базу під пікове навантаження, — і rate limit на цьому ж джерелі мовчки вимкнувся разом з ним, бо рахувати стало нізвідки. Ну ок, це не баг: журнал тут одразу і аудит, і лічильник, а вимкнути їх окремо один від одного вже не можна.
Як цим користуватись при розборі інциденту
Я завжди починаю розбір з фільтра по джерелу й вікну часу, а не з читання логу підряд.
Важливо! code — це fields.Char, не Integer. Домен [('code', '>', '400')] порівнює рядки, не числа, і поверне не те, що ви очікуєте. Для помилок надійніше not in:
env['kw.http.request.log'].search([
('log_source_id', '=', source.id),
('create_date', '>=', '2026-08-19 10:00:00'),
('code', 'not in', ['200', '201', '204', '']),
])
Це весь інцидент-репорт: джерело, вікно часу, коди, що не є успіхом. Тіла запитів і заголовки в результаті побачите, тільки якщо у вас є kw_http_request_log.group_log_viewer — цієї групи за замовчуванням немає навіть у base.group_system, лише в службового user_root.