Skip to content

Commit 99e2a07

Browse files
authored
Add log_level config (#946)
* Remove unused arg from conf.setup_logging * Add ValidValuesValidator * Add tests for ValidValuesValidator * Add support for callbacks_on_default Also refactored one of the `call_callbacks` methods to `call_all_callbacks` for clarity. * Add log_level config with stubbed callback * Don't run callbacks on copy operations * Move callbacks to the end of any update() operation This allows for callbacks to be config aware, and makes the full config available to callback functions. * Add log_file/size and implement logging setup callback * Fix docs link * Add changelog * Audit logger calls * Don't mess with global logging library * Add tests * Fix missing arg for ConfigurationError * Import logging.handlers * Implement review suggestions * Fix windows tests * Don't set log_level by default * Better handling for callbacks if default is None * With new default handling, don't need to check for new_value * Allow `warn` in addition to `warning` for log_level
1 parent 943607a commit 99e2a07

File tree

11 files changed

+326
-30
lines changed

11 files changed

+326
-30
lines changed

CHANGELOG.asciidoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ endif::[]
4040
* Implement "sample_rate" property for transactions and spans, and propagate through tracestate {pull}891[#891]
4141
* Add support for callbacks on config changes {pull}912[#912]
4242
* Override `sys.excepthook` to catch all exceptions {pull}943[#943]
43+
* Implement `log_level` config (supports central config) {pull}946[#946]
4344
4445
[float]
4546
===== Bug fixes

docs/configuration.asciidoc

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,73 @@ but the agent will continue to poll the server for configuration changes.
157157

158158
The transport class to use when sending events to the APM Server.
159159

160+
[float]
161+
[[logging-options]]
162+
=== Logging Options
163+
164+
[float]
165+
[[config-log_level]]
166+
==== `log_level`
167+
168+
<<dynamic-configuration, image:./images/dynamic-config.svg[] >>
169+
170+
[options="header"]
171+
|============
172+
| Environment | Django/Flask | Default
173+
| `ELASTIC_APM_LOG_LEVEL` | `LOG_LEVEL` |
174+
|============
175+
176+
The `logging.logLevel` at which the `elasticapm` logger will log. Available
177+
options are:
178+
179+
* `"off"` (sets `logging.logLevel` to 1000)
180+
* `"critical"`
181+
* `"error"`
182+
* `"warning"`
183+
* `"info"`
184+
* `"debug"`
185+
* `"trace"` (sets `logging.log_level` to 5)
186+
187+
Options are case-insensitive.
188+
189+
Note that this option doesn't do anything with logging handlers. In order
190+
for any logs to be visible, you must either configure a handler
191+
(https://docs.python.org/3/library/logging.html#logging.basicConfig[`logging.basicConfig`]
192+
will do this for you) or set <<config-log_file>>. This will also override
193+
any log level your app has set for the `elasticapm` logger.
194+
195+
[float]
196+
[[config-log_file]]
197+
==== `log_file`
198+
199+
[options="header"]
200+
|============
201+
| Environment | Django/Flask | Default | Example
202+
| `ELASTIC_APM_LOG_FILE` | `LOG_FILE` | `""` | `"/var/log/elasticapm/log.txt"`
203+
|============
204+
205+
Enables the agent to log to a file. Disabled by default. The agent will log
206+
at the `logging.logLevel` configured with <<config-log_level>>. Use
207+
<<config-log_file_size>> to configure the max size of the log file. This log
208+
file will automatically rotate.
209+
210+
Note that setting <<config-log_level>> is required for this setting to do
211+
anything.
212+
213+
[float]
214+
[[config-log_file_size]]
215+
==== `log_file_size`
216+
217+
[options="header"]
218+
|============
219+
| Environment | Django/Flask | Default | Example
220+
| `ELASTIC_APM_LOG_FILE_SIZE` | `LOG_FILE_SIZE` | `"50mb"` | `"100mb"`
221+
|============
222+
223+
The size of the log file, if <<config-log_file>> is set.
224+
225+
The agent always keeps one backup file when rotating, so the max space that
226+
the log files will consume is twice the value of this setting.
160227

161228
[float]
162229
[[other-options]]

elasticapm/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -539,7 +539,7 @@ def _filter_exception_type(self, data):
539539
exc_name = "%s.%s" % (exc_module, exc_type)
540540
else:
541541
exc_name = exc_type
542-
self.logger.info("Ignored %s exception due to exception type filter", exc_name)
542+
self.logger.debug("Ignored %s exception due to exception type filter", exc_name)
543543
return True
544544
return False
545545

elasticapm/conf/__init__.py

Lines changed: 133 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131

3232
import logging
33+
import logging.handlers
3334
import math
3435
import os
3536
import re
@@ -46,6 +47,18 @@
4647

4748
logger = get_logger("elasticapm.conf")
4849

50+
log_levels_map = {
51+
"trace": 5,
52+
"debug": logging.DEBUG,
53+
"info": logging.INFO,
54+
"warning": logging.WARNING,
55+
"warn": logging.WARNING,
56+
"error": logging.ERROR,
57+
"critical": logging.CRITICAL,
58+
"off": 1000,
59+
}
60+
logfile_set_up = False
61+
4962

5063
class ConfigurationError(ValueError):
5164
def __init__(self, msg, field_name):
@@ -71,7 +84,19 @@ class _ConfigValue(object):
7184
fails.
7285
callbacks
7386
List of functions which will be called when the config value is updated.
74-
The callbacks must match this signature: callback(dict_key, old_value, new_value)
87+
The callbacks must match this signature:
88+
callback(dict_key, old_value, new_value, config_instance)
89+
90+
Note that callbacks wait until the end of any given `update()` operation
91+
and are called at this point. This, coupled with the fact that callbacks
92+
receive the config instance, means that callbacks can utilize multiple
93+
configuration values (such as is the case for logging). This is
94+
complicated if more than one of the involved config values are
95+
dynamic, as both would need callbacks and the callback would need to
96+
be idempotent.
97+
callbacks_on_default
98+
Whether the callback should be called on config initialization if the
99+
default value is used. Default: True
75100
default
76101
The default for this config value if not user-configured.
77102
required
@@ -92,6 +117,7 @@ def __init__(
92117
type=compat.text_type,
93118
validators=None,
94119
callbacks=None,
120+
callbacks_on_default=True,
95121
default=None,
96122
required=False,
97123
):
@@ -104,6 +130,7 @@ def __init__(
104130
if env_key is None:
105131
env_key = "ELASTIC_APM_" + dict_key
106132
self.env_key = env_key
133+
self.callbacks_on_default = callbacks_on_default
107134

108135
def __get__(self, instance, owner):
109136
if instance:
@@ -139,19 +166,20 @@ def _callback_if_changed(self, instance, new_value):
139166
"""
140167
old_value = instance._values.get(self.dict_key, self.default)
141168
if old_value != new_value:
142-
self.call_callbacks(old_value, new_value)
169+
instance.callbacks_queue.append((self.dict_key, old_value, new_value))
143170

144-
def call_callbacks(self, old_value, new_value):
171+
def call_callbacks(self, old_value, new_value, config_instance):
145172
if not self.callbacks:
146173
return
147174
for callback in self.callbacks:
148175
try:
149-
callback(self.dict_key, old_value, new_value)
176+
callback(self.dict_key, old_value, new_value, config_instance)
150177
except Exception as e:
151178
raise ConfigurationError(
152179
"Callback {} raised an exception when setting {} to {}: {}".format(
153180
callback, self.dict_key, new_value, e
154-
)
181+
),
182+
self.dict_key,
155183
)
156184

157185

@@ -297,20 +325,83 @@ def __call__(self, value, field_name):
297325
return value
298326

299327

328+
class EnumerationValidator(object):
329+
"""
330+
Validator which ensures that a given config value is chosen from a list
331+
of valid string options.
332+
"""
333+
334+
def __init__(self, valid_values, case_sensitive=False):
335+
"""
336+
valid_values
337+
List of valid string values for the config value
338+
case_sensitive
339+
Whether to compare case when comparing a value to the valid list.
340+
Defaults to False (case-insensitive)
341+
"""
342+
self.case_sensitive = case_sensitive
343+
if case_sensitive:
344+
self.valid_values = {s: s for s in valid_values}
345+
else:
346+
self.valid_values = {s.lower(): s for s in valid_values}
347+
348+
def __call__(self, value, field_name):
349+
if self.case_sensitive:
350+
ret = self.valid_values.get(value)
351+
else:
352+
ret = self.valid_values.get(value.lower())
353+
if ret is None:
354+
raise ConfigurationError(
355+
"{} is not in the list of valid values: {}".format(value, list(self.valid_values.values())), field_name
356+
)
357+
return ret
358+
359+
360+
def _log_level_callback(dict_key, old_value, new_value, config_instance):
361+
elasticapm_logger = logging.getLogger("elasticapm")
362+
elasticapm_logger.setLevel(log_levels_map.get(new_value, 100))
363+
364+
global logfile_set_up
365+
if not logfile_set_up and config_instance.log_file:
366+
logfile_set_up = True
367+
filehandler = logging.handlers.RotatingFileHandler(
368+
config_instance.log_file, maxBytes=config_instance.log_file_size, backupCount=1
369+
)
370+
elasticapm_logger.addHandler(filehandler)
371+
372+
300373
class _ConfigBase(object):
301374
_NO_VALUE = object() # sentinel object
302375

303-
def __init__(self, config_dict=None, env_dict=None, inline_dict=None):
376+
def __init__(self, config_dict=None, env_dict=None, inline_dict=None, copy=False):
377+
"""
378+
config_dict
379+
Configuration dict as is common for frameworks such as flask and django.
380+
Keys match the _ConfigValue.dict_key (usually all caps)
381+
env_dict
382+
Environment variables dict. Keys match the _ConfigValue.env_key
383+
(usually "ELASTIC_APM_" + dict_key)
384+
inline_dict
385+
Any config passed in as kwargs to the Client object. Typically
386+
the keys match the names of the _ConfigValue variables in the Config
387+
object.
388+
copy
389+
Whether this object is being created to copy an existing Config
390+
object. If True, don't run the initial `update` (which would call
391+
callbacks if present)
392+
"""
304393
self._values = {}
305394
self._errors = {}
306395
self._dict_key_lookup = {}
396+
self.callbacks_queue = []
307397
for config_value in self.__class__.__dict__.values():
308398
if not isinstance(config_value, _ConfigValue):
309399
continue
310400
self._dict_key_lookup[config_value.dict_key] = config_value
311-
self.update(config_dict, env_dict, inline_dict)
401+
if not copy:
402+
self.update(config_dict, env_dict, inline_dict, initial=True)
312403

313-
def update(self, config_dict=None, env_dict=None, inline_dict=None):
404+
def update(self, config_dict=None, env_dict=None, inline_dict=None, initial=False):
314405
if config_dict is None:
315406
config_dict = {}
316407
if env_dict is None:
@@ -336,20 +427,30 @@ def update(self, config_dict=None, env_dict=None, inline_dict=None):
336427
setattr(self, field, new_value)
337428
except ConfigurationError as e:
338429
self._errors[e.field_name] = str(e)
430+
# handle initial callbacks
431+
if (
432+
initial
433+
and config_value.callbacks_on_default
434+
and getattr(self, field) is not None
435+
and getattr(self, field) == config_value.default
436+
):
437+
self.callbacks_queue.append((config_value.dict_key, self._NO_VALUE, config_value.default))
339438
# if a field has not been provided by any config source, we have to check separately if it is required
340439
if config_value.required and getattr(self, field) is None:
341440
self._errors[config_value.dict_key] = "Configuration error: value for {} is required.".format(
342441
config_value.dict_key
343442
)
443+
self.call_pending_callbacks()
344444

345-
def call_callbacks(self, callbacks):
445+
def call_pending_callbacks(self):
346446
"""
347447
Call callbacks for config options matching list of tuples:
348448
349449
(dict_key, old_value, new_value)
350450
"""
351-
for dict_key, old_value, new_value in callbacks:
352-
self._dict_key_lookup[dict_key].call_callbacks(old_value, new_value)
451+
for dict_key, old_value, new_value in self.callbacks_queue:
452+
self._dict_key_lookup[dict_key].call_callbacks(old_value, new_value, self)
453+
self.callbacks_queue = []
353454

354455
@property
355456
def values(self):
@@ -364,21 +465,21 @@ def errors(self):
364465
return self._errors
365466

366467
def copy(self):
367-
c = self.__class__()
468+
c = self.__class__(copy=True)
368469
c._errors = {}
369470
c.values = self.values.copy()
370471
return c
371472

372473

373474
class Config(_ConfigBase):
374475
service_name = _ConfigValue("SERVICE_NAME", validators=[RegexValidator("^[a-zA-Z0-9 _-]+$")], required=True)
375-
service_node_name = _ConfigValue("SERVICE_NODE_NAME", default=None)
376-
environment = _ConfigValue("ENVIRONMENT", default=None)
476+
service_node_name = _ConfigValue("SERVICE_NODE_NAME")
477+
environment = _ConfigValue("ENVIRONMENT")
377478
secret_token = _ConfigValue("SECRET_TOKEN")
378479
api_key = _ConfigValue("API_KEY")
379480
debug = _BoolConfigValue("DEBUG", default=False)
380481
server_url = _ConfigValue("SERVER_URL", default="http://localhost:8200", required=True)
381-
server_cert = _ConfigValue("SERVER_CERT", default=None, validators=[FileIsReadableValidator()])
482+
server_cert = _ConfigValue("SERVER_CERT", validators=[FileIsReadableValidator()])
382483
verify_server_cert = _BoolConfigValue("VERIFY_SERVER_CERT", default=True)
383484
include_paths = _ListConfigValue("INCLUDE_PATHS")
384485
exclude_paths = _ListConfigValue("EXCLUDE_PATHS", default=compat.get_default_library_patters())
@@ -456,9 +557,9 @@ class Config(_ConfigBase):
456557
autoinsert_django_middleware = _BoolConfigValue("AUTOINSERT_DJANGO_MIDDLEWARE", default=True)
457558
transactions_ignore_patterns = _ListConfigValue("TRANSACTIONS_IGNORE_PATTERNS", default=[])
458559
service_version = _ConfigValue("SERVICE_VERSION")
459-
framework_name = _ConfigValue("FRAMEWORK_NAME", default=None)
460-
framework_version = _ConfigValue("FRAMEWORK_VERSION", default=None)
461-
global_labels = _DictConfigValue("GLOBAL_LABELS", default=None)
560+
framework_name = _ConfigValue("FRAMEWORK_NAME")
561+
framework_version = _ConfigValue("FRAMEWORK_VERSION")
562+
global_labels = _DictConfigValue("GLOBAL_LABELS")
462563
disable_send = _BoolConfigValue("DISABLE_SEND", default=False)
463564
enabled = _BoolConfigValue("ENABLED", default=True)
464565
recording = _BoolConfigValue("RECORDING", default=True)
@@ -470,6 +571,13 @@ class Config(_ConfigBase):
470571
use_elastic_traceparent_header = _BoolConfigValue("USE_ELASTIC_TRACEPARENT_HEADER", default=True)
471572
use_elastic_excepthook = _BoolConfigValue("USE_ELASTIC_EXCEPTHOOK", default=False)
472573
cloud_provider = _ConfigValue("CLOUD_PROVIDER", default=True)
574+
log_level = _ConfigValue(
575+
"LOG_LEVEL",
576+
validators=[EnumerationValidator(["trace", "debug", "info", "warning", "warn", "error", "critical", "off"])],
577+
callbacks=[_log_level_callback],
578+
)
579+
log_file = _ConfigValue("LOG_FILE", default="")
580+
log_file_size = _ConfigValue("LOG_FILE_SIZE", validators=[size_validator], type=int, default=50 * 1024 * 1024)
473581

474582
@property
475583
def is_recording(self):
@@ -544,7 +652,8 @@ def reset(self):
544652
self._version = self._first_version
545653
self._config = self._first_config
546654

547-
self._config.call_callbacks(callbacks)
655+
self._config.callbacks_queue.extend(callbacks)
656+
self._config.call_pending_callbacks()
548657

549658
@property
550659
def changed(self):
@@ -578,7 +687,7 @@ def update_config(self):
578687
logger.error("Error applying new configuration: %s", repr(errors))
579688
else:
580689
logger.info(
581-
"Applied new configuration: %s",
690+
"Applied new remote configuration: %s",
582691
"; ".join(
583692
"%s=%s" % (compat.text_type(k), compat.text_type(v)) for k, v in compat.iteritems(new_config)
584693
),
@@ -604,12 +713,10 @@ def stop_thread(self):
604713
self._update_thread = None
605714

606715

607-
def setup_logging(handler, exclude=("gunicorn", "south", "elasticapm.errors")):
716+
def setup_logging(handler):
608717
"""
609718
Configures logging to pipe to Elastic APM.
610719
611-
- ``exclude`` is a list of loggers that shouldn't go to ElasticAPM.
612-
613720
For a typical Python install:
614721
615722
>>> from elasticapm.handlers.logging import LoggingHandler
@@ -623,6 +730,9 @@ def setup_logging(handler, exclude=("gunicorn", "south", "elasticapm.errors")):
623730
624731
Returns a boolean based on if logging was configured or not.
625732
"""
733+
# TODO We should probably revisit this. Does it make more sense as
734+
# a method within the Client class? The Client object could easily
735+
# pass itself into LoggingHandler and we could eliminate args altogether.
626736
logger = logging.getLogger()
627737
if handler.__class__ in map(type, logger.handlers):
628738
return False

0 commit comments

Comments
 (0)