Skip to content

Commit

Permalink
Add concurrency tracking (#55)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
actualwitch and Brett Beutell authored Jun 23, 2023
1 parent 2412b13 commit 88164e7
Show file tree
Hide file tree
Showing 16 changed files with 1,094 additions and 15 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Added

- Exemplars support (#51)
- Optional concurrency tracking support (#55)

### Changed

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

### Fixed

- Fixed decorator async function handling (#55)

### Security

- Update requests, starlette, fastapi dependencies used by the examples
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ def sayHello:

```

- 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.

- To access the PromQL queries for your decorated functions, run `help(yourfunction)` or `print(yourfunction.__doc__)`.

- 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).
Expand Down
2 changes: 2 additions & 0 deletions examples/django_example/django_example/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@
"""
from django.urls import path

from .views.concurrency import ConcurrencyView
from .views.latency import RandomLatencyView
from .views.metrics import metrics
from .views.simple import simple_handler
from .views.error import ErrorOrOkView

urlpatterns = [
path("concurrency/", ConcurrencyView.as_view()),
path("latency/", RandomLatencyView.as_view()),
path("error/", ErrorOrOkView.as_view()),
path("simple/", simple_handler),
Expand Down
15 changes: 15 additions & 0 deletions examples/django_example/django_example/views/concurrency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import time
from autometrics import autometrics
from django.http import HttpResponse
from django.views import View


class ConcurrencyView(View):
"""Here you can see how concurrency tracking works in autometrics.
Just add the `track_concurrency=True` argument, and autometrics
will track the number of concurrent requests to this endpoint."""

@autometrics(track_concurrency=True)
def get(self, request):
time.sleep(0.25)
return HttpResponse("Many clients wait for a reply from this endpoint!")
4 changes: 2 additions & 2 deletions examples/django_example/django_example/views/latency.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ class RandomLatencyView(View):

@autometrics
def get(self, request):
duration = random.randint(1, 500)
duration = random.randint(1, 10)

time.sleep(duration / 1000)
time.sleep(duration / 10)

return HttpResponse("i was waiting for {}ms!".format(duration))
22 changes: 22 additions & 0 deletions examples/django_example/locustfile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import time
from locust import HttpUser, task, between


class DjangoUser(HttpUser):
wait_time = between(1, 2.5)

@task(10)
def visit_concurrency_handler(self):
self.client.get("/concurrency/")

@task
def visit_error_handler(self):
self.client.get("/error/")

@task
def visit_simple_handler(self):
self.client.get("/simple/")

@task
def visit_latency_handler(self):
self.client.get("/latency/")
15 changes: 15 additions & 0 deletions examples/django_example/run_example.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/bin/sh

export AUTOMETRICS_COMMIT=67a1b3a
export AUTOMETRICS_VERSION=0.1.0
export AUTOMETRICS_BRANCH=main
export AUTOMETRICS_TRACKER=prometheus

# run the server itself
poetry run python manage.py runserver 8080 &
# run the locust load test and pipe stdout to dev/null
poetry run locust --host=http://localhost:8080 --users=100 --headless &

# kill all child processes on exit
trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT
wait
839 changes: 838 additions & 1 deletion poetry.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "autometrics"
version = "0.5"
version = "0.6"
description = "Easily add metrics to your system – and actually understand them using automatically customized Prometheus queries"
authors = ["Fiberplane <[email protected]>"]
license = "MIT OR Apache-2.0"
Expand Down Expand Up @@ -72,6 +72,7 @@ urllib3 = "1.26.15"
uvicorn = "0.21.1"
webencodings = "0.5.1"
zipp = "3.15.0"
locust = "^2.15.1"

[build-system]
requires = ["poetry-core"]
Expand Down
4 changes: 4 additions & 0 deletions src/autometrics/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@

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


COUNTER_NAME_PROMETHEUS = COUNTER_NAME.replace(".", "_")
HISTOGRAM_NAME_PROMETHEUS = HISTOGRAM_NAME.replace(".", "_")
CONCURRENCY_NAME_PROMETHEUS = CONCURRENCY_NAME.replace(".", "_")

COUNTER_DESCRIPTION = "Autometrics counter for tracking function calls"
HISTOGRAM_DESCRIPTION = "Autometrics histogram for tracking function call duration"
CONCURRENCY_DESCRIPTION = "Autometrics gauge for tracking function call concurrency"
BUILD_INFO_DESCRIPTION = (
"Autometrics info metric for tracking software version and build details"
)
Expand Down
24 changes: 22 additions & 2 deletions src/autometrics/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,24 +28,33 @@ def autometrics(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:

# Decorator with arguments
@overload
def autometrics(*, objective: Optional[Objective] = None) -> Callable:
def autometrics(
*, objective: Optional[Objective] = None, track_concurrency: Optional[bool] = False
) -> Callable:
...


def autometrics(
func: Optional[Callable] = None,
*,
objective: Optional[Objective] = None,
track_concurrency: Optional[bool] = False,
):
"""Decorator for tracking function calls and duration. Supports synchronous and async functions."""

def track_start(function: str, module: str):
get_tracker().start(
function=function, module=module, track_concurrency=track_concurrency
)

def track_result_ok(start_time: float, function: str, module: str, caller: str):
get_tracker().finish(
start_time,
function=function,
module=module,
caller=caller,
objective=objective,
track_concurrency=track_concurrency,
result=Result.OK,
)

Expand All @@ -61,6 +70,7 @@ def track_result_error(
module=module,
caller=caller,
objective=objective,
track_concurrency=track_concurrency,
result=Result.ERROR,
)

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

try:
if track_concurrency:
track_start(module=module_name, function=func_name)
result = func(*args, **kwds)
track_result_ok(
start_time, function=func_name, module=module_name, caller=caller
Expand Down Expand Up @@ -110,6 +122,8 @@ async def async_wrapper(*args: P.args, **kwds: P.kwargs) -> T:
caller = get_caller_function()

try:
if track_concurrency:
track_start(module=module_name, function=func_name)
result = await func(*args, **kwds)
track_result_ok(
start_time, function=func_name, module=module_name, caller=caller
Expand All @@ -130,8 +144,14 @@ async def async_wrapper(*args: P.args, **kwds: P.kwargs) -> T:
async_wrapper.__doc__ = append_docs_to_docstring(func, func_name, module_name)
return async_wrapper

def pick_decorator(func: Callable) -> Callable:
"""Pick the correct decorator based on the function type."""
if inspect.iscoroutinefunction(func):
return async_decorator(func)
return sync_decorator(func)

if func is None:
return sync_decorator
return pick_decorator
elif inspect.iscoroutinefunction(func):
return async_decorator(func)
else:
Expand Down
44 changes: 44 additions & 0 deletions src/autometrics/test_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,50 @@ def test_objectives(self):
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}"}}"""
assert duration_sum in data

@pytest.mark.asyncio
async def test_objectives_async(self):
"""This is a test that covers objectives for async functions."""

# set up the function + objective variables
caller = get_caller_function(depth=1)
assert caller is not None
assert caller != ""
objective_name = "test_objective"
success_rate = ObjectivePercentile.P90
latency = (ObjectiveLatency.Ms100, ObjectivePercentile.P99)
objective = Objective(
name=objective_name, success_rate=success_rate, latency=latency
)
function_name = basic_async_function.__name__
wrapped_function = autometrics(objective=objective)(basic_async_function)

sleep_duration = 0.25

# Test that the function is *still* async after we wrapped it
assert asyncio.iscoroutinefunction(wrapped_function) == True

await wrapped_function(sleep_duration)

# get the metrics
blob = generate_latest()
assert blob is not None
data = blob.decode("utf-8")

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"""
assert total_count in data

# Check the latency buckets
for objective in ObjectiveLatency:
count = 0 if float(objective.value) <= sleep_duration else 1
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}"""
assert query in data

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}"}}"""
assert duration_count in data

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}"}}"""
assert duration_sum in data

def test_exception(self):
"""This is a test that covers exceptions."""
caller = get_caller_function(depth=1)
Expand Down
37 changes: 33 additions & 4 deletions src/autometrics/tracker/opentelemetry.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import os
import time
from typing import Optional

Expand All @@ -17,6 +16,8 @@
from .tracker import Result
from ..objectives import Objective, ObjectiveLatency
from ..constants import (
CONCURRENCY_NAME,
CONCURRENCY_DESCRIPTION,
COUNTER_DESCRIPTION,
COUNTER_NAME,
HISTOGRAM_DESCRIPTION,
Expand All @@ -41,7 +42,8 @@ class OpenTelemetryTracker:

__counter_instance: Counter
__histogram_instance: Histogram
__up_down_counter_instance: UpDownCounter
__up_down_counter_build_info_instance: UpDownCounter
__up_down_counter_concurrency_instance: UpDownCounter

def __init__(self):
exporter = PrometheusMetricReader("")
Expand All @@ -63,10 +65,14 @@ def __init__(self):
name=HISTOGRAM_NAME,
description=HISTOGRAM_DESCRIPTION,
)
self.__up_down_counter_instance = meter.create_up_down_counter(
self.__up_down_counter_build_info_instance = meter.create_up_down_counter(
name=BUILD_INFO_NAME,
description=BUILD_INFO_DESCRIPTION,
)
self.__up_down_counter_concurrency_instance = meter.create_up_down_counter(
name=CONCURRENCY_NAME,
description=CONCURRENCY_DESCRIPTION,
)
self._has_set_build_info = False

def __count(
Expand Down Expand Up @@ -129,7 +135,7 @@ def __histogram(
def set_build_info(self, commit: str, version: str, branch: str):
if not self._has_set_build_info:
self._has_set_build_info = True
self.__up_down_counter_instance.add(
self.__up_down_counter_build_info_instance.add(
1.0,
attributes={
"commit": commit,
Expand All @@ -138,6 +144,19 @@ def set_build_info(self, commit: str, version: str, branch: str):
},
)

def start(
self, function: str, module: str, track_concurrency: Optional[bool] = False
):
"""Start tracking metrics for a function call."""
if track_concurrency:
self.__up_down_counter_concurrency_instance.add(
1.0,
attributes={
"function": function,
"module": module,
},
)

def finish(
self,
start_time: float,
Expand All @@ -146,12 +165,22 @@ def finish(
caller: str,
result: Result = Result.OK,
objective: Optional[Objective] = None,
track_concurrency: Optional[bool] = False,
):
"""Finish tracking metrics for a function call."""

exemplar = None
# Currently, exemplars are only supported by prometheus-client
# https://github.com/autometrics-dev/autometrics-py/issues/41
# if os.getenv("AUTOMETRICS_EXEMPLARS") == "true":
# exemplar = get_exemplar()
self.__count(function, module, caller, objective, exemplar, result)
self.__histogram(function, module, start_time, objective, exemplar)
if track_concurrency:
self.__up_down_counter_concurrency_instance.add(
-1.0,
attributes={
"function": function,
"module": module,
},
)
Loading

0 comments on commit 88164e7

Please sign in to comment.