Skip to content

Commit 8b88555

Browse files
Colton Myersbmorelli25beniwohli
authored
Collect metrics once per lambda invocation (#1643)
* Collect metrics once before ending lambda function * Allow a MetricsSet instance to be added to the registry * Add a test * Add first pass at docs * Apply suggestions from code review Co-authored-by: Brandon Morelli <[email protected]> Co-authored-by: Benjamin Wohlwend <[email protected]> * More review suggestions * Simplify API to accept string or class object * Remove some unnecessary checks (type hints are enough) * Rename MetricsSet to MetricSet for more consistency * Add an alias for backwards compat * Document metrics_sets and fix up custom metrics docs * Rename Client._metrics to Client.metrics * Add __module__ to metricset identifier Co-authored-by: Benjamin Wohlwend <[email protected]> * Add a deprecation warning for client._metrics * CHANGELOG Co-authored-by: Brandon Morelli <[email protected]> Co-authored-by: Benjamin Wohlwend <[email protected]>
1 parent 515df75 commit 8b88555

File tree

13 files changed

+160
-46
lines changed

13 files changed

+160
-46
lines changed

CHANGELOG.asciidoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ endif::[]
3737
===== Features
3838
* Add backend granularity data to SQL backends as well as Cassandra and pymongo {pull}1585[#1585], {pull}1639[#1639]
3939
* Add support for instrumenting the Elasticsearch 8 Python client {pull}1642[#1642]
40+
* Add docs and better support for custom metrics, including in AWS Lambda {pull}1643[#1643]
4041
4142
[float]
4243
===== Bug fixes

docs/configuration.asciidoc

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,6 +1113,20 @@ See <<prometheus-metricset>> for more information.
11131113

11141114
NOTE: This feature is currently in beta status.
11151115

1116+
[float]
1117+
[[config-metrics_sets]]
1118+
==== `metrics_sets`
1119+
1120+
[options="header"]
1121+
|============
1122+
| Environment | Django/Flask | Default
1123+
| `ELASTIC_APM_METRICS_SETS` | `METRICS_SETS` | ["elasticapm.metrics.sets.cpu.CPUMetricSet"]
1124+
|============
1125+
1126+
List of import paths for the MetricSets that should be used to collect metrics.
1127+
1128+
See <<custom-metrics>> for more information.
1129+
11161130
[float]
11171131
[[config-central_config]]
11181132
==== `central_config`

docs/metrics.asciidoc

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ These metrics will be sent regularly to the APM Server and from there to Elastic
1111
* <<cpu-memory-metricset>>
1212
* <<breakdown-metricset>>
1313
* <<prometheus-metricset>>
14+
* <<custom-metrics>>
1415

1516
[float]
1617
[[cpu-memory-metricset]]
@@ -160,3 +161,55 @@ All metrics collected from `prometheus_client` are prefixed with `"prometheus.me
160161
[[prometheus-metricset-beta]]
161162
===== Beta limitations
162163
* The metrics format may change without backwards compatibility in future releases.
164+
165+
[float]
166+
[[custom-metrics]]
167+
=== Custom Metrics
168+
169+
Custom metrics allow you to send your own metrics to Elasticsearch.
170+
171+
The most common way to send custom metrics is with the
172+
<<prometheus-metricset,Prometheus metric set>>. However, you can also use your
173+
own metric set. If you collect the metrics manually in your code, you can use
174+
the base `MetricSet` class:
175+
176+
[source,python]
177+
----
178+
from elasticapm.metrics.base_metrics import MetricSet
179+
180+
client = elasticapm.Client()
181+
metricset = client.metrics.register(MetricSet)
182+
183+
for x in range(10):
184+
metricset.counter("my_counter").inc()
185+
----
186+
187+
Alternatively, you can create your own MetricSet class which inherits from the
188+
base class. In this case, you'll usually want to override the `before_collect`
189+
method, where you can gather and set metrics before they are collected and sent
190+
to Elasticsearch.
191+
192+
You can add your `MetricSet` class as shown in the example above, or you can
193+
add an import string for your class to the <<config-metrics_sets,`metrics_sets`>>
194+
configuration option:
195+
196+
[source,bash]
197+
----
198+
ELASTIC_APM_METRICS_SETS="elasticapm.metrics.sets.cpu.CPUMetricSet,myapp.metrics.MyMetricSet"
199+
----
200+
201+
Your MetricSet might look something like this:
202+
203+
[source,python]
204+
----
205+
from elasticapm.metrics.base_metrics import MetricSet
206+
207+
class MyAwesomeMetricSet(MetricSet):
208+
def before_collect(self):
209+
self.gauge("my_gauge").set(myapp.some_value)
210+
----
211+
212+
In the example above, the MetricSet would look up `myapp.some_value` and set
213+
the metric `my_gauge` to that value. This would happen whenever metrics are
214+
collected/sent, which is controlled by the
215+
<<config-metrics_interval,`metrics_interval`>> setting.

elasticapm/base.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -198,15 +198,15 @@ def __init__(self, config=None, **inline):
198198
)
199199
self.include_paths_re = stacks.get_path_regex(self.config.include_paths) if self.config.include_paths else None
200200
self.exclude_paths_re = stacks.get_path_regex(self.config.exclude_paths) if self.config.exclude_paths else None
201-
self._metrics = MetricsRegistry(self)
201+
self.metrics = MetricsRegistry(self)
202202
for path in self.config.metrics_sets:
203-
self._metrics.register(path)
203+
self.metrics.register(path)
204204
if self.config.breakdown_metrics:
205-
self._metrics.register("elasticapm.metrics.sets.breakdown.BreakdownMetricSet")
205+
self.metrics.register("elasticapm.metrics.sets.breakdown.BreakdownMetricSet")
206206
if self.config.prometheus_metrics:
207-
self._metrics.register("elasticapm.metrics.sets.prometheus.PrometheusMetrics")
207+
self.metrics.register("elasticapm.metrics.sets.prometheus.PrometheusMetrics")
208208
if self.config.metrics_interval:
209-
self._thread_managers["metrics"] = self._metrics
209+
self._thread_managers["metrics"] = self.metrics
210210
compat.atexit_register(self.close)
211211
if self.config.central_config:
212212
self._thread_managers["config"] = self.config
@@ -682,6 +682,11 @@ def check_server_version(
682682
lte = lte or (2**32,) # let's assume APM Server version will never be greater than 2^32
683683
return bool(gte <= self.server_version <= lte)
684684

685+
@property
686+
def _metrics(self):
687+
warnings.warn(DeprecationWarning("Use `client.metrics` instead"))
688+
return self.metrics
689+
685690

686691
class DummyClient(Client):
687692
"""Sends messages into an empty void"""

elasticapm/contrib/serverless/aws.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ def __init__(self, name: Optional[str] = None, elasticapm_client: Optional[Clien
8181

8282
# Disable all background threads except for transport
8383
kwargs["metrics_interval"] = "0ms"
84+
kwargs["breakdown_metrics"] = False
85+
if "metrics_sets" not in kwargs and "ELASTIC_APM_METRICS_SETS" not in os.environ:
86+
# Allow users to override metrics sets
87+
kwargs["metrics_sets"] = []
8488
kwargs["central_config"] = False
8589
kwargs["cloud_provider"] = "none"
8690
kwargs["framework_name"] = "AWS Lambda"
@@ -220,6 +224,8 @@ def __exit__(self, exc_type, exc_val, exc_tb):
220224
elasticapm.set_transaction_outcome(outcome="failure", override=False)
221225

222226
self.client.end_transaction()
227+
# Collect any custom+prometheus metrics if enabled
228+
self.client.metrics.collect()
223229

224230
try:
225231
logger.debug("flushing elasticapm")

elasticapm/metrics/base_metrics.py

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import threading
3232
import time
3333
from collections import defaultdict
34+
from typing import Union
3435

3536
from elasticapm.conf import constants
3637
from elasticapm.utils.logging import get_logger
@@ -56,25 +57,36 @@ def __init__(self, client, tags=None):
5657
self._collect_timer = None
5758
super(MetricsRegistry, self).__init__()
5859

59-
def register(self, class_path):
60+
def register(self, metricset: Union[str, type]) -> "MetricSet":
6061
"""
6162
Register a new metric set
62-
:param class_path: a string with the import path of the metricset class
63+
64+
:param metricset: a string with the import path of the metricset class,
65+
or a class object that can be used to instantiate the metricset.
66+
If a class object is used, you can use the class object or
67+
`metricset.__name__` to retrieve the metricset using `get_metricset`.
68+
:return: the metricset instance
6369
"""
64-
if class_path in self._metricsets:
65-
return
70+
class_id = metricset if isinstance(metricset, str) else f"{metricset.__module__}.{metricset.__name__}"
71+
if class_id in self._metricsets:
72+
return self._metricsets[class_id]
6673
else:
67-
try:
68-
class_obj = import_string(class_path)
69-
self._metricsets[class_path] = class_obj(self)
70-
except ImportError as e:
71-
logger.warning("Could not register %s metricset: %s", class_path, str(e))
72-
73-
def get_metricset(self, class_path):
74+
if isinstance(metricset, str):
75+
try:
76+
class_obj = import_string(metricset)
77+
self._metricsets[metricset] = class_obj(self)
78+
except ImportError as e:
79+
logger.warning("Could not register %s metricset: %s", metricset, str(e))
80+
else:
81+
self._metricsets[class_id] = metricset(self)
82+
return self._metricsets.get(class_id)
83+
84+
def get_metricset(self, metricset: Union[str, type]) -> "MetricSet":
85+
metricset = metricset if isinstance(metricset, str) else f"{metricset.__module__}.{metricset.__name__}"
7486
try:
75-
return self._metricsets[class_path]
87+
return self._metricsets[metricset]
7688
except KeyError:
77-
raise MetricSetNotFound(class_path)
89+
raise MetricSetNotFound(metricset)
7890

7991
def collect(self):
8092
"""
@@ -114,7 +126,7 @@ def ignore_patterns(self):
114126
return self.client.config.disable_metrics or []
115127

116128

117-
class MetricsSet(object):
129+
class MetricSet(object):
118130
def __init__(self, registry):
119131
self._lock = threading.Lock()
120132
self._counters = {}
@@ -282,13 +294,17 @@ def before_collect(self):
282294
pass
283295

284296
def before_yield(self, data):
297+
"""
298+
A method that is called right before the data is yielded to be sent
299+
to Elasticsearch. Can be used to modify the data.
300+
"""
285301
return data
286302

287303
def _labels_to_key(self, labels):
288304
return tuple((k, str(v)) for k, v in sorted(labels.items()))
289305

290306

291-
class SpanBoundMetricSet(MetricsSet):
307+
class SpanBoundMetricSet(MetricSet):
292308
def before_yield(self, data):
293309
tags = data.get("tags", None)
294310
if tags:
@@ -495,3 +511,9 @@ def reset(self):
495511
class MetricSetNotFound(LookupError):
496512
def __init__(self, class_path):
497513
super(MetricSetNotFound, self).__init__("%s metric set not found" % class_path)
514+
515+
516+
# This is for backwards compatibility for the brave souls who were using
517+
# the undocumented system for custom metrics before we fixed it up and
518+
# documented it.
519+
MetricsSet = MetricSet

elasticapm/metrics/sets/cpu_linux.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
import resource
3535
import threading
3636

37-
from elasticapm.metrics.base_metrics import MetricsSet
37+
from elasticapm.metrics.base_metrics import MetricSet
3838

3939
SYS_STATS = "/proc/stat"
4040
MEM_STATS = "/proc/meminfo"
@@ -72,7 +72,7 @@ def __init__(self, limit, usage, stat):
7272
self.stat = stat if os.access(stat, os.R_OK) else None
7373

7474

75-
class CPUMetricSet(MetricsSet):
75+
class CPUMetricSet(MetricSet):
7676
def __init__(
7777
self,
7878
registry,

elasticapm/metrics/sets/cpu_psutil.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,15 @@
2828
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
2929
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
3030

31-
from elasticapm.metrics.base_metrics import MetricsSet
31+
from elasticapm.metrics.base_metrics import MetricSet
3232

3333
try:
3434
import psutil
3535
except ImportError:
3636
raise ImportError("psutil not found. Install it to get system and process metrics")
3737

3838

39-
class CPUMetricSet(MetricsSet):
39+
class CPUMetricSet(MetricSet):
4040
def __init__(self, registry):
4141
psutil.cpu_percent(interval=None)
4242
self._process = psutil.Process()

elasticapm/metrics/sets/prometheus.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@
3333

3434
import prometheus_client
3535

36-
from elasticapm.metrics.base_metrics import MetricsSet
36+
from elasticapm.metrics.base_metrics import MetricSet
3737

3838

39-
class PrometheusMetrics(MetricsSet):
39+
class PrometheusMetrics(MetricSet):
4040
def __init__(self, registry):
4141
super(PrometheusMetrics, self).__init__(registry)
4242
self._prometheus_registry = prometheus_client.REGISTRY

elasticapm/traces.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ def __init__(
233233
self._span_timers_lock = threading.Lock()
234234
self._dropped_span_statistics = defaultdict(lambda: {"count": 0, "duration.sum.us": 0})
235235
try:
236-
self._breakdown = self.tracer._agent._metrics.get_metricset(
236+
self._breakdown = self.tracer._agent.metrics.get_metricset(
237237
"elasticapm.metrics.sets.breakdown.BreakdownMetricSet"
238238
)
239239
except (LookupError, AttributeError):

0 commit comments

Comments
 (0)