Skip to content

Commit 4b92589

Browse files
authored
Add logging integrations with logging and structlog (#586)
* Add logging integrations with logging and structlog * Update changelog * Simplify and make naming consistent * Add span.id to logging (and pub API) as well * Fix more refactor leftovers, test against actual ID * Add the tracing fields under "correct" name as well * Add log_record_factory and DRY some code * Automatically install the log_record_factory Plus add a test * Update changelog * Add more documentation about correlation * s/elasticapm/The Elastic APM agent/ Co-Authored-By: Benjamin Wohlwend <bw@piquadrat.ch> * Fix project-level import Co-Authored-By: Benjamin Wohlwend <bw@piquadrat.ch> * Apply suggestions from code review Co-Authored-By: Brandon Morelli <bmorelli25@gmail.com> * Fix up tracing fields link * Apply suggestions from code review Co-Authored-By: Brandon Morelli <bmorelli25@gmail.com> * Separate log_record_factory tests from client instantiation Also fix up some imports and assert elasticapm_labels everywhere * More docs changes from reviews * Fix another accidental relative import * Use full link for ecs docs * Fix for py2 testing * Fix missing import (inline due to circular import) * Remove standalone log_record_factory test Because of the dynamic insertion in Client, this test was failing on the wider integration tests (perhaps because it had been inserted on previous test runs? I'm actually not sure). Anyway, we test that it works with an instantiated Client so I think this test is unnecessary. * Apply suggestions from code review Co-Authored-By: Benjamin Wohlwend <bw@piquadrat.ch> Co-Authored-By: Andrew Wilkins <axwalk@gmail.com> * Implement config doc changes * Fix structlog processor to skip None values
1 parent 442b0db commit 4b92589

File tree

12 files changed

+457
-17
lines changed

12 files changed

+457
-17
lines changed

CHANGELOG.md

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
## Unreleased
44
[Check the diff](https://github.com/elastic/apm-agent-python/compare/v5.1.2...master)
55

6+
### New Features
7+
* added automatic tagging of LogRecord objects with transaction, trace, and span IDs via a LogRecordFactory (Python 3.2+) (#520, #586)
8+
* added `logging` filter and record factory for adding transaction, trace, and span IDs (#520, #586)
9+
* added `structlog` processor for adding transaction, trace, and span IDs (#520, #586)
10+
* added new public API calls for getting transaction, trace, and span IDs (#520, #586)
11+
612
### Bugfixes
713
* drop events immediately if a processor returns a falsy value (#585)
814

@@ -15,7 +21,7 @@
1521
* fixed zerorpc tests (#581)
1622
* fixed to correctly check if gevent has patched threading.local (#579)
1723

18-
## v5.1.1
24+
## v5.1.1
1925
[Check the diff](https://github.com/elastic/apm-agent-python/compare/v5.1.0...v5.1.1)
2026

2127
### Bugfixes
@@ -27,7 +33,7 @@
2733
### Other
2834
* Added Python 3.8 RC to the test matrix (#565)
2935

30-
## v5.1.0
36+
## v5.1.0
3137
[Check the diff](https://github.com/elastic/apm-agent-python/compare/v5.0.0...v5.1.0)
3238

3339
### Security issues
@@ -46,7 +52,7 @@
4652
* fixed an issue with the `instrument` config option (#546, #547)
4753
* limited the amount of distinct metrics to 1000 (#540, #544)
4854

49-
## v5.0.0
55+
## v5.0.0
5056
[Check the diff](https://github.com/elastic/apm-agent-python/compare/v4.2.2...v5.0.0)
5157
### Breaking changes
5258

@@ -72,12 +78,12 @@
7278
* introduced `IntervalTimer` and use it instead of `threading.Timer` (#452)
7379
* added license header check as pre-commit hook (#456)
7480

75-
## v4.2.1
81+
## v4.2.1
7682
[Check the diff](https://github.com/elastic/apm-agent-python/compare/v4.2.0...v4.2.1)
7783
* fixed an issue with the certificate pinning feature introduced in 4.2.0 (#433, #434)
7884
* fixed incompatibility with eventlet introduced in 4.2.0 (#435, #436)
79-
80-
## v4.2.0
85+
86+
## v4.2.0
8187
[Check the diff](https://github.com/elastic/apm-agent-python/compare/v4.1.0...v4.2.0)
8288

8389
* Implemented a new transport queue, which should avoid certain deadlock scenarios (#411)
@@ -90,7 +96,7 @@
9096
* Fixed an issue with parsing /proc/stat in RHEL/centos 6 (#406, #407)
9197
* Added copyright header to all files, and a CI check (#429)
9298

93-
## v4.1.0
99+
## v4.1.0
94100
[Check the diff](https://github.com/elastic/apm-agent-python/compare/v4.0.3...v4.1.0)
95101

96102
* Added support for collecting system and process metrics (#361)
@@ -118,7 +124,7 @@
118124

119125
* fixed an issue with instrumenting redis-py 3.0+
120126
* fixed a multithreading issue that occurs when using threaded workers (#335)
121-
127+
122128
## v4.0.0
123129
[Check the diff](https://github.com/elastic/apm-agent-python/compare/v3.0.2...v4.0.0)
124130

@@ -143,14 +149,14 @@ Other changes:
143149

144150
* fixed an issue with detecting names of wrapped functions that are partials (#294)
145151
* fixed a bug in Flask instrumentation that could appear together with FlaskAPI (#286)
146-
152+
147153
## v3.0.1
148154

149155
[Check the diff](https://github.com/elastic/apm-agent-python/compare/v3.0.0...v3.0.1)
150156

151157
* added sanitization for `Set-Cookie` response headers (#264)
152158
* added instrumentation for the non-standard `Connection.execute()` method for SQLite3 (#271)
153-
* added "authorization" to list of sensitive keywords, to ensure that "Authorization"
159+
* added "authorization" to list of sensitive keywords, to ensure that "Authorization"
154160
HTTP headers are properly sanitized (#275)
155161
* taught the Logbook handler how to handle the `stack=False` option (#278)
156162
* fixed a race condition with managing the timer-send thread (#279)
@@ -160,12 +166,12 @@ Other changes:
160166
[Check the diff](https://github.com/elastic/apm-agent-python/compare/v2.2.1...v3.0.0)
161167

162168
- adapted "black" code formatter for this repository (#262)
163-
- **BREAKING**: dropped support for Python 3.3 (#242)
169+
- **BREAKING**: dropped support for Python 3.3 (#242)
164170
- **BREAKING**: changed order of precedence when evaluating configuration (#255, #261)
165-
- **BREAKING**: changed default value of `span_frames_min_duration` setting
171+
- **BREAKING**: changed default value of `span_frames_min_duration` setting
166172
from `-1` (always collect) to `5` (only collect for spans longer than 5 ms) (#243)
167173
- added instrumentation for pymssql (#241)
168-
- added instrumentation for pyodbc (#238)
174+
- added instrumentation for pyodbc (#238)
169175

170176
## v2.2.1
171177

docs/advanced-topics.asciidoc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@
77

88
include::./custom-instrumentation.asciidoc[Custom Instrumentation]
99
include::./sanitizing-data.asciidoc[Sanitizing Data]
10-
include::./run-tests-locally.asciidoc[Run Tests Locally]
10+
include::./run-tests-locally.asciidoc[Run Tests Locally]
11+
include::./logging.asciidoc[Logging Integrations]

docs/api.asciidoc

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,6 @@ elasticapm.set_transaction_name('myapp.billing_process')
174174
* `override`: if `True` (the default), overrides any previously set transaction name.
175175
If `False`, only sets the name if the transaction name hasn't already been set.
176176

177-
178177
[float]
179178
[[api-set-transaction-result]]
180179
==== `elasticapm.set_transaction_result()`
@@ -194,6 +193,49 @@ elasticapm.set_transaction_result('SUCCESS')
194193
* `override`: if `True` (the default), overrides any previously set result.
195194
If `False`, only sets the result if the result hasn't already been set.
196195

196+
197+
[float]
198+
[[api-get-transaction-id]]
199+
==== `elasticapm.get_transaction_id()`
200+
201+
Get the id of the current transaction. Example:
202+
203+
[source,python]
204+
----
205+
import elasticapm
206+
207+
transaction_id = elasticapm.get_transaction_id()
208+
----
209+
210+
211+
[float]
212+
[[api-get-trace-id]]
213+
==== `elasticapm.get_trace_id()`
214+
215+
Get the `trace_id` of the current transaction's trace. Example:
216+
217+
[source,python]
218+
----
219+
import elasticapm
220+
221+
trace_id = elasticapm.get_trace_id()
222+
----
223+
224+
225+
[float]
226+
[[api-get-span-id]]
227+
==== `elasticapm.get_span_id()`
228+
229+
Get the id of the current span. Example:
230+
231+
[source,python]
232+
----
233+
import elasticapm
234+
235+
span_id = elasticapm.get_span_id()
236+
----
237+
238+
197239
[float]
198240
[[api-set-custom-context]]
199241
==== `elasticapm.set_custom_context()`

docs/configuration.asciidoc

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,18 @@ Any labels set by application via the API will override global labels with the s
679679

680680
NOTE: This feature requires APM Server >= 7.2.
681681

682+
[float]
683+
[[config-generic-disable-log-record-factory]]
684+
==== `disable_log_record_factory`
685+
|============
686+
| Environment | Django/Flask | Default
687+
| `ELASTIC_APM_DISABLE_LOG_RECORD_FACTORY` | `DISABLE_LOG_RECORD_FACTORY` | `False`
688+
|============
689+
690+
By default in python 3, the agent will install a <<logging,LogRecord factory>> that
691+
automatically adds tracing fields to your log records. You can disable this
692+
behavior by setting this to `True`.
693+
682694
[float]
683695
[[config-django-specific]]
684696
=== Django-specific configuration

docs/logging.asciidoc

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
[[logging-integrations]]
2+
=== Logging integrations
3+
4+
The Elastic APM agent provides integrations with both the default Python logging library,
5+
as well as http://www.structlog.org/en/stable/[`structlog`].
6+
7+
[[logging]]
8+
==== `logging`
9+
10+
For Python 3.2+, we use https://docs.python.org/3/library/logging.html#logging.setLogRecordFactory[`logging.setLogRecordFactory()`]
11+
to decorate the default LogRecordFactory to automatically add new attributes to
12+
each LogRecord object:
13+
14+
* `elasticapm_transaction_id`
15+
* `elasticapm_trace_id`
16+
* `elasticapm_span_id`
17+
18+
This factory also adds these fields to a dictionary attribute,
19+
`elasticapm_labels`, using the official ECS https://www.elastic.co/guide/en/ecs/current/ecs-tracing.html[tracing fields].
20+
21+
You can disable this automatic behavior by using the
22+
<<config-generic-disable-log-record-factory,`disable_log_record_factory`>> setting
23+
in your configuration
24+
25+
For Python versions <3.2, we also provide a
26+
https://docs.python.org/3/library/logging.html#filter-objects[filter] which will
27+
add the same new attributes to any filtered `LogRecord`:
28+
29+
Note: Because https://docs.python.org/3/library/logging.html#filter-objects[filters
30+
are not propagated to descendent loggers], you should add the filter to each of
31+
your log handlers, as handlers are propagated, along with their attached filters.
32+
33+
[source,python]
34+
----
35+
import logging
36+
from elasticapm.handlers.logging import LoggingFilter
37+
38+
console = logging.StreamHandler()
39+
console.addFilter(LoggingFilter())
40+
# add the handler to the root logger
41+
logging.getLogger("").addHandler(console)
42+
----
43+
44+
45+
[[structlog]]
46+
==== `structlog`
47+
48+
We provide a http://www.structlog.org/en/stable/processors.html[processor] for
49+
http://www.structlog.org/en/stable/[`structlog`] which will add three new keys
50+
to the event_dict of any processed event:
51+
52+
* `transaction.id`
53+
* `trace.id`
54+
* `span.id`
55+
56+
[source,python]
57+
----
58+
from structlog import PrintLogger, wrap_logger
59+
from elasticapm.handlers.structlog import structlog_processor
60+
61+
wrapped_logger = PrintLogger()
62+
logger = wrap_logger(wrapped_logger, processors=[structlog_processor])
63+
log = logger.new()
64+
log.msg("some_event")
65+
----
66+
67+
68+
[[log-correlation]]
69+
=== Log correlation in Elasticsearch
70+
71+
In order to correlate logs from your app with transactions captured by the
72+
Elastic APM Python Agent, your logs must contain one or more of the following
73+
identifiers:
74+
75+
* `transaction.id`
76+
* `trace.id`
77+
* `span.id`
78+
79+
If you're using structured logging, either https://docs.python.org/3/howto/logging-cookbook.html#implementing-structured-logging[with a custom solution]
80+
or with http://www.structlog.org/en/stable/[structlog] (recommended), then this
81+
is fairly easy. Throw the http://www.structlog.org/en/stable/api.html#structlog.processors.JSONRenderer[JSONRenderer]
82+
in, and use {blog-ref}structured-logging-filebeat[Filebeat]
83+
to pull these logs into Elasticsearch.
84+
85+
Without structured logging the task gets a little trickier. Here we
86+
recommend first making sure your LogRecord objects have the elasticapm
87+
attributes (see <<logging>>), and then you'll want to combine some specific
88+
formatting with a Grok pattern, either in Elasticsearch using
89+
{ref}/grok-processor.html[the grok processor],
90+
or in {logstash-ref}/plugins-filters-grok.html[logstash with a plugin].
91+
92+
Say you have a https://docs.python.org/3/library/logging.html#logging.Formatter[Formatter]
93+
that looks like this:
94+
95+
[source,python]
96+
----
97+
import logging
98+
99+
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
100+
----
101+
102+
You can add the APM identifiers in an easy-to-parse manner:
103+
104+
[source,python]
105+
----
106+
import logging
107+
108+
format_string = (
109+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s "
110+
"| elasticapm "
111+
"transaction.id=%(elasticapm_transaction_id) "
112+
"trace.id=%(elasticapm_trace_id) "
113+
"span.id=%(elasticapm_span_id)"
114+
)
115+
formatter = logging.Formatter(format_string)
116+
----
117+
118+
Then, you could use a grok pattern like this (for the
119+
{ref}/grok-processor.html[Elasticsearch Grok Processor]):
120+
121+
122+
[source, json]
123+
----
124+
{
125+
"description" : "...",
126+
"processors": [
127+
{
128+
"grok": {
129+
"field": "message",
130+
"patterns": ["%{GREEDYDATA:msg} | elasticapm transaction.id=%{DATA:transaction.id} trace.id=%{DATA:trace.id} span.id=%{DATA:span.id}"]
131+
}
132+
}
133+
]
134+
}
135+
----

elasticapm/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,4 @@
4242
from elasticapm.traces import capture_span, set_context, set_custom_context # noqa: F401
4343
from elasticapm.traces import set_transaction_name, set_user_context, tag, label # noqa: F401
4444
from elasticapm.traces import set_transaction_result # noqa: F401
45+
from elasticapm.traces import get_transaction_id, get_trace_id, get_span_id # noqa: F401

elasticapm/base.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,21 @@ def __init__(self, config=None, **inline):
103103
config.disable_send = True
104104
self.config = VersionedConfig(config, version=None)
105105

106+
# Insert the log_record_factory into the logging library
107+
# The LogRecordFactory functionality is only available on python 3.2+
108+
if compat.PY3 and not self.config.disable_log_record_factory:
109+
record_factory = logging.getLogRecordFactory()
110+
# Only way to know if it's wrapped is to create a log record
111+
throwaway_record = record_factory(__name__, logging.DEBUG, __file__, 252, "dummy_msg", [], None)
112+
if not hasattr(throwaway_record, "elasticapm_labels"):
113+
self.logger.debug("Inserting elasticapm log_record_factory into logging")
114+
115+
# Late import due to circular imports
116+
import elasticapm.handlers.logging as elastic_logging
117+
118+
new_factory = elastic_logging.log_record_factory(record_factory)
119+
logging.setLogRecordFactory(new_factory)
120+
106121
headers = {
107122
"Content-Type": "application/x-ndjson",
108123
"Content-Encoding": "gzip",

elasticapm/conf/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,7 @@ class Config(_ConfigBase):
329329
enable_distributed_tracing = _BoolConfigValue("ENABLE_DISTRIBUTED_TRACING", default=True)
330330
capture_headers = _BoolConfigValue("CAPTURE_HEADERS", default=True)
331331
django_transaction_name_from_route = _BoolConfigValue("DJANGO_TRANSACTION_NAME_FROM_ROUTE", default=False)
332+
disable_log_record_factory = _BoolConfigValue("DISABLE_LOG_RECORD_FACTORY", default=False)
332333

333334

334335
class VersionedConfig(object):

0 commit comments

Comments
 (0)