Skip to content

Commit 88164e7

Browse files
actualwitchBrett Beutell
and
Brett Beutell
authored
Add concurrency tracking (#55)
* Add concurrency tracking * Fix concurrency tests for otel and prom trackers * Comment out opentelemetry concurrency test since gauges are not supported * Update docs * Add a test for passing objectives to autometrics then using async decorator * Update version to 0.6 --------- Co-authored-by: Brett Beutell <[email protected]>
1 parent 2412b13 commit 88164e7

16 files changed

+1094
-15
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
1313
### Added
1414

1515
- Exemplars support (#51)
16+
- Optional concurrency tracking support (#55)
1617

1718
### Changed
1819

@@ -28,6 +29,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
2829

2930
### Fixed
3031

32+
- Fixed decorator async function handling (#55)
33+
3134
### Security
3235

3336
- Update requests, starlette, fastapi dependencies used by the examples

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ def sayHello:
4545

4646
```
4747

48+
- You can also track the number of concurrent calls to a function by using the `track_concurrency` argument: `@autometrics(track_concurrency=True)`. Note: currently only supported by the `prometheus` tracker.
49+
4850
- To access the PromQL queries for your decorated functions, run `help(yourfunction)` or `print(yourfunction.__doc__)`.
4951

5052
- To show tooltips over decorated functions in VSCode, with links to Prometheus queries, try installing [the VSCode extension](https://marketplace.visualstudio.com/items?itemName=Fiberplane.autometrics).

examples/django_example/django_example/urls.py

+2
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@
1616
"""
1717
from django.urls import path
1818

19+
from .views.concurrency import ConcurrencyView
1920
from .views.latency import RandomLatencyView
2021
from .views.metrics import metrics
2122
from .views.simple import simple_handler
2223
from .views.error import ErrorOrOkView
2324

2425
urlpatterns = [
26+
path("concurrency/", ConcurrencyView.as_view()),
2527
path("latency/", RandomLatencyView.as_view()),
2628
path("error/", ErrorOrOkView.as_view()),
2729
path("simple/", simple_handler),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import time
2+
from autometrics import autometrics
3+
from django.http import HttpResponse
4+
from django.views import View
5+
6+
7+
class ConcurrencyView(View):
8+
"""Here you can see how concurrency tracking works in autometrics.
9+
Just add the `track_concurrency=True` argument, and autometrics
10+
will track the number of concurrent requests to this endpoint."""
11+
12+
@autometrics(track_concurrency=True)
13+
def get(self, request):
14+
time.sleep(0.25)
15+
return HttpResponse("Many clients wait for a reply from this endpoint!")

examples/django_example/django_example/views/latency.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ class RandomLatencyView(View):
1010

1111
@autometrics
1212
def get(self, request):
13-
duration = random.randint(1, 500)
13+
duration = random.randint(1, 10)
1414

15-
time.sleep(duration / 1000)
15+
time.sleep(duration / 10)
1616

1717
return HttpResponse("i was waiting for {}ms!".format(duration))

examples/django_example/locustfile.py

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import time
2+
from locust import HttpUser, task, between
3+
4+
5+
class DjangoUser(HttpUser):
6+
wait_time = between(1, 2.5)
7+
8+
@task(10)
9+
def visit_concurrency_handler(self):
10+
self.client.get("/concurrency/")
11+
12+
@task
13+
def visit_error_handler(self):
14+
self.client.get("/error/")
15+
16+
@task
17+
def visit_simple_handler(self):
18+
self.client.get("/simple/")
19+
20+
@task
21+
def visit_latency_handler(self):
22+
self.client.get("/latency/")
+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/bin/sh
2+
3+
export AUTOMETRICS_COMMIT=67a1b3a
4+
export AUTOMETRICS_VERSION=0.1.0
5+
export AUTOMETRICS_BRANCH=main
6+
export AUTOMETRICS_TRACKER=prometheus
7+
8+
# run the server itself
9+
poetry run python manage.py runserver 8080 &
10+
# run the locust load test and pipe stdout to dev/null
11+
poetry run locust --host=http://localhost:8080 --users=100 --headless &
12+
13+
# kill all child processes on exit
14+
trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT
15+
wait

poetry.lock

+838-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "autometrics"
3-
version = "0.5"
3+
version = "0.6"
44
description = "Easily add metrics to your system – and actually understand them using automatically customized Prometheus queries"
55
authors = ["Fiberplane <[email protected]>"]
66
license = "MIT OR Apache-2.0"
@@ -72,6 +72,7 @@ urllib3 = "1.26.15"
7272
uvicorn = "0.21.1"
7373
webencodings = "0.5.1"
7474
zipp = "3.15.0"
75+
locust = "^2.15.1"
7576

7677
[build-system]
7778
requires = ["poetry-core"]

src/autometrics/constants.py

+4
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@
22

33
COUNTER_NAME = "function.calls.count"
44
HISTOGRAM_NAME = "function.calls.duration"
5+
CONCURRENCY_NAME = "function.calls.concurrent"
56
# NOTE - The Rust implementation does not use `build.info`, instead opts for just `build_info`
67
BUILD_INFO_NAME = "build_info"
78

9+
810
COUNTER_NAME_PROMETHEUS = COUNTER_NAME.replace(".", "_")
911
HISTOGRAM_NAME_PROMETHEUS = HISTOGRAM_NAME.replace(".", "_")
12+
CONCURRENCY_NAME_PROMETHEUS = CONCURRENCY_NAME.replace(".", "_")
1013

1114
COUNTER_DESCRIPTION = "Autometrics counter for tracking function calls"
1215
HISTOGRAM_DESCRIPTION = "Autometrics histogram for tracking function call duration"
16+
CONCURRENCY_DESCRIPTION = "Autometrics gauge for tracking function call concurrency"
1317
BUILD_INFO_DESCRIPTION = (
1418
"Autometrics info metric for tracking software version and build details"
1519
)

src/autometrics/decorator.py

+22-2
Original file line numberDiff line numberDiff line change
@@ -28,24 +28,33 @@ def autometrics(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
2828

2929
# Decorator with arguments
3030
@overload
31-
def autometrics(*, objective: Optional[Objective] = None) -> Callable:
31+
def autometrics(
32+
*, objective: Optional[Objective] = None, track_concurrency: Optional[bool] = False
33+
) -> Callable:
3234
...
3335

3436

3537
def autometrics(
3638
func: Optional[Callable] = None,
3739
*,
3840
objective: Optional[Objective] = None,
41+
track_concurrency: Optional[bool] = False,
3942
):
4043
"""Decorator for tracking function calls and duration. Supports synchronous and async functions."""
4144

45+
def track_start(function: str, module: str):
46+
get_tracker().start(
47+
function=function, module=module, track_concurrency=track_concurrency
48+
)
49+
4250
def track_result_ok(start_time: float, function: str, module: str, caller: str):
4351
get_tracker().finish(
4452
start_time,
4553
function=function,
4654
module=module,
4755
caller=caller,
4856
objective=objective,
57+
track_concurrency=track_concurrency,
4958
result=Result.OK,
5059
)
5160

@@ -61,6 +70,7 @@ def track_result_error(
6170
module=module,
6271
caller=caller,
6372
objective=objective,
73+
track_concurrency=track_concurrency,
6474
result=Result.ERROR,
6575
)
6676

@@ -77,6 +87,8 @@ def sync_wrapper(*args: P.args, **kwds: P.kwargs) -> T:
7787
caller = get_caller_function()
7888

7989
try:
90+
if track_concurrency:
91+
track_start(module=module_name, function=func_name)
8092
result = func(*args, **kwds)
8193
track_result_ok(
8294
start_time, function=func_name, module=module_name, caller=caller
@@ -110,6 +122,8 @@ async def async_wrapper(*args: P.args, **kwds: P.kwargs) -> T:
110122
caller = get_caller_function()
111123

112124
try:
125+
if track_concurrency:
126+
track_start(module=module_name, function=func_name)
113127
result = await func(*args, **kwds)
114128
track_result_ok(
115129
start_time, function=func_name, module=module_name, caller=caller
@@ -130,8 +144,14 @@ async def async_wrapper(*args: P.args, **kwds: P.kwargs) -> T:
130144
async_wrapper.__doc__ = append_docs_to_docstring(func, func_name, module_name)
131145
return async_wrapper
132146

147+
def pick_decorator(func: Callable) -> Callable:
148+
"""Pick the correct decorator based on the function type."""
149+
if inspect.iscoroutinefunction(func):
150+
return async_decorator(func)
151+
return sync_decorator(func)
152+
133153
if func is None:
134-
return sync_decorator
154+
return pick_decorator
135155
elif inspect.iscoroutinefunction(func):
136156
return async_decorator(func)
137157
else:

src/autometrics/test_decorator.py

+44
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,50 @@ def test_objectives(self):
148148
duration_sum = f"""function_calls_duration_sum{{function="{function_name}",module="test_decorator",objective_latency_threshold="{latency[0].value}",objective_name="{objective_name}",objective_percentile="{latency[1].value}"}}"""
149149
assert duration_sum in data
150150

151+
@pytest.mark.asyncio
152+
async def test_objectives_async(self):
153+
"""This is a test that covers objectives for async functions."""
154+
155+
# set up the function + objective variables
156+
caller = get_caller_function(depth=1)
157+
assert caller is not None
158+
assert caller != ""
159+
objective_name = "test_objective"
160+
success_rate = ObjectivePercentile.P90
161+
latency = (ObjectiveLatency.Ms100, ObjectivePercentile.P99)
162+
objective = Objective(
163+
name=objective_name, success_rate=success_rate, latency=latency
164+
)
165+
function_name = basic_async_function.__name__
166+
wrapped_function = autometrics(objective=objective)(basic_async_function)
167+
168+
sleep_duration = 0.25
169+
170+
# Test that the function is *still* async after we wrapped it
171+
assert asyncio.iscoroutinefunction(wrapped_function) == True
172+
173+
await wrapped_function(sleep_duration)
174+
175+
# get the metrics
176+
blob = generate_latest()
177+
assert blob is not None
178+
data = blob.decode("utf-8")
179+
180+
total_count = f"""function_calls_count_total{{caller="{caller}",function="{function_name}",module="test_decorator",objective_name="{objective_name}",objective_percentile="{success_rate.value}",result="ok"}} 1.0"""
181+
assert total_count in data
182+
183+
# Check the latency buckets
184+
for objective in ObjectiveLatency:
185+
count = 0 if float(objective.value) <= sleep_duration else 1
186+
query = f"""function_calls_duration_bucket{{function="{function_name}",le="{objective.value}",module="test_decorator",objective_latency_threshold="{latency[0].value}",objective_name="{objective_name}",objective_percentile="{latency[1].value}"}} {count}"""
187+
assert query in data
188+
189+
duration_count = f"""function_calls_duration_count{{function="{function_name}",module="test_decorator",objective_latency_threshold="{latency[0].value}",objective_name="{objective_name}",objective_percentile="{latency[1].value}"}}"""
190+
assert duration_count in data
191+
192+
duration_sum = f"""function_calls_duration_sum{{function="{function_name}",module="test_decorator",objective_latency_threshold="{latency[0].value}",objective_name="{objective_name}",objective_percentile="{latency[1].value}"}}"""
193+
assert duration_sum in data
194+
151195
def test_exception(self):
152196
"""This is a test that covers exceptions."""
153197
caller = get_caller_function(depth=1)

src/autometrics/tracker/opentelemetry.py

+33-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import os
21
import time
32
from typing import Optional
43

@@ -17,6 +16,8 @@
1716
from .tracker import Result
1817
from ..objectives import Objective, ObjectiveLatency
1918
from ..constants import (
19+
CONCURRENCY_NAME,
20+
CONCURRENCY_DESCRIPTION,
2021
COUNTER_DESCRIPTION,
2122
COUNTER_NAME,
2223
HISTOGRAM_DESCRIPTION,
@@ -41,7 +42,8 @@ class OpenTelemetryTracker:
4142

4243
__counter_instance: Counter
4344
__histogram_instance: Histogram
44-
__up_down_counter_instance: UpDownCounter
45+
__up_down_counter_build_info_instance: UpDownCounter
46+
__up_down_counter_concurrency_instance: UpDownCounter
4547

4648
def __init__(self):
4749
exporter = PrometheusMetricReader("")
@@ -63,10 +65,14 @@ def __init__(self):
6365
name=HISTOGRAM_NAME,
6466
description=HISTOGRAM_DESCRIPTION,
6567
)
66-
self.__up_down_counter_instance = meter.create_up_down_counter(
68+
self.__up_down_counter_build_info_instance = meter.create_up_down_counter(
6769
name=BUILD_INFO_NAME,
6870
description=BUILD_INFO_DESCRIPTION,
6971
)
72+
self.__up_down_counter_concurrency_instance = meter.create_up_down_counter(
73+
name=CONCURRENCY_NAME,
74+
description=CONCURRENCY_DESCRIPTION,
75+
)
7076
self._has_set_build_info = False
7177

7278
def __count(
@@ -129,7 +135,7 @@ def __histogram(
129135
def set_build_info(self, commit: str, version: str, branch: str):
130136
if not self._has_set_build_info:
131137
self._has_set_build_info = True
132-
self.__up_down_counter_instance.add(
138+
self.__up_down_counter_build_info_instance.add(
133139
1.0,
134140
attributes={
135141
"commit": commit,
@@ -138,6 +144,19 @@ def set_build_info(self, commit: str, version: str, branch: str):
138144
},
139145
)
140146

147+
def start(
148+
self, function: str, module: str, track_concurrency: Optional[bool] = False
149+
):
150+
"""Start tracking metrics for a function call."""
151+
if track_concurrency:
152+
self.__up_down_counter_concurrency_instance.add(
153+
1.0,
154+
attributes={
155+
"function": function,
156+
"module": module,
157+
},
158+
)
159+
141160
def finish(
142161
self,
143162
start_time: float,
@@ -146,12 +165,22 @@ def finish(
146165
caller: str,
147166
result: Result = Result.OK,
148167
objective: Optional[Objective] = None,
168+
track_concurrency: Optional[bool] = False,
149169
):
150170
"""Finish tracking metrics for a function call."""
171+
151172
exemplar = None
152173
# Currently, exemplars are only supported by prometheus-client
153174
# https://github.com/autometrics-dev/autometrics-py/issues/41
154175
# if os.getenv("AUTOMETRICS_EXEMPLARS") == "true":
155176
# exemplar = get_exemplar()
156177
self.__count(function, module, caller, objective, exemplar, result)
157178
self.__histogram(function, module, start_time, objective, exemplar)
179+
if track_concurrency:
180+
self.__up_down_counter_concurrency_instance.add(
181+
-1.0,
182+
attributes={
183+
"function": function,
184+
"module": module,
185+
},
186+
)

0 commit comments

Comments
 (0)