Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

prometheus extra #213

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 101 additions & 125 deletions poetry.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ streamlit-card = ">=0.0.4"
markdownlit = ">=0.0.5"
streamlit-image-coordinates = "^0.1.1"
entrypoints = ">=0.4"
prometheus-client = ">=0.14.0"

[tool.poetry.group.dev.dependencies]
pytest = "^7.1.3"
Expand Down
139 changes: 139 additions & 0 deletions src/streamlit_extras/prometheus/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
from typing import List, NamedTuple

from prometheus_client import CollectorRegistry
from prometheus_client.openmetrics.exposition import generate_latest
from streamlit.runtime.stats import CacheStatsProvider
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this import something that's been valid for a few releases of streamlit already? We don't pin streamlit to anything else then > 1.0.0 so if this was introduced only in 1.31.0, that may break for others. LMK

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I read **Note:** This extra works best with Streamlit >= 1.31. There are known bugs with some earlier Streamlit versions, especially 1.30.0., just curious if it would bug or break with prior)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this import has worked since 1.12.

There was a bug in Streamlit's native implementation of Prometheus since before 1.12 -> 1.30 where it printed duplicate metric rows that breaks the protocol format. I think in practice most collectors would be able to handle it but it might throw a warning or something (and we never surfaced the endpoint for real use so no one complained). So this collector would work prior to 1.30 but the Streamlit portion might be slightly weird (I think there's a hacky way to fix it if anyone complains about that, I could do another PR).

Karen fixed that issue in 1.30 streamlit/streamlit#7921 in a way that totally broke this prometheus collector. So the only version that won't work at all is 1.30. Then he fixed it to be compatible with this AND have the bug fix in 1.31.

I think it's OK with the note but LMK if you think we should handle another way!

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for those precisions. I was mostly being careful for ImportErrors, but 1.12 is already a while back!
Let's do it!


from .. import extra


class CustomStat(NamedTuple):
metric_str: str = ""

def to_metric_str(self) -> str:
return self.metric_str

def marshall_metric_proto(self, metric) -> None:
"""Custom OpenMetrics collected via protobuf is not currently supported."""
# Included to be compatible with the RequestHandler's _stats_to_proto() method:
# https://github.com/streamlit/streamlit/blob/1.29.0/lib/streamlit/web/server/stats_request_handler.py#L73
# Fill in dummy values so protobuf format isn't broken
label = metric.labels.add()
label.name = "cache_type"
label.value = "custom_metrics"

label = metric.labels.add()
label.name = "cache"
label.value = "not_implemented"

metric_point = metric.metric_points.add()
metric_point.gauge_value.int_value = 0


class PrometheusMetricsProvider(CacheStatsProvider):
def __init__(self, registry: CollectorRegistry):
self.registry = registry

def get_stats(self) -> List[CustomStat]:
sfc-gh-jcarroll marked this conversation as resolved.
Show resolved Hide resolved
"""
Use generate_latest() method provided by prometheus to produce the
appropriately formatted OpenMetrics text encoding for all the stored metrics.

Then do a bit of string manipulation to package it in the format expected
by Streamlit's stats handler, so the final output looks the way we expect.
"""
DUPLICATE_SUFFIX = "\n# EOF\n"
output_str = generate_latest(self.registry).decode(encoding="utf-8")
if not output_str.endswith(DUPLICATE_SUFFIX):
raise ValueError("Unexpected output from OpenMetrics text encoding")
output = CustomStat(metric_str=output_str[: -len(DUPLICATE_SUFFIX)])
return [output]


@extra
def streamlit_registry() -> CollectorRegistry:
"""
Expose Prometheus metrics (https://prometheus.io) from your Streamlit app.

Create and use Prometheus metrics in your app with `registry=streamlit_registry()`.
The metrics will be exposed at Streamlit's existing `/_stcore/metrics` route.

**Note:** This extra works best with Streamlit >= 1.31. There are known bugs with
some earlier Streamlit versions, especially 1.30.0.

See more example metrics in the Prometheus Python docs:
https://prometheus.github.io/client_python/

To produce accurate metrics, you are responsible to ensure that unique metric
objects are shared across app runs and sessions. We recommend either 1) initialize
metrics in a separate file and import them in the main app script, or 2) initialize
metrics in a cached function (and ensure the cache is not cleared during execution).

For an app running locally you can view the output with
`curl localhost:8501/_stcore/metrics` or equivalent.
sfc-gh-jcarroll marked this conversation as resolved.
Show resolved Hide resolved
"""
from streamlit import runtime

stats = runtime.get_instance().stats_mgr

# Did we already register it elsewhere? If so, return that copy
for prv in stats._cache_stats_providers:
if isinstance(prv, PrometheusMetricsProvider):
return prv.registry

# This is the function was called, so create the registry
# and hook it into Streamlit stats
registry = CollectorRegistry(auto_describe=True)
prv = PrometheusMetricsProvider(registry=registry)
stats.register_provider(prv)
return registry


def example():
import streamlit as st
from prometheus_client import Counter

@st.cache_resource
def get_metric():
registry = streamlit_registry()
return Counter(
name="my_counter",
documentation="A cool counter",
labelnames=("app_name",),
registry=registry, # important!!
)

SLIDER_COUNT = get_metric()

app_name = st.text_input("App name", "prometheus_app")
latest = st.slider("Latest value", 0, 20, 3)
if st.button("Submit"):
SLIDER_COUNT.labels(app_name).inc(latest)

st.write(
"""
View a fuller example that uses the (safer) import metrics method at:
https://github.com/arnaudmiribel/streamlit-extras/tree/main/src/streamlit_extras/prometheus/example
"""
)

st.write(
"""
### Example output at `{host:port}/_stcore/metrics`
```
# TYPE my_counter counter
# HELP my_counter A cool counter
my_counter_total{app_name="prometheus_app"} 14.0
my_counter_created{app_name="prometheus_app"} 1.7042185907557938e+09
```
"""
)


__title__ = "Prometheus"
__desc__ = "Expose Prometheus metrics (https://prometheus.io) from your Streamlit app."
__icon__ = "📊"
__examples__ = [example]
__author__ = "Joshua Carroll"
__experimental_playground__ = False
__stlite__ = False
13 changes: 13 additions & 0 deletions src/streamlit_extras/prometheus/example/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Streamlit Prometheus example

This repo has a simple example of a Streamlit app that exposes prometheus metrics via `streamlit_extras.prometheus`.

It demonstrates the approach of creating metrics in a separate file and importing them into your main app file
for object persistence across runs.

## Running the example

```sh
pip install streamlit-extras
streamlit run app.py
```
28 changes: 28 additions & 0 deletions src/streamlit_extras/prometheus/example/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import time
from random import random

import streamlit as st
from metrics import EXEC_TIME, SLIDER_COUNT

st.header("Streamlit app with Prometheus metrics")

"""
1. Enter an app name and slider value and press Submit
1. At a terminal, do `curl localhost:8501/_stcore/metrics` to view the metrics generated
1. Note you can run this across multiple sessions and it aggregates the counter
"""

start_time = time.time()


app_name = st.text_input("App name", "prometheus_app")
latest = st.slider("Latest value", 0, 20, 3)
if st.button("Submit"):
SLIDER_COUNT.labels(app_name).inc(latest)
st.toast("Successfully submitted")

# Add a little variability to the response
time.sleep(random() / 2)
exec_time = time.time() - start_time
st.write(f"Exec time was {exec_time}")
EXEC_TIME.labels("main").observe(exec_time)
18 changes: 18 additions & 0 deletions src/streamlit_extras/prometheus/example/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from prometheus_client import Counter, Histogram
from streamlit_extras.prometheus import streamlit_registry

registry = streamlit_registry()

SLIDER_COUNT = Counter(
name="slider_count",
documentation="Total submitted count of the slider",
labelnames=("app_name",),
registry=registry,
)

EXEC_TIME = Histogram(
name="page_exec_seconds",
documentation="Execution time of each page run",
labelnames=("page",),
registry=registry,
)
Loading