Skip to content

Commit b2b6816

Browse files
SNOW-2044497 Add a snowflake log formatter (#56)
1 parent fd19329 commit b2b6816

File tree

3 files changed

+172
-17
lines changed

3 files changed

+172
-17
lines changed

src/snowflake/telemetry/_internal/exporter/otlp/proto/logs/__init__.py

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
encode_logs,
2626
)
2727
from snowflake.telemetry._internal.opentelemetry.proto.logs.v1.logs_marshaler import LogsData
28+
from snowflake.telemetry.logs import SnowflakeLogFormatter
2829
from opentelemetry.sdk.resources import Resource
2930
from opentelemetry.sdk._logs import export
3031
from opentelemetry.sdk import _logs
@@ -96,20 +97,6 @@ def __init__(
9697
)
9798
super().__init__(logger_provider=provider)
9899

99-
@staticmethod
100-
def _get_snowflake_log_level_name(py_level_name):
101-
"""
102-
Adapted from the getSnowflakeLogLevelName method in XP's logger.py
103-
"""
104-
level = py_level_name.upper()
105-
if level == "WARNING":
106-
return "WARN"
107-
if level == "CRITICAL":
108-
return "FATAL"
109-
if level == "NOTSET":
110-
return "TRACE"
111-
return level
112-
113100
@staticmethod
114101
def _get_attributes(record: logging.LogRecord) -> types.Attributes:
115102
attributes = _logs.LoggingHandler._get_attributes(record) # pylint: disable=protected-access
@@ -124,9 +111,7 @@ def _get_attributes(record: logging.LogRecord) -> types.Attributes:
124111

125112
def _translate(self, record: logging.LogRecord) -> _logs.LogRecord:
126113
otel_record = super()._translate(record)
127-
otel_record.severity_text = SnowflakeLoggingHandler._get_snowflake_log_level_name(
128-
record.levelname
129-
)
114+
otel_record.severity_text = SnowflakeLogFormatter.get_severity_text(record.levelname)
130115
return otel_record
131116

132117

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#
2+
# Copyright (c) 2012-2025 Snowflake Computing Inc. All rights reserved.
3+
#
4+
5+
import json
6+
import logging
7+
import traceback
8+
9+
# skip natural LogRecord attributes
10+
# http://docs.python.org/library/logging.html#logrecord-attributes
11+
_RESERVED_ATTRS = frozenset(
12+
(
13+
"asctime",
14+
"args",
15+
"created",
16+
"exc_info",
17+
"exc_text",
18+
"filename",
19+
"funcName",
20+
"message",
21+
"levelname",
22+
"levelno",
23+
"lineno",
24+
"module",
25+
"msecs",
26+
"msg",
27+
"name",
28+
"pathname",
29+
"process",
30+
"processName",
31+
"relativeCreated",
32+
"stack_info",
33+
"thread",
34+
"threadName",
35+
"taskName",
36+
# Params that Snowflake will populate and don't want users of this API to overwrite.
37+
"code.lineno",
38+
"code.function",
39+
"code.filepath",
40+
"exception.type",
41+
"exception.message",
42+
"exception.stacktrace",
43+
)
44+
)
45+
46+
_PY_LOG_LEVELS = frozenset(
47+
(
48+
"CRITICAL",
49+
"ERROR",
50+
"WARNING",
51+
"INFO",
52+
"DEBUG"
53+
)
54+
)
55+
56+
57+
class SnowflakeLogFormatter(logging.Formatter):
58+
"""
59+
A formatter to emit logs in JSON format so that they can be parsed by Snowflake.
60+
"""
61+
62+
@staticmethod
63+
def get_severity_text(py_level_name):
64+
"""
65+
Maps from Python logging level to OpenTelemetry's Severity Text.
66+
"""
67+
level = py_level_name.upper()
68+
if level not in _PY_LOG_LEVELS:
69+
return "TRACE"
70+
if level == "WARNING":
71+
return "WARN"
72+
if level == "CRITICAL":
73+
return "FATAL"
74+
return level
75+
76+
def format(self, record: logging.LogRecord) -> str:
77+
log_items = {
78+
"body": record.getMessage(),
79+
"severity_text": self.get_severity_text(record.levelname),
80+
"code.lineno": record.lineno,
81+
"code.function": record.funcName,
82+
"code.filepath": record.pathname
83+
}
84+
85+
if record.exc_info is not None:
86+
exctype, value, tb = record.exc_info
87+
if exctype is not None:
88+
log_items["exception.type"] = exctype.__name__
89+
if value is not None and value.args:
90+
log_items["exception.message"] = value.args[0]
91+
if tb is not None:
92+
log_items["exception.stacktrace"] = "".join(traceback.format_exception(*record.exc_info))
93+
# Remove traceback to avoid emitting logs in incorrect format
94+
record.exc_info = None
95+
96+
for attr, value in record.__dict__.items():
97+
if attr in _RESERVED_ATTRS:
98+
continue
99+
log_items[attr] = value
100+
101+
return json.dumps(log_items)
102+
103+
104+
__all__ = [
105+
"SnowflakeLogFormatter",
106+
]

tests/test_snowflake_log_formatter.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import json
2+
import logging
3+
import unittest
4+
from io import StringIO
5+
6+
from snowflake.telemetry.logs import SnowflakeLogFormatter
7+
8+
9+
class TestSnowflakeLogFormatter(unittest.TestCase):
10+
def setUp(self):
11+
super().setUp()
12+
self.stream = StringIO()
13+
self.root_logger = logging.getLogger("test")
14+
self.root_hdlr = logging.StreamHandler(self.stream)
15+
formatter = SnowflakeLogFormatter()
16+
self.root_hdlr.setFormatter(formatter)
17+
self.root_logger.addHandler(self.root_hdlr)
18+
19+
def test_normal_log(self):
20+
""" Does the logger produce the correct output? """
21+
self.root_logger.warning('foo', extra={"test": 123, "code.lineno": 35})
22+
expected_log_message = {
23+
"body": "foo",
24+
"severity_text": "WARN",
25+
"code.lineno": 21,
26+
"code.function": "test_normal_log",
27+
"test": 123
28+
}
29+
actual_log_message = json.loads(self.stream.getvalue())
30+
actual_filepath = actual_log_message.pop("code.filepath")
31+
self.assertIn("snowflake-telemetry-python/tests/test_snowflake_log_formatter.py", actual_filepath)
32+
self.assertEqual(expected_log_message, actual_log_message)
33+
34+
def test_exception_log(self):
35+
""" Does the logger include exception details? """
36+
try:
37+
10 / 0
38+
except ZeroDivisionError:
39+
self.root_logger.exception("\"test exception\"")
40+
41+
expected_log_message = {
42+
"body": "\"test exception\"",
43+
"severity_text": "ERROR",
44+
"code.lineno": 39,
45+
"code.function": "test_exception_log",
46+
"exception.type": "ZeroDivisionError",
47+
"exception.message": "division by zero",
48+
}
49+
actual_log_message = json.loads(self.stream.getvalue(), strict=False)
50+
actual_log_message.pop("code.filepath")
51+
actual_stacktrace = actual_log_message.pop("exception.stacktrace")
52+
self.assertIn("line 37, in test_exception_log\n", actual_stacktrace)
53+
self.assertIn("ZeroDivisionError: division by zero\n", actual_stacktrace)
54+
self.assertEqual(expected_log_message, actual_log_message)
55+
56+
def test_get_severity_text(self):
57+
""" Does get_severity_text work correctly? """
58+
self.assertEqual("INFO", SnowflakeLogFormatter.get_severity_text("info"))
59+
self.assertEqual("FATAL", SnowflakeLogFormatter.get_severity_text("critical"))
60+
self.assertEqual("WARN", SnowflakeLogFormatter.get_severity_text("warning"))
61+
self.assertEqual("DEBUG", SnowflakeLogFormatter.get_severity_text("debug"))
62+
self.assertEqual("ERROR", SnowflakeLogFormatter.get_severity_text("error"))
63+
self.assertEqual("TRACE", SnowflakeLogFormatter.get_severity_text("notset"))
64+
self.assertEqual("TRACE", SnowflakeLogFormatter.get_severity_text("random"))

0 commit comments

Comments
 (0)