diff --git a/src/otaclient_iot_logging_server/_common.py b/src/otaclient_iot_logging_server/_common.py index 438e418..31abd9b 100644 --- a/src/otaclient_iot_logging_server/_common.py +++ b/src/otaclient_iot_logging_server/_common.py @@ -15,12 +15,18 @@ from __future__ import annotations +from enum import Enum from queue import Queue from typing import Literal, TypedDict from typing_extensions import NotRequired, TypeAlias -LogsQueue: TypeAlias = "Queue[tuple[str, LogMessage]]" +LogsQueue: TypeAlias = "Queue[tuple[LogGroupType, str, LogMessage]]" + + +class LogGroupType(Enum): + LOG = 0 + METRICS = 1 class LogMessage(TypedDict): diff --git a/src/otaclient_iot_logging_server/_log_setting.py b/src/otaclient_iot_logging_server/_log_setting.py index f1c0ee5..ddaa36e 100644 --- a/src/otaclient_iot_logging_server/_log_setting.py +++ b/src/otaclient_iot_logging_server/_log_setting.py @@ -18,10 +18,9 @@ import contextlib import logging import time -from queue import Queue from otaclient_iot_logging_server import package_name as root_package_name -from otaclient_iot_logging_server._common import LogMessage +from otaclient_iot_logging_server._common import LogGroupType, LogMessage, LogsQueue from otaclient_iot_logging_server.configs import server_cfg @@ -30,7 +29,7 @@ class _LogTeeHandler(logging.Handler): def __init__( self, - queue: Queue[tuple[str, LogMessage]], + queue: LogsQueue, logstream_suffix: str, ) -> None: super().__init__() @@ -41,6 +40,7 @@ def emit(self, record: logging.LogRecord) -> None: with contextlib.suppress(Exception): self._queue.put_nowait( ( + LogGroupType.LOG, # always put into log group self._logstream_suffix, LogMessage( timestamp=int(time.time()) * 1000, # milliseconds @@ -51,7 +51,7 @@ def emit(self, record: logging.LogRecord) -> None: def config_logging( - queue: Queue[tuple[str, LogMessage]], + queue: LogsQueue, *, log_format: str, level: str, diff --git a/src/otaclient_iot_logging_server/aws_iot_logger.py b/src/otaclient_iot_logging_server/aws_iot_logger.py index bed7b74..f2954a5 100644 --- a/src/otaclient_iot_logging_server/aws_iot_logger.py +++ b/src/otaclient_iot_logging_server/aws_iot_logger.py @@ -26,7 +26,12 @@ import awscrt.exceptions from typing_extensions import NoReturn -from otaclient_iot_logging_server._common import LogEvent, LogMessage, LogsQueue +from otaclient_iot_logging_server._common import ( + LogEvent, + LogGroupType, + LogMessage, + LogsQueue, +) from otaclient_iot_logging_server._utils import retry from otaclient_iot_logging_server.boto3_session import get_session from otaclient_iot_logging_server.configs import server_cfg @@ -68,6 +73,7 @@ def __init__( self._session_config = session_config self._log_group_name = session_config.aws_cloudwatch_log_group + self._metrics_group_name = session_config.aws_cloudwatch_metrics_log_group self._interval = interval self._queue: LogsQueue = queue # NOTE: add this limitation to ensure all of the log_streams in a merge @@ -75,33 +81,37 @@ def __init__( self._max_logs_per_merge = min(max_logs_per_merge, self.MAX_LOGS_PER_PUT) @retry(max_retry=16, backoff_factor=2, backoff_max=32) - def _create_log_group(self): + def _create_log_groups(self): # TODO: (20240214) should we let the edge side iot_logging_server # create the log group? - log_group_name, client = self._log_group_name, self._client + log_group_names = [self._log_group_name, self._metrics_group_name] + client = self._client exc_types = self._exc_types - try: - client.create_log_group(logGroupName=log_group_name) - logger.info(f"{log_group_name=} has been created") - except exc_types.ResourceAlreadyExistsException as e: - logger.debug( - f"{log_group_name=} already existed, skip creating: {e.response}" - ) - except ValueError as e: - if e.__cause__ and isinstance(e.__cause__, awscrt.exceptions.AwsCrtError): - logger.error( - (f"failed to create mtls connection to remote: {e.__cause__}") + for log_group_name in log_group_names: + try: + client.create_log_group(logGroupName=log_group_name) + logger.info(f"{log_group_name=} has been created") + except exc_types.ResourceAlreadyExistsException as e: + logger.debug( + f"{log_group_name=} already existed, skip creating: {e.response}" ) - raise e.__cause__ from None - logger.error(f"failed to create {log_group_name=}: {e!r}") - raise - except Exception as e: - logger.error(f"failed to create {log_group_name=}: {e!r}") - raise + except ValueError as e: + if e.__cause__ and isinstance( + e.__cause__, awscrt.exceptions.AwsCrtError + ): + logger.error( + (f"failed to create mtls connection to remote: {e.__cause__}") + ) + raise e.__cause__ from None + logger.error(f"failed to create {log_group_name=}: {e!r}") + raise + except Exception as e: + logger.error(f"failed to create {log_group_name=}: {e!r}") + raise @retry(max_retry=16, backoff_factor=2, backoff_max=32) - def _create_log_stream(self, log_stream_name: str): - log_group_name, client = self._log_group_name, self._client + def _create_log_stream(self, log_group_name: str, log_stream_name: str): + client = self._client exc_types = self._exc_types try: client.create_log_stream( @@ -126,7 +136,9 @@ def _create_log_stream(self, log_stream_name: str): raise @retry(backoff_factor=2) - def put_log_events(self, log_stream_name: str, message_list: list[LogMessage]): + def put_log_events( + self, log_group_name: str, log_stream_name: str, message_list: list[LogMessage] + ): """ Ref: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/logs/client/put_log_events.html @@ -137,7 +149,7 @@ def put_log_events(self, log_stream_name: str, message_list: list[LogMessage]): See the documentation for more details. """ request = LogEvent( - logGroupName=self._log_group_name, + logGroupName=log_group_name, logStreamName=log_stream_name, logEvents=message_list, ) @@ -148,7 +160,7 @@ def put_log_events(self, log_stream_name: str, message_list: list[LogMessage]): # logger.debug(f"successfully uploaded: {response}") except exc_types.ResourceNotFoundException as e: logger.debug(f"{log_stream_name=} not found: {e!r}") - self._create_log_stream(log_stream_name) + self._create_log_stream(log_group_name, log_stream_name) raise except Exception as e: # NOTE: for unhandled exception, we just log it and ignore, @@ -156,36 +168,46 @@ def put_log_events(self, log_stream_name: str, message_list: list[LogMessage]): # in the future! logger.error( f"put_log_events failure: {e!r}\n" - f"log_group_name={self._log_group_name}, \n" + f"log_group_name={log_group_name}, \n" f"log_stream_name={log_stream_name}" ) def thread_main(self) -> NoReturn: """Main entry for running this iot_logger in a thread.""" # unconditionally create log_group and log_stream, do nothing if existed. - self._create_log_group() + self._create_log_groups() while True: # merge LogMessages into the same source, identified by - # log_stream_suffix. - message_dict: dict[str, list[LogMessage]] = defaultdict(list) + # log_group_type and log_stream_suffix. + message_dict: dict[ + (log_group_type, log_stream_suffix), list[LogMessage] + ] = defaultdict(list) _merge_count = 0 while _merge_count < self._max_logs_per_merge: _queue = self._queue try: - log_stream_suffix, message = _queue.get_nowait() + log_group_type, log_stream_suffix, message = _queue.get_nowait() _merge_count += 1 - - message_dict[log_stream_suffix].append(message) + message_dict[(log_group_type, log_stream_suffix)].append(message) except Empty: break - for log_stream_suffix, logs in message_dict.items(): + for (log_group_type, log_stream_suffix), logs in message_dict.items(): + # get the log_group_name based on the log_group_type + log_group_name = ( + self._metrics_group_name + if log_group_type == LogGroupType.METRICS + else self._log_group_name + ) + with contextlib.suppress(Exception): self.put_log_events( + log_group_name, get_log_stream_name( - self._session_config.thing_name, log_stream_suffix + self._session_config.thing_name, + log_stream_suffix, ), logs, ) diff --git a/src/otaclient_iot_logging_server/greengrass_config.py b/src/otaclient_iot_logging_server/greengrass_config.py index 11536c0..060d151 100644 --- a/src/otaclient_iot_logging_server/greengrass_config.py +++ b/src/otaclient_iot_logging_server/greengrass_config.py @@ -237,6 +237,14 @@ def aws_cloudwatch_log_group(self) -> str: f"{self.account_id}/{self.profile}-edge-otaclient" ) + @computed_field + @property + def aws_cloudwatch_metrics_log_group(self) -> str: + return ( + f"/aws/greengrass/edge/{self.region}/" + f"{self.account_id}/{self.profile}-edge-otaclient-metrics" + ) + @computed_field @property def aws_credential_refresh_url(self) -> str: diff --git a/src/otaclient_iot_logging_server/v1/servicer.py b/src/otaclient_iot_logging_server/v1/servicer.py new file mode 100644 index 0000000..345e292 --- /dev/null +++ b/src/otaclient_iot_logging_server/v1/servicer.py @@ -0,0 +1,95 @@ +# Copyright 2022 TIER IV, INC. All rights reserved. +# +# 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. +"""OTA Client IoT Logging Server v1 implementation.""" + +from __future__ import annotations + +import logging +import time +from queue import Full + +from otaclient_iot_logging_server._common import LogGroupType, LogMessage, LogsQueue +from otaclient_iot_logging_server.ecu_info import ECUInfo +from otaclient_iot_logging_server.v1.types import ( + ErrorCode, + LogType, + PutLogRequest, + PutLogResponse, +) + +logger = logging.getLogger(__name__) + + +class OTAClientIoTLoggingServerServicer: + """Handlers for otaclient IoT logging service.""" + + def __init__( + self, + *, + ecu_info: ECUInfo, + queue: LogsQueue, + ): + self._queue = queue + self._allowed_ecus = None + + if ecu_info: + self._allowed_ecus = ecu_info.ecu_id_set + logger.info( + f"setup allowed_ecu_id from ecu_info.yaml: {ecu_info.ecu_id_set}" + ) + else: + logger.warning( + "no ecu_info.yaml presented, logging upload filtering is DISABLED" + ) + + async def put_log(self, request: PutLogRequest) -> PutLogResponse: + """ + NOTE: use as log_stream_suffix, each ECU has its own + logging stream for uploading. + """ + + def convert_from_log_type_to_log_group_type(log_type): + """ + Convert input log type to log group type + """ + if log_type == LogType.METRICS: + return LogGroupType.METRICS + return LogGroupType.LOG + + _ecu_id = request.ecu_id + _log_group_type = convert_from_log_type_to_log_group_type(request.log_type) + _timestamp = ( + request.timestamp if request.timestamp else int(time.time()) * 1000 + ) # milliseconds + _message = request.message + # don't allow empty message request + if not _message: + return PutLogResponse(code=ErrorCode.NO_MESSAGE) + # don't allow unknowned ECUs + # if ECU id is unknown(not listed in ecu_info.yaml), drop this log. + if self._allowed_ecus and _ecu_id not in self._allowed_ecus: + return PutLogResponse(code=ErrorCode.NOT_ALLOWED_ECU_ID) + + _logging_msg = LogMessage( + timestamp=_timestamp, + message=_message, + ) + # logger.debug(f"receive log from {_ecu_id}: {_logging_msg}") + try: + self._queue.put_nowait((_log_group_type, _ecu_id, _logging_msg)) + except Full: + logger.debug(f"message dropped: {_logging_msg}") + return PutLogResponse(code=ErrorCode.SERVER_QUEUE_FULL) + + return PutLogResponse(code=ErrorCode.NO_FAILURE) diff --git a/tests/test__log_setting.py b/tests/test__log_setting.py index 67a4550..78cc9f4 100644 --- a/tests/test__log_setting.py +++ b/tests/test__log_setting.py @@ -19,7 +19,7 @@ from queue import Queue import otaclient_iot_logging_server._log_setting -from otaclient_iot_logging_server._common import LogsQueue +from otaclient_iot_logging_server._common import LogGroupType, LogsQueue from otaclient_iot_logging_server._log_setting import _LogTeeHandler # type: ignore MODULE = otaclient_iot_logging_server._log_setting.__name__ @@ -39,5 +39,6 @@ def test_server_logger(): logger.removeHandler(_handler) # ------ check result ------ # _log = _queue.get_nowait() - assert _log[0] == suffix - assert _log[1] + assert _log[0] == LogGroupType.LOG + assert _log[1] == suffix + assert _log[2] diff --git a/tests/test_aws_iot_logger.py b/tests/test_aws_iot_logger.py index 378efc5..283d1ca 100644 --- a/tests/test_aws_iot_logger.py +++ b/tests/test_aws_iot_logger.py @@ -28,7 +28,7 @@ from pytest_mock import MockerFixture import otaclient_iot_logging_server.aws_iot_logger -from otaclient_iot_logging_server._common import LogMessage, LogsQueue +from otaclient_iot_logging_server._common import LogGroupType, LogMessage, LogsQueue from otaclient_iot_logging_server.aws_iot_logger import ( AWSIoTLogger, get_log_stream_name, @@ -76,10 +76,13 @@ def generate_random_msgs( ) -> list[tuple[str, LogMessage]]: _res: list[tuple[str, LogMessage]] = [] for _ in range(msg_num): - _ecu, *_ = random.sample(ecus_list, 1) + _ecu_id, *_ = random.sample(ecus_list, 1) + _log_group_type = random.choice(list(LogGroupType)) _msg = os.urandom(msg_len).hex() _timestamp = int(time.time()) * 1000 # milliseconds - _res.append((_ecu, LogMessage(timestamp=_timestamp, message=_msg))) + _res.append( + (_log_group_type, _ecu_id, LogMessage(timestamp=_timestamp, message=_msg)) + ) return _res @@ -90,16 +93,27 @@ class TestAWSIoTLogger: class _TestFinished(Exception): pass - def _mocked_put_log_events(self, _ecu_id: str, _logs: list[LogMessage]): - self._test_result[_ecu_id] = _logs + def _mocked_put_log_events( + self, _log_group_name: str, _ecu_id: str, _logs: list[LogMessage] + ): + self._test_result[(_log_group_name, _ecu_id)] = _logs @pytest.fixture def prepare_test_data(self): + self._log_group_name = "some_log_group_name" # place holder + self._metrics_group_name = "some_metrics_group_name" # place holder + _msgs = generate_random_msgs(self.MSG_LEN, self.MSG_NUM) # prepare result for test_thread_main - _merged_msgs: dict[str, list[LogMessage]] = defaultdict(list) - for _ecu_id, _log_msg in _msgs: - _merged_msgs[_ecu_id].append(_log_msg) + _merged_msgs: dict[(LogGroupType, str), list[LogMessage]] = defaultdict(list) + for _log_group_type, _ecu_id, _log_msg in _msgs: + # get the log_group_name based on the log_group_type + _log_group_name = ( + self._metrics_group_name + if _log_group_type == LogGroupType.METRICS + else self._log_group_name + ) + _merged_msgs[(_log_group_name, _ecu_id)].append(_log_msg) self._merged_msgs = _merged_msgs # prepare the queue for test _queue: LogsQueue = Queue() @@ -123,7 +137,7 @@ def setup_test(self, prepare_test_data, mocker: MockerFixture): self._session_config = mocker.MagicMock() # place holder # for holding test results # mocked_send_messages will record each calls in this dict - self._test_result: dict[str, list[LogMessage]] = {} + self._test_result: dict[(LogGroupType, str), list[LogMessage]] = {} # mock get_log_stream_name to let it returns the log_stream_suffix # as it, make the test easier. # see get_log_stream_name signature for more details @@ -132,8 +146,8 @@ def setup_test(self, prepare_test_data, mocker: MockerFixture): def test_thread_main(self, mocker: MockerFixture): func_to_test = AWSIoTLogger.thread_main - self._create_log_group = mocked__create_log_group = mocker.MagicMock( - spec=AWSIoTLogger._create_log_group + self._create_log_groups = mocked__create_log_groups = mocker.MagicMock( + spec=AWSIoTLogger._create_log_groups ) # ------ execution ------ # @@ -142,6 +156,6 @@ def test_thread_main(self, mocker: MockerFixture): logger.info("execution finished") # ------ check result ------ # - mocked__create_log_group.assert_called_once() + mocked__create_log_groups.assert_called_once() # confirm the send_messages mock receives the expecting calls. assert self._merged_msgs == self._test_result diff --git a/tests/test_log_proxy_server.py b/tests/test_log_proxy_server.py index 9113cfa..40e357a 100644 --- a/tests/test_log_proxy_server.py +++ b/tests/test_log_proxy_server.py @@ -19,21 +19,23 @@ import os import random from dataclasses import dataclass -from http import HTTPStatus from pathlib import Path from queue import Queue -from urllib.parse import urljoin -import aiohttp -import aiohttp.client_exceptions +import grpc import pytest -from aiohttp import web from pytest_mock import MockerFixture import otaclient_iot_logging_server.log_proxy_server as log_server_module -from otaclient_iot_logging_server._common import LogsQueue +from otaclient_iot_logging_server._common import LogGroupType, LogsQueue from otaclient_iot_logging_server.ecu_info import parse_ecu_info -from otaclient_iot_logging_server.log_proxy_server import LoggingPostHandler +from otaclient_iot_logging_server.v1 import otaclient_iot_logging_server_v1_pb2 as pb2 +from otaclient_iot_logging_server.v1 import ( + otaclient_iot_logging_server_v1_pb2_grpc as v1_grpc, +) +from otaclient_iot_logging_server.v1 import types +from otaclient_iot_logging_server.v1.api_stub import OtaClientIoTLoggingServiceV1 +from otaclient_iot_logging_server.v1.servicer import OTAClientIoTLoggingServerServicer logger = logging.getLogger(__name__) @@ -58,6 +60,9 @@ class _ServerConfig: @dataclass class MessageEntry: ecu_id: str + log_type: types.LogType + timestamp: int + level: types.LogLevel message: str @@ -72,101 +77,107 @@ def generate_random_msgs( ) -> list[MessageEntry]: _res: list[MessageEntry] = [] for _ in range(msg_num): - _ecu, *_ = random.sample(ecus_list, 1) - _msg = os.urandom(msg_len).hex() - _res.append(MessageEntry(_ecu, _msg)) + _ecu_id, *_ = random.sample(ecus_list, 1) + _log_type = random.choice(list(types.LogType)) + _timestamp = random.randint(0, 2**64 - 1) + _level = random.choice(list(types.LogLevel)) + _message = os.urandom(msg_len).hex() + _res.append(MessageEntry(_ecu_id, _log_type, _timestamp, _level, _message)) return _res class TestLogProxyServer: - - SERVER_URL = ( - f"http://{_test_server_cfg.LISTEN_ADDRESS}:{_test_server_cfg.LISTEN_PORT}/" - ) + SERVER_URL = f"{_test_server_cfg.LISTEN_ADDRESS}:{_test_server_cfg.LISTEN_PORT}" TOTAL_MSG_NUM = 4096 @pytest.fixture(autouse=True) def mock_ecu_info(self, mocker: MockerFixture): - ecu_info = parse_ecu_info(TEST_DIR / "ecu_info.yaml") - mocker.patch(f"{MODULE}.ecu_info", ecu_info) + self._ecu_info = parse_ecu_info(TEST_DIR / "ecu_info.yaml") + mocker.patch(f"{MODULE}.ecu_info", self._ecu_info) @pytest.fixture(autouse=True) async def launch_server(self, mocker: MockerFixture, mock_ecu_info): - """ - See https://docs.aiohttp.org/en/stable/web_advanced.html#custom-resource-implementation - for more details. - """ mocker.patch(f"{MODULE}.server_cfg", _test_server_cfg) queue: LogsQueue = Queue() self._queue = queue - handler = LoggingPostHandler(queue) - app = web.Application() - # mute the aiohttp server logging - aiohttp_server_logger = logging.getLogger("aiohttp") - aiohttp_server_logger.setLevel("ERROR") - # add handler to the server - app.add_routes([web.post(r"/{ecu_id}", handler.logging_post_handler)]) - # star the server - runner = web.AppRunner(app) - try: - await runner.setup() - site = web.TCPSite( - runner, _test_server_cfg.LISTEN_ADDRESS, _test_server_cfg.LISTEN_PORT - ) - await site.start() - logger.info(f"test log_proxy_server started at {self.SERVER_URL}") - yield - finally: - await runner.cleanup() + servicer = OTAClientIoTLoggingServerServicer( + ecu_info=self._ecu_info, + queue=queue, + ) - @pytest.fixture(autouse=True) - async def client_sesion(self): - client_session = aiohttp.ClientSession( - raise_for_status=True, - timeout=aiohttp.ClientTimeout(total=0.2), # for speedup testing + server = grpc.aio.server() + v1_grpc.add_OtaClientIoTLoggingServiceServicer_to_server( + servicer=OtaClientIoTLoggingServiceV1(servicer), server=server ) + server.add_insecure_port(self.SERVER_URL) try: - yield client_session + await server.start() + yield finally: - await client_session.close() + await server.stop(None) @pytest.fixture(autouse=True) def prepare_test_data(self): self._msgs = generate_random_msgs(msg_num=self.TOTAL_MSG_NUM) - async def test_server(self, client_sesion: aiohttp.ClientSession): + async def test_server(self): # ------ execution ------ # logger.info(f"sending {self.TOTAL_MSG_NUM} msgs to {self.SERVER_URL}...") + + async def send_msg(item): + _req = pb2.PutLogRequest( + ecu_id=item.ecu_id, + log_type=item.log_type, + timestamp=item.timestamp, + level=item.level, + message=item.message, + ) + async with grpc.aio.insecure_channel(self.SERVER_URL) as channel: + stub = v1_grpc.OtaClientIoTLoggingServiceStub(channel) + _response = await stub.PutLog(_req) + assert _response.code == pb2.ErrorCode.NO_FAILURE + + def convert_from_log_type_to_log_group_type(log_type): + """ + Convert input log type to log group type + """ + if log_type == types.LogType.METRICS: + return LogGroupType.METRICS + return LogGroupType.LOG + for item in self._msgs: - _ecu_id, _msg = item.ecu_id, item.message - _log_upload_endpoint_url = urljoin(self.SERVER_URL, _ecu_id) - async with client_sesion.post(_log_upload_endpoint_url, data=_msg): - pass # raise_for_status is set on session + await send_msg(item) + # ------ check result ------ # # ensure the all msgs are sent in order to the queue by the server. logger.info("checking all the received messages...") for item in self._msgs: - _ecu_id, _log_msg = self._queue.get_nowait() + _log_group_type, _ecu_id, _log_msg = self._queue.get_nowait() assert _ecu_id == item.ecu_id + assert _log_group_type == convert_from_log_type_to_log_group_type( + item.log_type + ) assert _log_msg["message"] == item.message assert self._queue.empty() - @pytest.mark.parametrize( - "_ecu_id, _data", - [ - # unknowned ECU's request will be dropped - ("bad_ecu_id", "valid_msg"), - # empty message will be dropped - ("main", ""), - ], - ) - async def test_reject_invalid_request( - self, _ecu_id: str, _data: str, client_sesion: aiohttp.ClientSession - ): - with pytest.raises(aiohttp.client_exceptions.ClientResponseError) as exc_info: - _log_upload_endpoint_url = urljoin(self.SERVER_URL, _ecu_id) - async with client_sesion.post(_log_upload_endpoint_url, data=_data): - pass # raise_for_status is set on session - assert exc_info.value.status == HTTPStatus.BAD_REQUEST + async def test_reject_invalid_ecu_id(self): + _req = pb2.PutLogRequest( + ecu_id="bad_ecu_id", + message="valid_msg", + ) + async with grpc.aio.insecure_channel(self.SERVER_URL) as channel: + stub = v1_grpc.OtaClientIoTLoggingServiceStub(channel) + _response = await stub.PutLog(_req) + assert _response.code == pb2.ErrorCode.NOT_ALLOWED_ECU_ID + + async def test_reject_invalid_message(self): + _req = pb2.PutLogRequest( + ecu_id="main", + message="", + ) + async with grpc.aio.insecure_channel(self.SERVER_URL) as channel: + stub = v1_grpc.OtaClientIoTLoggingServiceStub(channel) + _response = await stub.PutLog(_req) + assert _response.code == pb2.ErrorCode.NO_MESSAGE