Skip to content

Commit

Permalink
feat: make default / rich messages nice
Browse files Browse the repository at this point in the history
Signed-off-by: GRBurst <[email protected]>
  • Loading branch information
GRBurst committed Jul 23, 2023
1 parent a8801fc commit b11e3fd
Show file tree
Hide file tree
Showing 2 changed files with 66 additions and 36 deletions.
90 changes: 63 additions & 27 deletions slack_logger/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
from abc import ABC, abstractmethod
from enum import Enum
from logging import LogRecord
from typing import Any, Dict, Optional, Sequence, Union
from typing import Any, Dict, List, Optional, Sequence, Union

from attrs import define
from slack_sdk.models.attachments import Attachment
from slack_sdk.models.blocks import Block, DividerBlock, HeaderBlock, SectionBlock
from slack_sdk.models.blocks import Block, ContextBlock, DividerBlock, HeaderBlock, SectionBlock
from slack_sdk.models.blocks.basic_components import MarkdownTextObject, PlainTextObject
from slack_sdk.webhook.async_client import AsyncWebhookClient
from slack_sdk.webhook.webhook_response import WebhookResponse
Expand All @@ -33,6 +33,7 @@
class Configuration:
service: Optional[str] = None
environment: Optional[str] = None
context: List[str] = []
emojis: Dict[int, str] = DEFAULT_EMOJIS
extra_fields: Dict[str, str] = {}

Expand All @@ -43,6 +44,51 @@ class MessageDesign(ABC):
def format_blocks(self, record: LogRecord) -> Sequence[Optional[Block]]:
pass

def get_env(self, config: Configuration, record: LogRecord) -> Optional[str]:
dynamic_env: Optional[str] = getattr(record, "environment", None)
if dynamic_env is not None:
return dynamic_env
if config.environment is not None:
return config.environment
return None

def get_service(self, config: Configuration, record: LogRecord) -> Optional[str]:
dynamic_service: Optional[str] = getattr(record, "service", None)
if dynamic_service is not None:
return dynamic_service
if config.service is not None:
return config.service
return None

def construct_header(
self, record: LogRecord, config: Configuration, icon: Optional[str], level: str
) -> HeaderBlock:
service: Optional[str] = self.get_service(config=config, record=record)
header_msg: str
if icon is not None:
header_msg = f"{icon} "
header_msg += level
if config.service is not None:
header_msg += f" | {service}"
else:
header_msg += f" | {record.name}"

return HeaderBlock(text=PlainTextObject(text=header_msg))

def construct_context(
self, config: Configuration, env: Optional[str], service: Optional[str]
) -> Optional[ContextBlock]:
if config.context != []:
context_msg = ", ".join(config.context)
return ContextBlock(elements=[MarkdownTextObject(text=context_msg)])
elif env is not None and service is not None:
return ContextBlock(elements=[MarkdownTextObject(text=f":point_right: {env}, {service}")])
elif env is None:
return ContextBlock(elements=[MarkdownTextObject(text=f":point_right: {env}")])
elif service is None:
return ContextBlock(elements=[MarkdownTextObject(text=f":point_right: {service}")])
return None

def format(self, record: LogRecord) -> str:
maybe_blocks: Sequence[Optional[Block]] = self.format_blocks(record=record)
blocks: Sequence[Block] = [b for b in maybe_blocks if b is not None]
Expand All @@ -66,11 +112,7 @@ def format_blocks(self, record: LogRecord) -> Sequence[Optional[Block]]:
message = record.getMessage()
icon = self.config.emojis.get(record.levelno)

header: HeaderBlock
if icon is not None:
header = HeaderBlock(text=PlainTextObject(text=f"{icon} {level} | {self.config.service}"))
else:
header = HeaderBlock(text=PlainTextObject(text=f"{level} | {self.config.service}"))
header: HeaderBlock = self.construct_header(record=record, config=self.config, icon=icon, level=level)

body = SectionBlock(text=MarkdownTextObject(text=message))
default_blocks: Sequence[Block] = [
Expand All @@ -90,31 +132,28 @@ def format_blocks(self, record: LogRecord) -> Sequence[Optional[Block]]:
message = record.getMessage()
icon = self.config.emojis.get(record.levelno)

dynamic_extra_fields = getattr(record, "extra_fields", {})
all_extra_fields = {**self.config.extra_fields, **dynamic_extra_fields}

header: HeaderBlock
if icon is not None:
header = HeaderBlock(text=PlainTextObject(text=f"{icon} {level} | {self.config.service}"))
else:
header = HeaderBlock(text=PlainTextObject(text=f"{level} | {self.config.service}"))
env: Optional[str] = self.get_env(config=self.config, record=record)
service: Optional[str] = self.get_service(config=self.config, record=record)

header: HeaderBlock = self.construct_header(record=record, config=self.config, icon=icon, level=level)
context: Optional[ContextBlock] = self.construct_context(config=self.config, env=env, service=service)
body = SectionBlock(text=MarkdownTextObject(text=message))

error: Optional[SectionBlock] = None
if record.exc_info is not None:
error = SectionBlock(text=MarkdownTextObject(text=f"```{record.exc_text}```"))

fields = SectionBlock(
fields=[
MarkdownTextObject(text=f"*Environment*\n{self.config.environment}"),
MarkdownTextObject(text=f"*Service*\n{self.config.service}"),
]
+ [MarkdownTextObject(text=f"*{key}*\n{value}") for key, value in all_extra_fields.items()]
)
dynamic_extra_fields = getattr(record, "extra_fields", {})
all_extra_fields = {**self.config.extra_fields, **dynamic_extra_fields}
fields: Optional[SectionBlock] = None
if all_extra_fields != {}:
fields = SectionBlock(
fields=[MarkdownTextObject(text=f"*{key}*\n{value}") for key, value in all_extra_fields.items()]
)

maybe_blocks: Sequence[Optional[Block]] = [
header,
context,
DividerBlock(),
body,
error,
Expand Down Expand Up @@ -147,6 +186,7 @@ def default(cls, config: Configuration) -> "SlackFormatter":
return cls(design=RichDesign(config), config=config)

def format(self, record: LogRecord) -> str:
super().format(record)
return self.design.format(record)


Expand Down Expand Up @@ -266,7 +306,6 @@ def dummy(cls) -> "SlackHandler":
return cls(client=DummyClient())

async def send_text_via_webhook(self, text: str) -> str:
log.debug(text)
response = await self.client.send(text=text)
assert response.status_code == 200
assert response.body == "ok"
Expand All @@ -281,13 +320,10 @@ async def send_blocks_via_webhook(self, blocks: str) -> str:

def emit(self, record: LogRecord) -> None:
try:
formatted_message = self.format(record)
if isinstance(self.formatter, SlackFormatter):
formatted_message = self.format(record)
log.debug(f"formatted_message: {formatted_message}")
asyncio.run(self.send_blocks_via_webhook(blocks=formatted_message))
else:
formatted_message = self.format(record)
log.debug(f"formatted_message: {formatted_message}")
asyncio.run(self.send_text_via_webhook(text=formatted_message))

except Exception:
Expand Down
12 changes: 3 additions & 9 deletions tests/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,6 @@

logger = logging.getLogger("LocalTest")

# Log to console as well
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(logging.Formatter("%(asctime)s %(name)s|%(levelname)s: %(message)s"))
logger.addHandler(stream_handler)

# Setup test handler
slack_handler = SlackHandler.dummy()
slack_handler.setLevel(logging.WARN)
Expand Down Expand Up @@ -169,8 +164,6 @@ def basic_blocks_filter(log_msg: str) -> None: # type: ignore


DEFAULT_ADDITIONAL_FIELDS: Dict[str, Dict[str, str]] = {
"env": {"text": "*Environment*\ntest", "type": "mrkdwn"},
"service": {"text": "*Service*\ntestrunner", "type": "mrkdwn"},
"foo": {"text": "*foo*\nbar", "type": "mrkdwn"},
"raven": {"text": "*raven*\ncaw", "type": "mrkdwn"},
}
Expand All @@ -186,6 +179,7 @@ def default_msg(
{
"blocks": [
{"text": {"text": f"{emoji} {level_name} | testrunner", "type": "plain_text"}, "type": "header"},
{"elements": [{"text": ":point_right: test, testrunner", "type": "mrkdwn"}], "type": "context"},
{"type": "divider"},
{
"text": {"text": log_msg, "type": "mrkdwn"},
Expand Down Expand Up @@ -289,7 +283,7 @@ def test_exception_logging(self, caplog) -> None: # type: ignore
with pytest.raises(ZeroDivisionError):
exception_logging(log_msg)

blocks_prefix = '{"blocks": [{"text": {"text": ":x: ERROR | testrunner", "type": "plain_text"}, "type": "header"}, {"type": "divider"}, {"text": {"text": "Error!", "type": "mrkdwn"}, "type": "section"}, {"text": {"text": "```Traceback (most recent call last):'
blocks_prefix = '{"blocks": [{"text": {"text": ":x: ERROR | testrunner", "type": "plain_text"}, "type": "header"}, {"elements": [{"text": ":point_right: test, testrunner", "type": "mrkdwn"}], "type": "context"}, {"type": "divider"}, {"text": {"text": "Error!", "type": "mrkdwn"}, "type": "section"}, {"text": {"text": "```Traceback (most recent call last):'

assert any(map(lambda m: blocks_prefix in m, caplog.messages))

Expand All @@ -301,7 +295,7 @@ def test_auto_exception_logging(self, caplog) -> None: # type: ignore
with pytest.raises(Exception):
auto_exception_logging(log_msg)

blocks_prefix = '{"blocks": [{"text": {"text": ":x: ERROR | testrunner", "type": "plain_text"}, "type": "header"}, {"type": "divider"}, {"text": {"text": "Exception!", "type": "mrkdwn"}, "type": "section"}, {"text": {"text": "```Traceback (most recent call last):'
blocks_prefix = '{"blocks": [{"text": {"text": ":x: ERROR | testrunner", "type": "plain_text"}, "type": "header"}, {"elements": [{"text": ":point_right: test, testrunner", "type": "mrkdwn"}], "type": "context"}, {"type": "divider"}, {"text": {"text": "Exception!", "type": "mrkdwn"}, "type": "section"}, {"text": {"text": "```Traceback (most recent call last):'

assert any(map(lambda m: blocks_prefix in m, caplog.messages))

Expand Down

0 comments on commit b11e3fd

Please sign in to comment.