Merge "Declarative meters support"

This commit is contained in:
Jenkins 2015-07-16 19:07:55 +00:00 committed by Gerrit Code Review
commit 9a67d602b3
8 changed files with 786 additions and 22 deletions

View File

@ -93,8 +93,9 @@ class NotificationBase(PluginBase):
super(NotificationBase, self).__init__()
# NOTE(gordc): this is filter rule used by oslo.messaging to dispatch
# messages to an endpoint.
self.filter_rule = oslo_messaging.NotificationFilter(
event_type='|'.join(self.event_types))
if self.event_types is not None:
self.filter_rule = oslo_messaging.NotificationFilter(
event_type='|'.join(self.event_types))
self.manager = manager
@abc.abstractproperty

View File

View File

@ -0,0 +1,204 @@
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import fnmatch
import os
import six
import yaml
import jsonpath_rw
from oslo_config import cfg
from oslo_log import log
import oslo_messaging
from ceilometer.agent import plugin_base
from ceilometer.i18n import _LE
from ceilometer import sample
OPTS = [
cfg.StrOpt('meter_definitions_cfg_file',
default="meters.yaml",
help="Configuration file for defining meter notifications."
),
]
cfg.CONF.register_opts(OPTS, group='meter')
LOG = log.getLogger(__name__)
class MeterDefinitionException(Exception):
def __init__(self, message, definition_cfg):
super(MeterDefinitionException, self).__init__(message)
self.definition_cfg = definition_cfg
def __str__(self):
return '%s %s: %s' % (self.__class__.__name__,
self.definition_cfg, self.message)
class MeterDefinition(object):
def __init__(self, definition_cfg):
self.cfg = definition_cfg
self._validate_type()
def match_type(self, meter_name):
try:
event_type = self.cfg['event_type']
except KeyError as err:
raise MeterDefinitionException(
_LE("Required field %s not specified") % err.args[0], self.cfg)
if isinstance(event_type, six.string_types):
event_type = [event_type]
for t in event_type:
if fnmatch.fnmatch(meter_name, t):
return True
def parse_fields(self, field, message):
fval = self.cfg.get(field)
if not fval:
return
if isinstance(fval, six.integer_types):
return fval
try:
parts = jsonpath_rw.parse(fval)
except Exception as e:
raise MeterDefinitionException(
_LE("Parse error in JSONPath specification "
"'%(jsonpath)s': %(err)s")
% dict(jsonpath=parts, err=e), self.cfg)
values = [match.value for match in parts.find(message)
if match.value is not None]
if values:
return values[0]
def _validate_type(self):
if self.cfg['type'] not in sample.TYPES:
raise MeterDefinitionException(
_LE("Invalid type %s specified") % self.cfg['type'], self.cfg)
def get_config_file():
config_file = cfg.CONF.meter.meter_definitions_cfg_file
if not os.path.exists(config_file):
config_file = cfg.CONF.find_file(config_file)
return config_file
def setup_meters_config():
"""Setup the meters definitions from yaml config file."""
config_file = get_config_file()
if config_file is not None:
LOG.debug(_LE("Meter Definitions configuration file: %s"), config_file)
with open(config_file) as cf:
config = cf.read()
try:
meters_config = yaml.safe_load(config)
except yaml.YAMLError as err:
if hasattr(err, 'problem_mark'):
mark = err.problem_mark
errmsg = (_LE("Invalid YAML syntax in Meter Definitions file "
"%(file)s at line: %(line)s, column: %(column)s.")
% dict(file=config_file,
line=mark.line + 1,
column=mark.column + 1))
else:
errmsg = (_LE("YAML error reading Meter Definitions file "
"%(file)s")
% dict(file=config_file))
LOG.error(errmsg)
raise
else:
LOG.debug(_LE("No Meter Definitions configuration file found!"
" Using default config."))
meters_config = []
LOG.info(_LE("Meter Definitions: %s"), meters_config)
return meters_config
def load_definitions(config_def):
return [MeterDefinition(event_def)
for event_def in reversed(config_def['metric'])]
class ProcessMeterNotifications(plugin_base.NotificationBase):
event_types = None
def __init__(self, manager):
super(ProcessMeterNotifications, self).__init__(manager)
self.definitions = load_definitions(setup_meters_config())
def get_targets(self, conf):
"""Return a sequence of oslo_messaging.Target
It is defining the exchange and topics to be connected for this plugin.
:param conf: Configuration.
#TODO(prad): This should be defined in the notification agent
"""
targets = []
exchanges = [
conf.nova_control_exchange,
conf.cinder_control_exchange,
conf.glance_control_exchange,
conf.neutron_control_exchange,
conf.heat_control_exchange,
conf.keystone_control_exchange,
conf.sahara_control_exchange,
conf.trove_control_exchange,
conf.zaqar_control_exchange,
conf.swift_control_exchange,
conf.magnetodb_control_exchange,
conf.ceilometer_control_exchange,
]
for exchange in exchanges:
targets.extend(oslo_messaging.Target(topic=topic,
exchange=exchange)
for topic in conf.notification_topics)
return targets
def process_notification(self, notification_body):
for d in self.definitions:
if d.match_type(notification_body['event_type']):
userid = self.get_user_id(d, notification_body)
projectid = self.get_project_id(d, notification_body)
resourceid = d.parse_fields('resource_id', notification_body)
yield sample.Sample.from_notification(
name=d.cfg['name'],
type=d.cfg['type'],
unit=d.cfg['unit'],
volume=d.parse_fields('volume', notification_body),
resource_id=resourceid,
user_id=userid,
project_id=projectid,
message=notification_body)
@staticmethod
def get_user_id(d, notification_body):
return (d.parse_fields('user_id', notification_body) or
notification_body.get('_context_user_id') or
notification_body.get('_context_user', None))
@staticmethod
def get_project_id(d, notification_body):
return (d.parse_fields('project_id', notification_body) or
notification_body.get('_context_tenant_id') or
notification_body.get('_context_tenant', None))

View File

@ -13,10 +13,12 @@
import datetime
import mock
from oslotest import base
from ceilometer.key_value_storage import notifications
from oslo_config import fixture as fixture_config
from ceilometer.meter import notifications
from ceilometer import sample
from ceilometer.tests import base as test
def fake_uuid(x):
@ -70,7 +72,15 @@ NOTIFICATION_TABLE_DELETE = {
}
class TestNotification(base.BaseTestCase):
class TestNotification(test.BaseTestCase):
def setUp(self):
super(TestNotification, self).setUp()
self.CONF = self.useFixture(fixture_config.Config()).conf
self.CONF.set_override(
'meter_definitions_cfg_file',
self.path_get('etc/ceilometer/meters.yaml'), group='meter')
self.handler = notifications.ProcessMeterNotifications(mock.Mock())
def _verify_common_counter(self, c, name, volume):
self.assertIsNotNone(c)
@ -82,19 +92,18 @@ class TestNotification(base.BaseTestCase):
self.assertEqual(u'magnetodb.winterfell.com', metadata.get('host'))
def test_create_table(self):
handler = notifications.Table(mock.Mock())
counters = list(handler.process_notification(
counters = list(self.handler.process_notification(
NOTIFICATION_TABLE_CREATE))
self.assertEqual(1, len(counters))
table = counters[0]
self.assertEqual(2, len(counters))
table = [item for item in counters
if item.name == "magnetodb.table.create"][0]
self._verify_common_counter(table, 'magnetodb.table.create', 1)
self.assertEqual(fake_uuid('u'), table.user_id)
self.assertEqual(fake_uuid('t'), table.project_id)
self.assertEqual(sample.TYPE_GAUGE, table.type)
def test_delete_table(self):
handler = notifications.Table(mock.Mock())
counters = list(handler.process_notification(
counters = list(self.handler.process_notification(
NOTIFICATION_TABLE_DELETE))
self.assertEqual(1, len(counters))
table = counters[0]
@ -104,11 +113,11 @@ class TestNotification(base.BaseTestCase):
self.assertEqual(sample.TYPE_GAUGE, table.type)
def test_index_count(self):
handler = notifications.Index(mock.Mock())
counters = list(handler.process_notification(
counters = list(self.handler.process_notification(
NOTIFICATION_TABLE_CREATE))
self.assertEqual(1, len(counters))
table = counters[0]
self.assertEqual(2, len(counters))
table = [item for item in counters
if item.name == "magnetodb.table.index.count"][0]
self._verify_common_counter(table, 'magnetodb.table.index.count', 2)
self.assertEqual(fake_uuid('u'), table.user_id)
self.assertEqual(fake_uuid('t'), table.project_id)

View File

View File

@ -0,0 +1,149 @@
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""Tests for ceilometer.meter.notifications
"""
import mock
import six
import yaml
from oslo_config import fixture as fixture_config
from ceilometer.meter import notifications
from ceilometer.openstack.common import fileutils
from ceilometer.tests import base as test
NOTIFICATION = {
'event_type': u'test.create',
'timestamp': u'2015-06-1909: 19: 35.786893',
'payload': {u'user_id': u'e1d870e51c7340cb9d555b15cbfcaec2',
u'resource_id': u'bea70e51c7340cb9d555b15cbfcaec23',
u'timestamp': u'2015-06-19T09: 19: 35.785330',
u'message_signature': u'fake_signature1',
u'resource_metadata': {u'foo': u'bar'},
u'source': u'30be1fc9a03c4e94ab05c403a8a377f2: openstack',
u'volume': 1.0,
u'project_id': u'30be1fc9a03c4e94ab05c403a8a377f2',
},
u'_context_tenant': u'30be1fc9a03c4e94ab05c403a8a377f2',
u'_context_request_id': u'req-da91b4bf-d2b5-43ae-8b66-c7752e72726d',
u'_context_user': u'e1d870e51c7340cb9d555b15cbfcaec2',
'message_id': u'939823de-c242-45a2-a399-083f4d6a8c3e',
'publisher_id': "foo123"
}
class TestMeterDefinition(test.BaseTestCase):
def test_config_definition(self):
cfg = dict(name="test",
event_type="test.create",
type="delta",
unit="B",
volume="payload.volume",
resource_id="payload.resource_id",
project_id="payload.project_id")
handler = notifications.MeterDefinition(cfg)
self.assertTrue(handler.match_type("test.create"))
self.assertEqual(1, handler.parse_fields("volume", NOTIFICATION))
self.assertEqual("bea70e51c7340cb9d555b15cbfcaec23",
handler.parse_fields("resource_id", NOTIFICATION))
self.assertEqual("30be1fc9a03c4e94ab05c403a8a377f2",
handler.parse_fields("project_id", NOTIFICATION))
def test_config_missing_fields(self):
cfg = dict(name="test", type="delta")
handler = notifications.MeterDefinition(cfg)
try:
handler.match_type("test.create")
except notifications.MeterDefinitionException as e:
self.assertEqual("Required field event_type not specified",
e.message)
def test_bad_type_cfg_definition(self):
cfg = dict(name="test", type="foo")
try:
notifications.MeterDefinition(cfg)
except notifications.MeterDefinitionException as e:
self.assertEqual("Invalid type foo specified", e.message)
class TestMeterProcessing(test.BaseTestCase):
def setUp(self):
super(TestMeterProcessing, self).setUp()
self.CONF = self.useFixture(fixture_config.Config()).conf
self.CONF.set_override(
'meter_definitions_cfg_file',
self.path_get('etc/ceilometer/meters.yaml'), group='meter')
self.handler = notifications.ProcessMeterNotifications(mock.Mock())
def __setup_meter_def_file(self, cfg):
if six.PY3:
cfg = cfg.encode('utf-8')
meter_cfg_file = fileutils.write_to_tempfile(content=cfg,
prefix="meters",
suffix="yaml")
self.CONF.set_override(
'meter_definitions_cfg_file',
meter_cfg_file, group='meter')
cfg = notifications.setup_meters_config()
return cfg
def test_multiple_meter(self):
cfg = yaml.dump(
{'metric': [dict(name="test1",
event_type="test.create",
type="delta",
unit="B",
volume="payload.volume",
resource_id="payload.resource_id",
project_id="payload.project_id"),
dict(name="test2",
event_type="test.create",
type="delta",
unit="B",
volume="payload.volume",
resource_id="payload.resource_id",
project_id="payload.project_id")]})
self.handler.definitions = notifications.load_definitions(
self.__setup_meter_def_file(cfg))
c = list(self.handler.process_notification(NOTIFICATION))
self.assertEqual(2, len(c))
def test_unmatched_meter(self):
cfg = yaml.dump(
{'metric': [dict(name="test1",
event_type="test.update",
type="delta",
unit="B",
volume="payload.volume",
resource_id="payload.resource_id",
project_id="payload.project_id")]})
self.handler.definitions = notifications.load_definitions(
self.__setup_meter_def_file(cfg))
c = list(self.handler.process_notification(NOTIFICATION))
self.assertEqual(0, len(c))
def test_regex_match_meter(self):
cfg = yaml.dump(
{'metric': [dict(name="test1",
event_type="test.*",
type="delta",
unit="B",
volume="payload.volume",
resource_id="payload.resource_id",
project_id="payload.project_id")]})
self.handler.definitions = notifications.load_definitions(
self.__setup_meter_def_file(cfg))
c = list(self.handler.process_notification(NOTIFICATION))
self.assertEqual(1, len(c))

View File

@ -14,11 +14,12 @@ import datetime
import mock
from oslo_config import cfg
from oslo_config import fixture as fixture_config
from oslo_log import log
from oslotest import base
from ceilometer.orchestration import notifications
from ceilometer.meter import notifications
from ceilometer import sample
from ceilometer.tests import base as test
NOW = datetime.datetime.isoformat(datetime.datetime.utcnow())
@ -51,8 +52,7 @@ def stack_notification_for(operation, use_trust=None):
trustor_id = None
return {
u'event_type': '%s.stack.%s.end' % (notifications.SERVICE,
operation),
u'event_type': 'orchestration.stack.%s.end' % operation,
u'_context_roles': [
u'Member',
],
@ -87,7 +87,15 @@ def stack_notification_for(operation, use_trust=None):
}
class TestNotification(base.BaseTestCase):
class TestNotification(test.BaseTestCase):
def setUp(self):
super(TestNotification, self).setUp()
self.CONF = self.useFixture(fixture_config.Config()).conf
self.CONF.set_override(
'meter_definitions_cfg_file',
self.path_get('etc/ceilometer/meters.yaml'), group='meter')
self.handler = notifications.ProcessMeterNotifications(mock.Mock())
def _verify_common_sample(self, s, name, volume):
self.assertIsNotNone(s)
@ -102,8 +110,8 @@ class TestNotification(base.BaseTestCase):
def _test_operation(self, operation, trust=None):
notif = stack_notification_for(operation, trust)
handler = notifications.StackCRUD(mock.Mock())
data = list(handler.process_notification(notif))
data = list(self.handler.process_notification(notif))
self.assertEqual(1, len(data))
if trust:
self.assertEqual(TRUSTOR_ID, data[0].user_id)

393
etc/ceilometer/meters.yaml Normal file
View File

@ -0,0 +1,393 @@
---
metric:
- name: "image.size"
event_type:
- "image.upload"
- "image.delete"
- "image.update"
type: "gauge"
unit: B
volume: payload.size
resource_id: payload.id
project_id: payload.owner
- name: "image.download"
event_type: "image.send"
type: "delta"
unit: "B"
volume: payload.bytes_sent
resource_id: payload.image_id
user_id: payload.receiver_user_id
project_id: payload.receiver_tenant_id
- name: "image.serve"
event_type: "image.send"
type: "delta"
unit: "B"
volume: payload.bytes_sent
resource_id: payload.image_id
project_id: payload.owner_id
- name: 'bandwidth'
event_type: 'l3.meter'
type: 'delta'
unit: 'B'
volume: payload.bytes
project_id: payload.tenant_id
resource_id: payload.label_id
- name: 'magnetodb.table.index.count'
type: 'gauge'
unit: 'index'
event_type: 'magnetodb.table.create.end'
volume: payload.index_count
resource_id: payload.table_uuid
user_id: _context_user
- name: 'memory'
event_type: 'compute.instance.*'
type: 'gauge'
unit: 'MB'
volume: payload.memory_mb
user_id: payload.user_id
project_id: payload.tenant_id
resource_id: payload.instance_id
- name: 'vcpus'
event_type: 'compute.instance.*'
type: 'gauge'
unit: 'vcpu'
volume: payload.vcpus
user_id: payload.user_id
project_id: payload.tenant_id
resource_id: payload.instance_id
- name: 'disk.root.size'
event_type: 'compute.instance.*'
type: 'gauge'
unit: 'GB'
volume: payload.root_gb
user_id: payload.user_id
project_id: payload.tenant_id
resource_id: payload.instance_id
- name: 'disk.ephemeral.size'
event_type: 'compute.instance.*'
type: 'gauge'
unit: 'GB'
volume: payload.ephemeral_gb
user_id: payload.user_id
project_id: payload.tenant_id
resource_id: payload.instance_id
- name: 'volume.size'
event_type:
- 'volume.exists'
- 'volume.create.*'
- 'volume.delete.*'
- 'volume.resize.*'
- 'volume.attach.*'
- 'volume.detach.*'
- 'volume.update.*'
type: 'gauge'
unit: 'GB'
volume: payload.size
user_id: payload.user_id
project_id: payload.tenant_id
resource_id: payload.volume_id
- name: 'snapshot.size'
event_type:
- 'snapshot.exists'
- 'snapshot.create.*'
- 'snapshot.delete.*'
type: 'gauge'
unit: 'GB'
volume: payload.volume_size
user_id: payload.user_id
project_id: payload.tenant_id
resource_id: payload.snapshot_id
# NOTE: non-metric meters are generally events/existence meters
# These are expected to be DEPRECATED in future releases
#
- name: 'stack.create'
event_type:
- 'orchestration.stack.create.end'
type: 'delta'
unit: 'stack'
volume: 1
user_id: _context_trustor_user_id
project_id: payload.tenant_id
resource_id: payload.stack_identity
- name: 'stack.update'
event_type:
- 'orchestration.stack.update.end'
type: 'delta'
unit: 'stack'
volume: 1
user_id: _context_trustor_user_id
project_id: payload.tenant_id
resource_id: payload.stack_identity
- name: 'stack.delete'
event_type:
- 'orchestration.stack.delete.end'
type: 'delta'
unit: 'stack'
volume: 1
user_id: _context_trustor_user_id
project_id: payload.tenant_id
resource_id: payload.stack_identity
- name: 'stack.resume'
event_type:
- 'orchestration.stack.resume.end'
type: 'delta'
unit: 'stack'
volume: 1
user_id: _context_trustor_user_id
project_id: payload.tenant_id
resource_id: payload.stack_identity
- name: 'stack.suspend'
event_type:
- 'orchestration.stack.suspend.end'
type: 'delta'
unit: 'stack'
volume: 1
user_id: _context_trustor_user_id
project_id: payload.tenant_id
resource_id: payload.stack_identity
- name: 'magnetodb.table.create'
type: 'gauge'
unit: 'table'
volume: 1
event_type: 'magnetodb.table.create.end'
resource_id: payload.table_uuid
user_id: _context_user
project_id: _context_tenant
- name: 'magnetodb.table.delete'
type: 'gauge'
unit: 'table'
volume: 1
event_type: 'magnetodb.table.delete.end'
resource_id: payload.table_uuid
user_id: _context_user
project_id: _context_tenant
- name: 'volume'
type: 'gauge'
unit: 'volume'
volume: 1
event_type:
- 'volume.exists'
- 'volume.create.*'
- 'volume.delete.*'
- 'volume.resize.*'
- 'volume.attach.*'
- 'volume.detach.*'
- 'volume.update.*'
resource_id: payload.volume_id
user_id: payload.user_id
project_id: payload.tenant_id
- name: 'volume.exists'
type: 'delta'
unit: 'volume'
volume: 1
event_type:
- 'volume.exists'
resource_id: payload.volume_id
user_id: payload.user_id
project_id: payload.tenant_id
- name: 'volume.create.start'
type: 'delta'
unit: 'volume'
volume: 1
event_type:
- 'volume.create.*'
resource_id: payload.volume_id
user_id: payload.user_id
project_id: payload.tenant_id
- name: 'volume.create.end'
type: 'delta'
unit: 'volume'
volume: 1
event_type:
- 'volume.create.*'
resource_id: payload.volume_id
user_id: payload.user_id
project_id: payload.tenant_id
- name: 'volume.delete.start'
type: 'delta'
unit: 'volume'
volume: 1
event_type:
- 'volume.delete.*'
resource_id: payload.volume_id
user_id: payload.user_id
project_id: payload.tenant_id
- name: 'volume.delete.end'
type: 'delta'
unit: 'volume'
volume: 1
event_type:
- 'volume.delete.*'
resource_id: payload.volume_id
user_id: payload.user_id
project_id: payload.tenant_id
- name: 'volume.update.end'
type: 'delta'
unit: 'volume'
volume: 1
event_type:
- 'volume.update.*'
resource_id: payload.volume_id
user_id: payload.user_id
project_id: payload.tenant_id
- name: 'volume.update.start'
type: 'delta'
unit: 'volume'
volume: 1
event_type:
- 'volume.update.*'
resource_id: payload.volume_id
user_id: payload.user_id
project_id: payload.tenant_id
- name: 'volume.resize.end'
type: 'delta'
unit: 'volume'
volume: 1
event_type:
- 'volume.resize.*'
resource_id: payload.volume_id
user_id: payload.user_id
project_id: payload.tenant_id
- name: 'volume.resize.start'
type: 'delta'
unit: 'volume'
volume: 1
event_type:
- 'volume.resize.*'
resource_id: payload.volume_id
user_id: payload.user_id
project_id: payload.tenant_id
- name: 'volume.attach.end'
type: 'delta'
unit: 'volume'
volume: 1
event_type:
- 'volume.attach.*'
resource_id: payload.volume_id
user_id: payload.user_id
project_id: payload.tenant_id
- name: 'volume.attach.start'
type: 'delta'
unit: 'volume'
volume: 1
event_type:
- 'volume.attach.*'
resource_id: payload.volume_id
user_id: payload.user_id
project_id: payload.tenant_id
- name: 'volume.detach.end'
type: 'delta'
unit: 'volume'
volume: 1
event_type:
- 'volume.detach.*'
resource_id: payload.volume_id
user_id: payload.user_id
project_id: payload.tenant_id
- name: 'volume.detach.start'
type: 'delta'
unit: 'volume'
volume: 1
event_type:
- 'volume.detach.*'
resource_id: payload.volume_id
user_id: payload.user_id
project_id: payload.tenant_id
- name: 'snapshot'
type: 'gauge'
unit: 'snapshot'
volume: 1
event_type:
- 'snapshot.exists'
- 'snapshot.create.*'
- 'snapshot.delete.*'
resource_id: payload.snapshot_id
user_id: payload.user_id
project_id: payload.tenant_id
- name: 'snapshot.exists'
type: 'delta'
unit: 'snapshot'
volume: 1
event_type:
- 'snapshot.exists'
resource_id: payload.snapshot_id
user_id: payload.user_id
project_id: payload.tenant_id
- name: 'snapshot.create.start'
type: 'delta'
unit: 'snapshot'
volume: 1
event_type:
- 'snapshot.create.*'
resource_id: payload.snapshot_id
user_id: payload.user_id
project_id: payload.tenant_id
- name: 'snapshot.create.end'
type: 'delta'
unit: 'snapshot'
volume: 1
event_type:
- 'snapshot.create.*'
resource_id: payload.snapshot_id
user_id: payload.user_id
project_id: payload.tenant_id
- name: 'snapshot.delete.start'
type: 'delta'
unit: 'snapshot'
volume: 1
event_type:
- 'snapshot.delete.*'
resource_id: payload.snapshot_id
user_id: payload.user_id
project_id: payload.tenant_id
- name: 'snapshot.delete.end'
type: 'delta'
unit: 'snapshot'
volume: 1
event_type:
- 'snapshot.delete.*'
resource_id: payload.snapshot_id
user_id: payload.user_id
project_id: payload.tenant_id