Skip to content

Commit b0e5961

Browse files
authored
Add LocalStack Prometheus Metrics extension (#92)
1 parent eeea50b commit b0e5961

19 files changed

Lines changed: 1553 additions & 0 deletions

prometheus/Makefile

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
VENV_BIN = python3 -m venv
2+
VENV_DIR ?= .venv
3+
VENV_ACTIVATE = $(VENV_DIR)/bin/activate
4+
VENV_RUN = . $(VENV_ACTIVATE)
5+
6+
venv: $(VENV_ACTIVATE)
7+
8+
$(VENV_ACTIVATE): pyproject.toml
9+
test -d .venv || $(VENV_BIN) .venv
10+
$(VENV_RUN); pip install --upgrade pip setuptools plux wheel
11+
$(VENV_RUN); pip install --upgrade black isort pyproject-flake8 flake8-black flake8-isort
12+
touch $(VENV_DIR)/bin/activate
13+
14+
clean:
15+
rm -rf .venv/
16+
rm -rf build/
17+
rm -rf .eggs/
18+
rm -rf *.egg-info/
19+
20+
lint: venv
21+
$(VENV_RUN); python -m pflake8 --show-source
22+
23+
format: venv
24+
$(VENV_RUN); python -m isort .; python -m black .
25+
26+
install: venv
27+
$(VENV_RUN); python -m pip install -e .[dev]
28+
29+
dist: venv
30+
$(VENV_RUN); python setup.py sdist bdist_wheel
31+
32+
publish: clean-dist venv dist
33+
$(VENV_RUN); pip install --upgrade twine; twine upload dist/*
34+
35+
clean-dist: clean
36+
rm -rf dist/
37+
38+
.PHONY: clean clean-dist dist install publish

prometheus/README.md

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# LocalStack Prometheus Metrics
2+
[![Install LocalStack Extension](https://localstack.cloud/gh/extension-badge.svg)](https://app.localstack.cloud/extensions/remote?url=git+https://github.com/localstack/localstack-extensions/#egg=localstack-extension-prometheus-metrics&subdirectory=prometheus)
3+
4+
Instruments, collects, and exposes LocalStack metrics via a [Prometheus](https://prometheus.io/) endpoint.
5+
6+
## Installing
7+
8+
```bash
9+
localstack extensions install localstack-extension-prometheus-metrics
10+
```
11+
12+
**Note**: This plugin only supports LocalStack `>=v4.2`
13+
14+
## Usage
15+
16+
Scrape metrics via the endpoint:
17+
```bash
18+
curl localhost.localstack.cloud:4566/_extension/metrics
19+
```
20+
21+
## Quickstart (Docker-Compose)
22+
23+
See the documentation on [Automating extension installation](https://docs.localstack.cloud/user-guide/extensions/managing-extensions/#automating-extensions-installation) for more details.
24+
25+
First, enable the extension by adding it to your LocalStack environment:
26+
27+
```yaml
28+
services:
29+
localstack:
30+
environment:
31+
- EXTENSION_AUTO_INSTALL=localstack-extension-prometheus-metrics
32+
```
33+
34+
Next, you'll need to spin up a Prometheus instance to run alongside your LocalStack container. A [configuration file](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#configuration-file) is required.
35+
36+
### Option 1: Using a Volume Mount (Recommended)
37+
38+
Create `prometheus_config.yml`:
39+
```yaml
40+
global:
41+
scrape_interval: 15s # Set the scrape interval to every 15 seconds
42+
scrape_timeout: 5s # Set the scrape request timeout to 5 seconds
43+
# Scrape configuration for LocalStack metrics
44+
scrape_configs:
45+
- job_name: 'localstack'
46+
static_configs:
47+
# Note: The target needs to match the LocalStack container name for the Prometheus container to resolve the endpoint.
48+
- targets: ['localstack:4566'] # Target the LocalStack Gateway.
49+
metrics_path: '/_extension/metrics' # Metrics are exposed via `/_extension/metrics` endpoint
50+
```
51+
52+
And mount it on startup in your `docker-compose.yml`:
53+
```yaml
54+
services:
55+
# ... LocalStack container should be defined
56+
prometheus:
57+
image: prom/prometheus
58+
ports:
59+
- "9090:9090"
60+
volumes:
61+
- "./prometheus_config.yml:/etc/prometheus/prometheus.yml"
62+
```
63+
64+
### Option 2: Inline Configuration
65+
66+
Using the Docker Compose top-level [configs](https://docs.docker.com/reference/compose-file/configs/):
67+
```yaml
68+
services:
69+
prometheus:
70+
image: prom/prometheus
71+
ports:
72+
- "9090:9090"
73+
configs:
74+
- source: prometheus_config
75+
target: /etc/prometheus/prometheus.yml
76+
77+
configs:
78+
prometheus_config:
79+
content: |
80+
global:
81+
scrape_interval: 15s
82+
scrape_timeout: 5s
83+
scrape_configs:
84+
- job_name: 'localstack'
85+
static_configs:
86+
- targets: ['localstack:4566']
87+
metrics_path: '/_extension/metrics'
88+
```
89+
90+
### Full Example
91+
92+
```yaml
93+
services:
94+
localstack:
95+
container_name: "${LOCALSTACK_DOCKER_NAME:-localstack-main}"
96+
image: localstack/localstack-pro # required for Pro
97+
ports:
98+
- "4566:4566" # LocalStack Gateway
99+
- "4510-4559:4510-4559" # external services port range
100+
- "443:443" # LocalStack HTTPS Gateway (Pro)
101+
environment:
102+
- LOCALSTACK_AUTH_TOKEN=${LOCALSTACK_AUTH_TOKEN:?} # required for Pro
103+
- DEBUG=${DEBUG:-0}
104+
- PERSISTENCE=${PERSISTENCE:-0}
105+
- EXTENSION_AUTO_INSTALL=localstack-extension-prometheus-metrics
106+
volumes:
107+
- "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack"
108+
- "/var/run/docker.sock:/var/run/docker.sock"
109+
110+
prometheus:
111+
image: prom/prometheus
112+
ports:
113+
- "9090:9090"
114+
volumes:
115+
- "./prometheus_config.yml:/etc/prometheus/prometheus.yml" # Assumes prometheus_config.yml exists in your CWD
116+
```
117+
118+
## Available Metrics
119+
120+
The Prometheus extension exposes various LocalStack metrics through the `/_extension/metrics` endpoint, including:
121+
- Request counts by service
122+
- Request latencies
123+
- Resource utilization
124+
- Error rates
125+
126+
For a complete list of available metrics, visit the endpoint directly at `localhost.localstack.cloud:4566/_extension/metrics` when LocalStack is running.
127+
128+
## Licensing
129+
130+
* [client_python](https://github.com/prometheus/client_python) is licensed under the Apache License version 2.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
name = "localstack_prometheus"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from localstack.extensions.api import http
2+
from prometheus_client.exposition import choose_encoder
3+
4+
5+
def retrieve_metrics(request: http.Request):
6+
"""Expose the Prometheus metrics"""
7+
_generate_latest_metrics, content_type = choose_encoder(
8+
request.headers.get("Content-Type", "")
9+
)
10+
data = _generate_latest_metrics()
11+
return http.Response(response=data, status=200, mimetype=content_type)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import logging
2+
3+
from localstack.aws.chain import (
4+
CompositeExceptionHandler,
5+
CompositeHandler,
6+
CompositeResponseHandler,
7+
)
8+
from localstack.extensions.api import Extension, http
9+
10+
from localstack_prometheus.expose import retrieve_metrics
11+
from localstack_prometheus.handler import RequestMetricsHandler, ResponseMetricsHandler
12+
from localstack_prometheus.instruments.patch import apply_poller_tracking_patches
13+
14+
LOG = logging.getLogger(__name__)
15+
16+
17+
class PrometheusMetricsExtension(Extension):
18+
name = "prometheus"
19+
20+
def on_extension_load(self):
21+
apply_poller_tracking_patches()
22+
LOG.debug("PrometheusMetricsExtension: extension is loaded")
23+
24+
def on_platform_start(self):
25+
LOG.debug("PrometheusMetricsExtension: localstack is starting")
26+
27+
def on_platform_ready(self):
28+
LOG.debug("PrometheusMetricsExtension: localstack is running")
29+
30+
def update_gateway_routes(self, router: http.Router[http.RouteHandler]):
31+
router.add("/_extension/metrics", retrieve_metrics)
32+
LOG.debug("Added /metrics endpoint for Prometheus metrics")
33+
34+
def update_request_handlers(self, handlers: CompositeHandler):
35+
# Prepend the RequestMetricsHandler to handlers ensuring it runs first
36+
handlers.handlers.insert(0, RequestMetricsHandler())
37+
38+
def update_response_handlers(self, handlers: CompositeResponseHandler):
39+
# Insert the ResponseMetricsHandler as the final handler in the chain.
40+
handlers.handlers.append(ResponseMetricsHandler())
41+
42+
def update_exception_handlers(self, handlers: CompositeExceptionHandler):
43+
# TODO
44+
pass
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import logging
2+
import time
3+
4+
from localstack.aws.api import RequestContext
5+
from localstack.aws.chain import Handler, HandlerChain
6+
from localstack.http import Response
7+
8+
from localstack_prometheus.metrics.core import (
9+
LOCALSTACK_IN_FLIGHT_REQUESTS_GAUGE,
10+
LOCALSTACK_REQUEST_PROCESSING_DURATION_SECONDS,
11+
)
12+
13+
LOG = logging.getLogger(__name__)
14+
15+
16+
class TimedRequestContext(RequestContext):
17+
start_time: float | None
18+
19+
20+
class RequestMetricsHandler(Handler):
21+
"""
22+
Handler that records the start time of incoming requests
23+
"""
24+
25+
def __call__(
26+
self, chain: HandlerChain, context: TimedRequestContext, response: Response
27+
):
28+
# Record the start time
29+
context.start_time = time.perf_counter()
30+
31+
# Do not record metrics if no service operation information is found
32+
if not context.service_operation:
33+
return
34+
35+
service, operation = context.service_operation
36+
LOCALSTACK_IN_FLIGHT_REQUESTS_GAUGE.labels(
37+
service=service, operation=operation
38+
).inc()
39+
40+
41+
class ResponseMetricsHandler(Handler):
42+
"""
43+
Handler that records metrics when a response is ready
44+
"""
45+
46+
def __call__(
47+
self, chain: HandlerChain, context: TimedRequestContext, response: Response
48+
):
49+
# Do not record metrics if no service operation information is found
50+
if not context.service_operation:
51+
return
52+
53+
service, operation = context.service_operation
54+
LOCALSTACK_IN_FLIGHT_REQUESTS_GAUGE.labels(
55+
service=service, operation=operation
56+
).dec()
57+
58+
# Do not record if response is None
59+
if response is None:
60+
return
61+
62+
# Do not record if no start_time attribute is found
63+
if not hasattr(context, "start_time") or context.start_time is None:
64+
return
65+
66+
duration = time.perf_counter() - context.start_time
67+
68+
if (ex := context.service_exception) is not None:
69+
status = ex.code
70+
else:
71+
status = "success"
72+
73+
status_code = str(response.status_code)
74+
75+
LOCALSTACK_REQUEST_PROCESSING_DURATION_SECONDS.labels(
76+
service=service,
77+
operation=operation,
78+
status=status,
79+
status_code=status_code,
80+
).observe(duration)

prometheus/localstack_prometheus/instruments/__init__.py

Whitespace-only changes.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import logging
2+
3+
from localstack.services.lambda_.event_source_mapping.pollers.dynamodb_poller import (
4+
DynamoDBPoller,
5+
)
6+
from localstack.services.lambda_.event_source_mapping.pollers.kinesis_poller import (
7+
KinesisPoller,
8+
)
9+
from localstack.services.lambda_.event_source_mapping.pollers.sqs_poller import (
10+
SqsPoller,
11+
)
12+
from localstack.services.lambda_.event_source_mapping.senders.lambda_sender import (
13+
LambdaSender,
14+
)
15+
from localstack.utils.patch import Patch, Patches
16+
17+
from localstack_prometheus.instruments.poller import tracked_poll_events
18+
from localstack_prometheus.instruments.sender import tracked_send_events
19+
from localstack_prometheus.instruments.sqs_poller import tracked_sqs_handle_messages
20+
from localstack_prometheus.instruments.stream_poller import tracked_get_records
21+
22+
LOG = logging.getLogger(__name__)
23+
24+
25+
def apply_poller_tracking_patches():
26+
"""Apply all poller metrics tracking patches in one call"""
27+
patches = Patches(
28+
[
29+
# Track entire poll_events function
30+
Patch.function(target=SqsPoller.poll_events, fn=tracked_poll_events),
31+
Patch.function(target=KinesisPoller.poll_events, fn=tracked_poll_events),
32+
Patch.function(target=DynamoDBPoller.poll_events, fn=tracked_poll_events),
33+
# Track when events get sent to the target lambda
34+
Patch.function(target=LambdaSender.send_events, fn=tracked_send_events),
35+
# TODO: Standardise a single abstract method that all Poller subclasses can use to fetch records
36+
# SQS-specific patches
37+
Patch.function(
38+
target=SqsPoller.handle_messages, fn=tracked_sqs_handle_messages
39+
),
40+
# Stream-specific patches
41+
Patch.function(target=KinesisPoller.get_records, fn=tracked_get_records),
42+
Patch.function(target=DynamoDBPoller.get_records, fn=tracked_get_records),
43+
# TODO: How should KafkaPollers be handled?
44+
]
45+
)
46+
47+
# TODO: Investigate patching subclasses of Poller and Sender to ensure all children have changes
48+
# since currently, Pipes Senders and Kafka Pollers are unsupported.
49+
50+
patches.apply()
51+
LOG.debug("Applied all poller event and latency tracking patches")
52+
return patches

0 commit comments

Comments
 (0)