Skip to content

Commit 5add1e7

Browse files
feat: Expose metric dictionary to make logging metrics as JSON easier
1 parent eda4fd6 commit 5add1e7

File tree

6 files changed

+126
-51
lines changed

6 files changed

+126
-51
lines changed

docs/implementation/logging.md

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,36 +44,41 @@ Users of a tap can configure the SDK logging by setting the `SINGER_SDK_LOG_CONF
4444
environment variable. The value of this variable should be a path to a YAML file in the
4545
[Python logging dict format](https://docs.python.org/3/library/logging.config.html#dictionary-schema-details).
4646

47-
For example, to send [metrics](./metrics.md) (with logger name `singer_sdk.metrics`) to a file, you could use the following config:
47+
### Metrics logging
48+
49+
The Singer SDK provides a logger named `singer_sdk.metrics` for logging [Singer metrics](./metrics.md). Metric log records contain an extra field `point` which is a dictionary containing the metric data. The `point` field is formatted as JSON by default.
50+
51+
To send metrics to a file in JSON format, you could use the following config:
4852

4953
```yaml
5054
version: 1
5155
disable_existing_loggers: false
5256
formatters:
5357
metrics:
54-
format: "{asctime} {levelname} {message}"
58+
(): pythonjsonlogger.jsonlogger.JsonFormatter
59+
format: "{created} {message} {point}"
5560
style: "{"
5661
handlers:
5762
metrics:
5863
class: logging.FileHandler
5964
formatter: metrics
60-
filename: metrics.log
65+
filename: metrics.jsonl
6166
loggers:
6267
singer_sdk.metrics:
6368
level: INFO
6469
handlers: [ metrics ]
65-
propagate: yes
70+
propagate: no
6671
```
6772
68-
This will send metrics to a `metrics.log`:
73+
This will send metrics to a `metrics.jsonl`:
6974

70-
```
71-
2022-09-29 00:48:52,746 INFO METRIC: {"metric_type": "timer", "metric": "http_request_duration", "value": 0.501743, "tags": {"stream": "continents", "endpoint": "", "http_status_code": 200, "status": "succeeded"}}
72-
2022-09-29 00:48:52,775 INFO METRIC: {"metric_type": "counter", "metric": "http_request_count", "value": 1, "tags": {"stream": "continents", "endpoint": ""}}
73-
2022-09-29 00:48:52,776 INFO METRIC: {"metric_type": "timer", "metric": "sync_duration", "value": 0.7397160530090332, "tags": {"stream": "continents", "context": {}, "status": "succeeded"}}
74-
2022-09-29 00:48:52,776 INFO METRIC: {"metric_type": "counter", "metric": "record_count", "value": 7, "tags": {"stream": "continents", "context": {}}}
75-
2022-09-29 00:48:53,225 INFO METRIC: {"metric_type": "timer", "metric": "http_request_duration", "value": 0.392148, "tags": {"stream": "countries", "endpoint": "", "http_status_code": 200, "status": "succeeded"}}
76-
2022-09-29 00:48:53,302 INFO METRIC: {"metric_type": "counter", "metric": "http_request_count", "value": 1, "tags": {"stream": "countries", "endpoint": ""}}
77-
2022-09-29 00:48:53,302 INFO METRIC: {"metric_type": "timer", "metric": "sync_duration", "value": 0.5258760452270508, "tags": {"stream": "countries", "context": {}, "status": "succeeded"}}
78-
2022-09-29 00:48:53,303 INFO METRIC: {"metric_type": "counter", "metric": "record_count", "value": 250, "tags": {"stream": "countries", "context": {}}}
75+
```json
76+
{"created": 1705709074.883021, "message": "METRIC", "point": {"type": "timer", "metric": "http_request_duration", "value": 0.501743, "tags": {"stream": "continents", "endpoint": "", "http_status_code": 200, "status": "succeeded"}}}
77+
{"created": 1705709074.897184, "message": "METRIC", "point": {"type": "counter", "metric": "http_request_count", "value": 1, "tags": {"stream": "continents", "endpoint": ""}}}
78+
{"created": 1705709074.897256, "message": "METRIC", "point": {"type": "timer", "metric": "sync_duration", "value": 0.7397160530090332, "tags": {"stream": "continents", "context": {}, "status": "succeeded"}}}
79+
{"created": 1705709074.897292, "message": "METRIC", "point": {"type": "counter", "metric": "record_count", "value": 7, "tags": {"stream": "continents", "context": {}}}}
80+
{"created": 1705709075.397254, "message": "METRIC", "point": {"type": "timer", "metric": "http_request_duration", "value": 0.392148, "tags": {"stream": "countries", "endpoint": "", "http_status_code": 200, "status": "succeeded"}}}
81+
{"created": 1705709075.421888, "message": "METRIC", "point": {"type": "counter", "metric": "http_request_count", "value": 1, "tags": {"stream": "countries", "endpoint": ""}}}
82+
{"created": 1705709075.422001, "message": "METRIC", "point": {"type": "timer", "metric": "sync_duration", "value": 0.5258760452270508, "tags": {"stream": "countries", "context": {}, "status": "succeeded"}}}
83+
{"created": 1705709075.422047, "message": "METRIC", "point": {"type": "counter", "metric": "record_count", "value": 250, "tags": {"stream": "countries", "context": {}}}}
7984
```

poetry.lock

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ pendulum = ">=2.1.0,<4"
5555
PyJWT = "~=2.4"
5656
python-dateutil = ">=2.8.2"
5757
python-dotenv = ">=0.20"
58+
python-json-logger = ">=2"
5859
PyYAML = ">=6.0"
5960
referencing = ">=0.30.0"
6061
requests = ">=2.25.1"

singer_sdk/default_logging.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,24 @@ formatters:
44
console:
55
format: "{asctime:23s} | {levelname:8s} | {name:20s} | {message}"
66
style: "{"
7+
metrics:
8+
(): singer_sdk.metrics.SingerMetricsFormatter
9+
format: "{asctime:23s} | {levelname:8s} | {name:20s} | {message}: {metric_json}"
10+
style: "{"
711
handlers:
812
default:
913
class: logging.StreamHandler
1014
formatter: console
1115
stream: ext://sys.stderr
16+
metrics:
17+
class: logging.StreamHandler
18+
formatter: metrics
19+
stream: ext://sys.stderr
1220
root:
1321
level: INFO
1422
handlers: [default]
23+
loggers:
24+
singer_sdk.metrics:
25+
level: INFO
26+
handlers: [ metrics ]
27+
propagate: no

singer_sdk/metrics.py

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -67,29 +67,35 @@ class Point(t.Generic[_TVal]):
6767
value: _TVal
6868
tags: dict[str, t.Any] = field(default_factory=dict)
6969

70-
def __str__(self) -> str:
71-
"""Get string representation of this measurement.
70+
def to_dict(self) -> dict[str, t.Any]:
71+
"""Convert this measure to a dictionary.
7272
7373
Returns:
74-
A string representation of this measurement.
74+
A dictionary.
7575
"""
76-
return self.to_json()
76+
return {
77+
"type": self.metric_type,
78+
"metric": self.metric.value,
79+
"value": self.value,
80+
"tags": self.tags,
81+
}
7782

78-
def to_json(self) -> str:
79-
"""Convert this measure to a JSON object.
83+
84+
class SingerMetricsFormatter(logging.Formatter):
85+
"""Logging formatter that adds a ``metric_json`` field to the log record."""
86+
87+
def format(self, record: logging.LogRecord) -> str:
88+
"""Format a log record.
89+
90+
Args:
91+
record: A log record.
8092
8193
Returns:
82-
A JSON object.
94+
A formatted log record.
8395
"""
84-
return json.dumps(
85-
{
86-
"type": self.metric_type,
87-
"metric": self.metric.value,
88-
"value": self.value,
89-
"tags": self.tags,
90-
},
91-
default=str,
92-
)
96+
point = record.__dict__.get("point")
97+
record.__dict__["metric_json"] = json.dumps(point, default=str) if point else ""
98+
return super().format(record)
9399

94100

95101
def log(logger: logging.Logger, point: Point) -> None:
@@ -99,7 +105,7 @@ def log(logger: logging.Logger, point: Point) -> None:
99105
logger: An logger instance.
100106
point: A measurement.
101107
"""
102-
logger.info("METRIC: %s", point)
108+
logger.info("METRIC", extra={"point": point.to_dict()})
103109

104110

105111
class Meter(metaclass=abc.ABCMeta):

tests/core/test_metrics.py

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from __future__ import annotations
22

3+
import json
34
import logging
4-
import time
55

66
import pytest
7+
import time_machine
78

89
from singer_sdk import metrics
910

@@ -17,6 +18,32 @@ def __str__(self) -> str:
1718
return f"{self.name}={self.value}"
1819

1920

21+
def test_singer_metrics_formatter():
22+
formatter = metrics.SingerMetricsFormatter(fmt="{metric_json}", style="{")
23+
24+
record = logging.LogRecord(
25+
name="test",
26+
level=logging.INFO,
27+
pathname="test.py",
28+
lineno=1,
29+
msg="METRIC",
30+
args=(),
31+
exc_info=None,
32+
)
33+
34+
assert formatter.format(record) == ""
35+
36+
metric_dict = {
37+
"type": "counter",
38+
"metric": "test_metric",
39+
"tags": {"test_tag": "test_value"},
40+
"value": 1,
41+
}
42+
record.__dict__["point"] = metric_dict
43+
44+
assert formatter.format(record) == json.dumps(metric_dict)
45+
46+
2047
def test_meter():
2148
class _MyMeter(metrics.Meter):
2249
def __enter__(self):
@@ -38,6 +65,9 @@ def __exit__(self, exc_type, exc_val, exc_tb):
3865

3966

4067
def test_record_counter(caplog: pytest.LogCaptureFixture):
68+
metrics_logger = logging.getLogger(metrics.METRICS_LOGGER_NAME)
69+
metrics_logger.propagate = True
70+
4171
caplog.set_level(logging.INFO, logger=metrics.METRICS_LOGGER_NAME)
4272
custom_object = CustomObject("test", 1)
4373

@@ -56,48 +86,57 @@ def test_record_counter(caplog: pytest.LogCaptureFixture):
5686
total = 0
5787

5888
assert len(caplog.records) == 100 + 1
89+
# raise AssertionError
5990

6091
for record in caplog.records:
6192
assert record.levelname == "INFO"
62-
assert record.msg == "METRIC: %s"
63-
assert "test=1" in record.message
93+
assert record.msg.startswith("METRIC")
6494

65-
point: metrics.Point[int] = record.args[0]
66-
assert point.metric_type == "counter"
67-
assert point.metric == "record_count"
68-
assert point.tags == {
95+
point = record.__dict__["point"]
96+
assert point["type"] == "counter"
97+
assert point["metric"] == "record_count"
98+
assert point["tags"] == {
6999
metrics.Tag.STREAM: "test_stream",
70100
metrics.Tag.ENDPOINT: "test_endpoint",
71101
"custom_tag": "pytest",
72102
"custom_obj": custom_object,
73103
}
74104

75-
total += point.value
105+
total += point["value"]
76106

77107
assert total == 100
78108

79109

80110
def test_sync_timer(caplog: pytest.LogCaptureFixture):
111+
metrics_logger = logging.getLogger(metrics.METRICS_LOGGER_NAME)
112+
metrics_logger.propagate = True
113+
81114
caplog.set_level(logging.INFO, logger=metrics.METRICS_LOGGER_NAME)
82-
with metrics.sync_timer("test_stream", custom_tag="pytest") as timer:
83-
start_time = timer.start_time
84-
for _ in range(1000):
85-
time.sleep(0.001)
86-
end_time = time.time()
115+
116+
traveller = time_machine.travel(0, tick=False)
117+
traveller.start()
118+
119+
with metrics.sync_timer("test_stream", custom_tag="pytest"):
120+
traveller.stop()
121+
122+
traveller = time_machine.travel(10, tick=False)
123+
traveller.start()
124+
125+
traveller.stop()
87126

88127
assert len(caplog.records) == 1
89128

90129
record = caplog.records[0]
91130
assert record.levelname == "INFO"
92-
assert record.msg == "METRIC: %s"
131+
assert record.msg.startswith("METRIC")
93132

94-
point: metrics.Point[float] = record.args[0]
95-
assert point.metric_type == "timer"
96-
assert point.metric == "sync_duration"
97-
assert point.tags == {
133+
point = record.__dict__["point"]
134+
assert point["type"] == "timer"
135+
assert point["metric"] == "sync_duration"
136+
assert point["tags"] == {
98137
metrics.Tag.STREAM: "test_stream",
99138
metrics.Tag.STATUS: "succeeded",
100139
"custom_tag": "pytest",
101140
}
102141

103-
assert pytest.approx(point.value, rel=0.001) == end_time - start_time
142+
assert pytest.approx(point["value"], rel=0.001) == 10.0

0 commit comments

Comments
 (0)