Skip to content

Commit 8ab2e0e

Browse files
authored
feat(reliability) Add /live and /ready probes (#90)
1 parent 7328160 commit 8ab2e0e

File tree

12 files changed

+1961
-1142
lines changed

12 files changed

+1961
-1142
lines changed

.github/actions/python-poetry/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ inputs:
99
poetry-version:
1010
required: false
1111
description: Poetry version
12-
default: "1.2.2"
12+
default: "2.1.4"
1313

1414
runs:
1515
using: composite

.github/workflows/build-and-push-image.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
steps:
1717
- uses: actions/checkout@v2
1818
- uses: ./.github/actions/python-poetry
19-
- uses: pre-commit/action@v2.0.3
19+
- uses: pre-commit/action@v3.0.1
2020

2121
run-tests:
2222
runs-on: ubuntu-latest

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
__pycache__/
22
*.py[cod]
33
*$py.class
4+
poetry.toml
45

56
.DS_Store
67
.envrc
78
.coverage
89

910
.env
10-
.vscode/
11+
.vscode/
12+
.idea/

.python-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.10
1+
3.11

Dockerfile

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
ARG APP_NAME=pyth-observer
44
ARG APP_PACKAGE=pyth_observer
55
ARG APP_PATH=/opt/$APP_NAME
6-
ARG PYTHON_VERSION=3.10.4
7-
ARG POETRY_VERSION=1.2.2
6+
ARG PYTHON_VERSION=3.11
7+
ARG POETRY_VERSION=2.1.4
88

99
#
1010
# Stage: base
1111
#
1212

13-
FROM python:$PYTHON_VERSION as base
13+
FROM python:$PYTHON_VERSION AS base
1414

1515
ARG APP_NAME
1616
ARG APP_PATH
@@ -27,8 +27,9 @@ ENV \
2727
POETRY_NO_INTERACTION=1
2828

2929
# Install Poetry - respects $POETRY_VERSION & $POETRY_HOME
30-
RUN curl -sSL https://install.python-poetry.org | python
30+
RUN curl -sSL https://install.python-poetry.org | python - --version $POETRY_VERSION
3131
ENV PATH="$POETRY_HOME/bin:$PATH"
32+
RUN which poetry && poetry --version
3233

3334
WORKDIR $APP_PATH
3435
COPY . .
@@ -37,7 +38,7 @@ COPY . .
3738
# Stage: development
3839
#
3940

40-
FROM base as development
41+
FROM base AS development
4142

4243
ARG APP_NAME
4344
ARG APP_PATH
@@ -54,20 +55,21 @@ CMD ["$APP_NAME"]
5455
# Stage: build
5556
#
5657

57-
FROM base as build
58+
FROM base AS build
5859

5960
ARG APP_NAME
6061
ARG APP_PATH
6162

6263
WORKDIR $APP_PATH
6364
RUN poetry build --format wheel
65+
RUN poetry self add poetry-plugin-export
6466
RUN poetry export --format requirements.txt --output constraints.txt --without-hashes
6567

6668
#
6769
# Stage: production
6870
#
6971

70-
FROM python:$PYTHON_VERSION as production
72+
FROM python:$PYTHON_VERSION AS production
7173

7274
ARG APP_NAME
7375
ARG APP_PATH

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,11 @@ To integrate Telegram events with the Observer, you need the Telegram group chat
6161

6262
Use this ID in the `publishers.yaml` configuration to correctly set up Telegram events.
6363

64+
## Health Endpoints
65+
66+
The Observer exposes HTTP endpoints for health checks, suitable for Kubernetes liveness and readiness probes:
67+
68+
- **Liveness probe**: `GET /live` always returns `200 OK` with body `OK`.
69+
- **Readiness probe**: `GET /ready` returns `200 OK` with body `OK` if the observer is ready, otherwise returns `503 Not Ready`.
70+
71+
By default, these endpoints are served on port 8080. You can use them in your Kubernetes deployment to monitor the application's health.

poetry.lock

Lines changed: 1810 additions & 1049 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
[tool.mypy]
2-
python_version = "3.10"
2+
python_version = "3.11"
33
ignore_missing_imports = true
44

55
[tool.poetry]
66
name = "pyth-observer"
7-
version = "0.3.5"
7+
version = "2.1.4"
88
description = "Alerts and stuff"
99
authors = []
1010
readme = "README.md"
@@ -28,6 +28,7 @@ types-pyyaml = "^6.0.12"
2828
types-pytz = "^2022.4.0.0"
2929
python-dotenv = "^1.0.1"
3030
numpy = "^2.1.3"
31+
cffi = "^1.17"
3132

3233

3334
[tool.poetry.group.dev.dependencies]
@@ -47,3 +48,6 @@ pyth-observer = "pyth_observer.cli:run"
4748
[build-system]
4849
requires = ["poetry-core"]
4950
build-backend = "poetry.core.masonry.api"
51+
52+
[tool.poetry.requires-plugins]
53+
poetry-plugin-export = ">=1.8"

pyth_observer/__init__.py

Lines changed: 86 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
)
1818
from throttler import Throttler
1919

20+
import pyth_observer.health_server as health_server
2021
from pyth_observer.check.price_feed import PriceFeedState
2122
from pyth_observer.check.publisher import PublisherState
2223
from pyth_observer.coingecko import Symbol, get_coingecko_prices
@@ -72,98 +73,106 @@ def __init__(
7273

7374
async def run(self):
7475
while True:
75-
logger.info("Running checks")
76-
77-
products = await self.get_pyth_products()
78-
coingecko_prices, coingecko_updates = await self.get_coingecko_prices()
79-
crosschain_prices = await self.get_crosschain_prices()
80-
81-
for product in products:
82-
# Skip tombstone accounts with blank metadata
83-
if "base" not in product.attrs:
84-
continue
85-
86-
if not product.first_price_account_key:
87-
continue
88-
89-
# For each product, we build a list of price feed states (one
90-
# for each price account) and a list of publisher states (one
91-
# for each publisher).
92-
states = []
93-
price_accounts = await self.get_pyth_prices(product)
94-
95-
crosschain_price = crosschain_prices.get(
96-
b58decode(product.first_price_account_key.key).hex(), None
97-
)
98-
99-
for _, price_account in price_accounts.items():
100-
# Handle potential None for min_publishers
101-
if (
102-
price_account.min_publishers is None
103-
# When min_publishers is high it means that the price is not production-ready
104-
# yet and it is still being tested. We need no alerting for these prices.
105-
or price_account.min_publishers >= 10
106-
):
76+
try:
77+
logger.info("Running checks")
78+
79+
products = await self.get_pyth_products()
80+
coingecko_prices, coingecko_updates = await self.get_coingecko_prices()
81+
crosschain_prices = await self.get_crosschain_prices()
82+
83+
health_server.observer_ready = True
84+
85+
for product in products:
86+
# Skip tombstone accounts with blank metadata
87+
if "base" not in product.attrs:
88+
continue
89+
90+
if not product.first_price_account_key:
10791
continue
10892

109-
# Ensure latest_block_slot is not None or provide a default value
110-
latest_block_slot = (
111-
price_account.slot if price_account.slot is not None else -1
93+
# For each product, we build a list of price feed states (one
94+
# for each price account) and a list of publisher states (one
95+
# for each publisher).
96+
states = []
97+
price_accounts = await self.get_pyth_prices(product)
98+
99+
crosschain_price = crosschain_prices.get(
100+
b58decode(product.first_price_account_key.key).hex(), None
112101
)
113102

114-
if not price_account.aggregate_price_status:
115-
raise RuntimeError("Price account status is missing")
116-
117-
if not price_account.aggregate_price_info:
118-
raise RuntimeError("Aggregate price info is missing")
119-
120-
states.append(
121-
PriceFeedState(
122-
symbol=product.attrs["symbol"],
123-
asset_type=product.attrs["asset_type"],
124-
schedule=MarketSchedule(product.attrs["schedule"]),
125-
public_key=price_account.key,
126-
status=price_account.aggregate_price_status,
127-
# this is the solana block slot when price account was fetched
128-
latest_block_slot=latest_block_slot,
129-
latest_trading_slot=price_account.last_slot,
130-
price_aggregate=price_account.aggregate_price_info.price,
131-
confidence_interval_aggregate=price_account.aggregate_price_info.confidence_interval,
132-
coingecko_price=coingecko_prices.get(product.attrs["base"]),
133-
coingecko_update=coingecko_updates.get(
134-
product.attrs["base"]
135-
),
136-
crosschain_price=crosschain_price,
103+
for _, price_account in price_accounts.items():
104+
# Handle potential None for min_publishers
105+
if (
106+
price_account.min_publishers is None
107+
# When min_publishers is high it means that the price is not production-ready
108+
# yet and it is still being tested. We need no alerting for these prices.
109+
or price_account.min_publishers >= 10
110+
):
111+
continue
112+
113+
# Ensure latest_block_slot is not None or provide a default value
114+
latest_block_slot = (
115+
price_account.slot if price_account.slot is not None else -1
137116
)
138-
)
139117

140-
for component in price_account.price_components:
141-
pub = self.publishers.get(component.publisher_key.key, None)
142-
publisher_name = (
143-
(pub.name if pub else "")
144-
+ f" ({component.publisher_key.key})"
145-
).strip()
118+
if not price_account.aggregate_price_status:
119+
raise RuntimeError("Price account status is missing")
120+
121+
if not price_account.aggregate_price_info:
122+
raise RuntimeError("Aggregate price info is missing")
123+
146124
states.append(
147-
PublisherState(
148-
publisher_name=publisher_name,
125+
PriceFeedState(
149126
symbol=product.attrs["symbol"],
150127
asset_type=product.attrs["asset_type"],
151128
schedule=MarketSchedule(product.attrs["schedule"]),
152-
public_key=component.publisher_key,
153-
confidence_interval=component.latest_price_info.confidence_interval,
154-
confidence_interval_aggregate=price_account.aggregate_price_info.confidence_interval,
155-
price=component.latest_price_info.price,
156-
price_aggregate=price_account.aggregate_price_info.price,
157-
slot=component.latest_price_info.pub_slot,
158-
aggregate_slot=price_account.last_slot,
129+
public_key=price_account.key,
130+
status=price_account.aggregate_price_status,
159131
# this is the solana block slot when price account was fetched
160132
latest_block_slot=latest_block_slot,
161-
status=component.latest_price_info.price_status,
162-
aggregate_status=price_account.aggregate_price_status,
133+
latest_trading_slot=price_account.last_slot,
134+
price_aggregate=price_account.aggregate_price_info.price,
135+
confidence_interval_aggregate=price_account.aggregate_price_info.confidence_interval,
136+
coingecko_price=coingecko_prices.get(
137+
product.attrs["base"]
138+
),
139+
coingecko_update=coingecko_updates.get(
140+
product.attrs["base"]
141+
),
142+
crosschain_price=crosschain_price,
163143
)
164144
)
165145

166-
await self.dispatch.run(states)
146+
for component in price_account.price_components:
147+
pub = self.publishers.get(component.publisher_key.key, None)
148+
publisher_name = (
149+
(pub.name if pub else "")
150+
+ f" ({component.publisher_key.key})"
151+
).strip()
152+
states.append(
153+
PublisherState(
154+
publisher_name=publisher_name,
155+
symbol=product.attrs["symbol"],
156+
asset_type=product.attrs["asset_type"],
157+
schedule=MarketSchedule(product.attrs["schedule"]),
158+
public_key=component.publisher_key,
159+
confidence_interval=component.latest_price_info.confidence_interval,
160+
confidence_interval_aggregate=price_account.aggregate_price_info.confidence_interval,
161+
price=component.latest_price_info.price,
162+
price_aggregate=price_account.aggregate_price_info.price,
163+
slot=component.latest_price_info.pub_slot,
164+
aggregate_slot=price_account.last_slot,
165+
# this is the solana block slot when price account was fetched
166+
latest_block_slot=latest_block_slot,
167+
status=component.latest_price_info.price_status,
168+
aggregate_status=price_account.aggregate_price_status,
169+
)
170+
)
171+
172+
await self.dispatch.run(states)
173+
except Exception as e:
174+
logger.error(f"Error in run loop: {e}")
175+
health_server.observer_ready = False
167176

168177
logger.debug("Sleeping...")
169178
await asyncio.sleep(5)

pyth_observer/cli.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from prometheus_client import start_http_server
99

1010
from pyth_observer import Observer, Publisher
11+
from pyth_observer.health_server import start_health_server
1112
from pyth_observer.models import ContactInfo
1213

1314

@@ -61,7 +62,11 @@ def run(config, publishers, coingecko_mapping, prometheus_port):
6162

6263
start_http_server(int(prometheus_port))
6364

64-
asyncio.run(observer.run())
65+
async def main():
66+
asyncio.create_task(start_health_server())
67+
await observer.run()
68+
69+
asyncio.run(main())
6570

6671

6772
logger.remove()

0 commit comments

Comments
 (0)