Skip to content

Commit aa6c4d8

Browse files
authored
Add support for Elastic APM log correlation
1 parent 0c26f34 commit aa6c4d8

File tree

4 files changed

+195
-3
lines changed

4 files changed

+195
-3
lines changed

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ $ python -m pip install ecs-logging
2626
[`logging`](https://docs.python.org/3/library/logging.html) module
2727
and the [`structlog`](https://www.structlog.org/en/stable/) package.
2828

29-
### Logging Example
29+
## Standard Library `logging` Module
3030

3131
```python
3232
import logging
@@ -111,7 +111,7 @@ formatter = StdlibFormatter(
111111
)
112112
```
113113

114-
### Structlog Example
114+
## Structlog Example
115115

116116
```python
117117
import structlog
@@ -180,6 +180,12 @@ logger.debug("Example message!")
180180
}
181181
```
182182

183+
## Elastic APM Log Correlation
184+
185+
`ecs-logging-python` supports automatically collecting [ECS tracing fields](https://www.elastic.co/guide/en/ecs/master/ecs-tracing.html)
186+
from the [Elastic APM Python agent](https://github.com/elastic/apm-agent-python) in order to
187+
[correlate logs to spans, transactions and traces](https://www.elastic.co/guide/en/apm/agent/python/current/log-correlation.html) in Elastic APM.
188+
183189
## License
184190

185191
Apache-2.0

ecs_logging/_stdlib.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,17 @@ def format_to_ecs(self, record):
158158
extra_keys = set(available).difference(self._LOGRECORD_DICT)
159159
extras = flatten_dict({key: available[key] for key in extra_keys})
160160

161+
# Pop all Elastic APM extras and add them
162+
# to standard tracing ECS fields.
163+
extras["span.id"] = extras.pop("elasticapm_span_id", None)
164+
extras["transaction.id"] = extras.pop("elasticapm_transaction_id", None)
165+
extras["trace.id"] = extras.pop("elasticapm_trace_id", None)
166+
161167
# Merge in any keys that were set within 'extra={...}'
162168
for field, value in extras.items():
163-
if self._is_field_excluded(field):
169+
if field.startswith("elasticapm_labels."):
170+
continue # Unconditionally remove, we don't need this info.
171+
if value is None or self._is_field_excluded(field):
164172
continue
165173
merge_dicts(de_dot(field, value), result)
166174

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ develop = [
3434
"pytest-cov",
3535
"mock",
3636
"structlog",
37+
"elastic-apm",
3738
]
3839

3940
[tool.flit.metadata.urls]

tests/test_apm.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import json
2+
import sys
3+
import elasticapm
4+
from elasticapm.handlers.logging import LoggingFilter
5+
from elasticapm.handlers.structlog import structlog_processor
6+
import ecs_logging
7+
import logging
8+
import structlog
9+
import pytest
10+
from .compat import StringIO
11+
12+
13+
def test_elasticapm_structlog_log_correlation_ecs_fields():
14+
apm = elasticapm.Client({"SERVICE_NAME": "apm-service", "DISABLE_SEND": True})
15+
stream = StringIO()
16+
logger = structlog.PrintLogger(stream)
17+
logger = structlog.wrap_logger(
18+
logger, processors=[structlog_processor, ecs_logging.StructlogFormatter()]
19+
)
20+
log = logger.new()
21+
22+
apm.begin_transaction("test-transaction")
23+
try:
24+
with elasticapm.capture_span("test-span"):
25+
span_id = elasticapm.get_span_id()
26+
trace_id = elasticapm.get_trace_id()
27+
transaction_id = elasticapm.get_transaction_id()
28+
29+
log.info("test message")
30+
finally:
31+
apm.end_transaction("test-transaction")
32+
33+
ecs = json.loads(stream.getvalue().rstrip())
34+
ecs.pop("@timestamp")
35+
assert ecs == {
36+
"ecs": {"version": "1.5.0"},
37+
"log": {"level": "info"},
38+
"message": "test message",
39+
"span": {"id": span_id},
40+
"trace": {"id": trace_id},
41+
"transaction": {"id": transaction_id},
42+
}
43+
44+
45+
@pytest.mark.skipif(
46+
sys.version_info < (3, 2), reason="elastic-apm uses logger factory in Python 3.2+"
47+
)
48+
def test_elastic_apm_stdlib_no_filter_log_correlation_ecs_fields():
49+
apm = elasticapm.Client({"SERVICE_NAME": "apm-service", "DISABLE_SEND": True})
50+
stream = StringIO()
51+
logger = logging.getLogger("apm-logger")
52+
handler = logging.StreamHandler(stream)
53+
handler.setFormatter(
54+
ecs_logging.StdlibFormatter(
55+
exclude_fields=["@timestamp", "process", "log.origin.file.line"]
56+
)
57+
)
58+
logger.addHandler(handler)
59+
logger.setLevel(logging.DEBUG)
60+
61+
apm.begin_transaction("test-transaction")
62+
try:
63+
with elasticapm.capture_span("test-span"):
64+
span_id = elasticapm.get_span_id()
65+
trace_id = elasticapm.get_trace_id()
66+
transaction_id = elasticapm.get_transaction_id()
67+
68+
logger.info("test message")
69+
finally:
70+
apm.end_transaction("test-transaction")
71+
72+
ecs = json.loads(stream.getvalue().rstrip())
73+
assert ecs == {
74+
"ecs": {"version": "1.5.0"},
75+
"log": {
76+
"level": "info",
77+
"logger": "apm-logger",
78+
"origin": {
79+
"file": {"name": "test_apm.py"},
80+
"function": "test_elastic_apm_stdlib_no_filter_log_correlation_ecs_fields",
81+
},
82+
"original": "test message",
83+
},
84+
"message": "test message",
85+
"span": {"id": span_id},
86+
"trace": {"id": trace_id},
87+
"transaction": {"id": transaction_id},
88+
}
89+
90+
91+
def test_elastic_apm_stdlib_with_filter_log_correlation_ecs_fields():
92+
apm = elasticapm.Client({"SERVICE_NAME": "apm-service", "DISABLE_SEND": True})
93+
stream = StringIO()
94+
logger = logging.getLogger("apm-logger")
95+
handler = logging.StreamHandler(stream)
96+
handler.setFormatter(
97+
ecs_logging.StdlibFormatter(
98+
exclude_fields=["@timestamp", "process", "log.origin.file.line"]
99+
)
100+
)
101+
handler.addFilter(LoggingFilter())
102+
logger.addHandler(handler)
103+
logger.setLevel(logging.DEBUG)
104+
105+
apm.begin_transaction("test-transaction")
106+
try:
107+
with elasticapm.capture_span("test-span"):
108+
span_id = elasticapm.get_span_id()
109+
trace_id = elasticapm.get_trace_id()
110+
transaction_id = elasticapm.get_transaction_id()
111+
112+
logger.info("test message")
113+
finally:
114+
apm.end_transaction("test-transaction")
115+
116+
ecs = json.loads(stream.getvalue().rstrip())
117+
assert ecs == {
118+
"ecs": {"version": "1.5.0"},
119+
"log": {
120+
"level": "info",
121+
"logger": "apm-logger",
122+
"origin": {
123+
"file": {"name": "test_apm.py"},
124+
"function": "test_elastic_apm_stdlib_with_filter_log_correlation_ecs_fields",
125+
},
126+
"original": "test message",
127+
},
128+
"message": "test message",
129+
"span": {"id": span_id},
130+
"trace": {"id": trace_id},
131+
"transaction": {"id": transaction_id},
132+
}
133+
134+
135+
def test_elastic_apm_stdlib_exclude_fields():
136+
apm = elasticapm.Client({"SERVICE_NAME": "apm-service", "DISABLE_SEND": True})
137+
stream = StringIO()
138+
logger = logging.getLogger("apm-logger")
139+
handler = logging.StreamHandler(stream)
140+
handler.setFormatter(
141+
ecs_logging.StdlibFormatter(
142+
exclude_fields=[
143+
"@timestamp",
144+
"process",
145+
"log.origin.file.line",
146+
"span",
147+
"transaction.id",
148+
]
149+
)
150+
)
151+
logger.addHandler(handler)
152+
logger.setLevel(logging.DEBUG)
153+
154+
apm.begin_transaction("test-transaction")
155+
try:
156+
with elasticapm.capture_span("test-span"):
157+
trace_id = elasticapm.get_trace_id()
158+
159+
logger.info("test message")
160+
finally:
161+
apm.end_transaction("test-transaction")
162+
163+
ecs = json.loads(stream.getvalue().rstrip())
164+
assert ecs == {
165+
"ecs": {"version": "1.5.0"},
166+
"log": {
167+
"level": "info",
168+
"logger": "apm-logger",
169+
"origin": {
170+
"file": {"name": "test_apm.py"},
171+
"function": "test_elastic_apm_stdlib_exclude_fields",
172+
},
173+
"original": "test message",
174+
},
175+
"message": "test message",
176+
"trace": {"id": trace_id},
177+
}

0 commit comments

Comments
 (0)