Skip to content

Commit e2aac19

Browse files
Merge branch 'main' into feat/conform-primitive-interpret-string
2 parents 24cb6e2 + 687254f commit e2aac19

File tree

7 files changed

+2026
-1946
lines changed

7 files changed

+2026
-1946
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ repos:
4949
- id: check-readthedocs
5050

5151
- repo: https://github.com/astral-sh/ruff-pre-commit
52-
rev: v0.11.7
52+
rev: v0.11.8
5353
hooks:
5454
- id: ruff
5555
args: [--fix, --exit-non-zero-on-fix, --show-fixes]
@@ -64,7 +64,7 @@ repos:
6464
)$
6565
6666
- repo: https://github.com/astral-sh/uv-pre-commit
67-
rev: 0.6.17
67+
rev: 0.7.2
6868
hooks:
6969
- id: uv-lock
7070
- id: uv-sync

docs/implementation/logging.md

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,38 +44,43 @@ 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`:
69-
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": {}}}
73+
This will send metrics to a `metrics.jsonl`:
74+
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
```
8085

8186
## For package developers

singer_sdk/metrics.py

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

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

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

95101

96102
def log(logger: logging.Logger, point: Point) -> None:
@@ -100,7 +106,7 @@ def log(logger: logging.Logger, point: Point) -> None:
100106
logger: An logger instance.
101107
point: A measurement.
102108
"""
103-
logger.info("METRIC: %s", point)
109+
logger.info("METRIC", extra={"point": point.to_dict()})
104110

105111

106112
class Meter(metaclass=abc.ABCMeta):
@@ -415,19 +421,36 @@ def _setup_logging(config: t.Mapping[str, t.Any]) -> None:
415421
"console": {
416422
"format": "{asctime:23s} | {levelname:8s} | {name:20s} | {message}",
417423
"style": "{",
418-
}
424+
},
425+
"metrics": {
426+
"()": SingerMetricsFormatter,
427+
"format": "{asctime:23s} | {levelname:8s} | {name:20s} | {message}: {metric_json}", # noqa: E501
428+
"style": "{",
429+
},
419430
},
420431
"handlers": {
421432
"default": {
422433
"()": logging.StreamHandler,
423434
"formatter": "console",
424435
"stream": sys.stderr,
425436
},
437+
"metrics": {
438+
"()": logging.StreamHandler,
439+
"formatter": "metrics",
440+
"stream": sys.stderr,
441+
},
426442
},
427443
"root": {
428444
"level": logging.INFO,
429445
"handlers": ["default"],
430446
},
447+
"loggers": {
448+
"singer_sdk.metrics": {
449+
"level": logging.INFO,
450+
"handlers": ["metrics"],
451+
"propagate": False,
452+
},
453+
},
431454
},
432455
)
433456

singer_sdk/singerlib/json.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,6 @@ def serialize_json(obj: object, **kwargs: t.Any) -> str:
5959
use_decimal=True,
6060
default=_default_encoding,
6161
separators=(",", ":"),
62+
ignore_nan=True,
6263
**kwargs,
6364
)

tests/core/test_metrics.py

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import json
34
import logging
45
import os
56

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

2021

22+
def test_singer_metrics_formatter():
23+
formatter = metrics.SingerMetricsFormatter(fmt="{metric_json}", style="{")
24+
25+
record = logging.LogRecord(
26+
name="test",
27+
level=logging.INFO,
28+
pathname="test.py",
29+
lineno=1,
30+
msg="METRIC",
31+
args=(),
32+
exc_info=None,
33+
)
34+
35+
assert not formatter.format(record)
36+
37+
metric_dict = {
38+
"type": "counter",
39+
"metric": "test_metric",
40+
"tags": {"test_tag": "test_value"},
41+
"value": 1,
42+
}
43+
record.__dict__["point"] = metric_dict
44+
45+
assert formatter.format(record) == json.dumps(metric_dict)
46+
47+
2148
def test_meter():
2249
pid = os.getpid()
2350

@@ -44,6 +71,9 @@ def __exit__(self, exc_type, exc_val, exc_tb):
4471

4572

4673
def test_record_counter(caplog: pytest.LogCaptureFixture):
74+
metrics_logger = logging.getLogger(metrics.METRICS_LOGGER_NAME)
75+
metrics_logger.propagate = True
76+
4777
caplog.set_level(logging.INFO, logger=metrics.METRICS_LOGGER_NAME)
4878
pid = os.getpid()
4979
custom_object = CustomObject("test", 1)
@@ -63,30 +93,34 @@ def test_record_counter(caplog: pytest.LogCaptureFixture):
6393
total = 0
6494

6595
assert len(caplog.records) == 100 + 1
96+
# raise AssertionError
6697

6798
for record in caplog.records:
6899
assert record.levelname == "INFO"
69-
assert record.msg == "METRIC: %s"
70-
assert "test=1" in record.message
100+
assert record.msg.startswith("METRIC")
71101

72-
point: metrics.Point[int] = record.args[0]
73-
assert point.metric_type == "counter"
74-
assert point.metric == "record_count"
75-
assert point.tags == {
102+
point = record.__dict__["point"]
103+
assert point["type"] == "counter"
104+
assert point["metric"] == "record_count"
105+
assert point["tags"] == {
76106
metrics.Tag.STREAM: "test_stream",
77107
metrics.Tag.ENDPOINT: "test_endpoint",
78108
metrics.Tag.PID: pid,
79109
"custom_tag": "pytest",
80110
"custom_obj": custom_object,
81111
}
82112

83-
total += point.value
113+
total += point["value"]
84114

85115
assert total == 100
86116

87117

88118
def test_sync_timer(caplog: pytest.LogCaptureFixture):
119+
metrics_logger = logging.getLogger(metrics.METRICS_LOGGER_NAME)
120+
metrics_logger.propagate = True
121+
89122
caplog.set_level(logging.INFO, logger=metrics.METRICS_LOGGER_NAME)
123+
90124
pid = os.getpid()
91125
traveler = time_machine.travel(0, tick=False)
92126
traveler.start()
@@ -101,16 +135,16 @@ def test_sync_timer(caplog: pytest.LogCaptureFixture):
101135

102136
record = caplog.records[0]
103137
assert record.levelname == "INFO"
104-
assert record.msg == "METRIC: %s"
138+
assert record.msg.startswith("METRIC")
105139

106-
point: metrics.Point[float] = record.args[0]
107-
assert point.metric_type == "timer"
108-
assert point.metric == "sync_duration"
109-
assert point.tags == {
140+
point = record.__dict__["point"]
141+
assert point["type"] == "timer"
142+
assert point["metric"] == "sync_duration"
143+
assert point["tags"] == {
110144
metrics.Tag.STREAM: "test_stream",
111145
metrics.Tag.STATUS: "succeeded",
112146
metrics.Tag.PID: pid,
113147
"custom_tag": "pytest",
114148
}
115149

116-
assert pytest.approx(point.value, rel=0.001) == 10.0
150+
assert pytest.approx(point["value"], rel=0.001) == 10.0

tests/singerlib/encoding/test_simple.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,18 @@ def test_write_message():
104104
assert out.getvalue() == (
105105
'{"type":"RECORD","stream":"test","record":{"id":1,"name":"test"}}\n'
106106
)
107+
108+
109+
@pytest.mark.parametrize("float_value", [float("nan"), float("inf"), float("-inf")])
110+
def test_encode_nan_values(float_value):
111+
writer = SimpleSingerWriter()
112+
message = RecordMessage(
113+
stream="test",
114+
record={"id": 1, "name": float_value},
115+
)
116+
with redirect_stdout(io.StringIO()) as out:
117+
writer.write_message(message)
118+
119+
assert out.getvalue() == (
120+
'{"type":"RECORD","stream":"test","record":{"id":1,"name":null}}\n'
121+
)

0 commit comments

Comments
 (0)