Skip to content

Commit 8e51c95

Browse files
authored
feat(hip-3-pusher): improved metrics (#3243)
1 parent bd43ac9 commit 8e51c95

File tree

6 files changed

+123
-24
lines changed

6 files changed

+123
-24
lines changed

apps/hip-3-pusher/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "hip-3-pusher"
3-
version = "0.2.1"
3+
version = "0.2.3"
44
description = "Hyperliquid HIP-3 market oracle pusher"
55
readme = "README.md"
66
requires-python = "==3.13.*"

apps/hip-3-pusher/src/pusher/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def load_config():
2727
config_toml = tomllib.load(config_file)
2828
config = Config(**config_toml)
2929
logger.debug("Config loaded: {}", config)
30+
logger.info("Price config: {}", config.price)
3031
return config
3132

3233

@@ -44,6 +45,7 @@ async def main():
4445

4546
price_state = PriceState(config)
4647
metrics = Metrics(config)
48+
metrics.set_price_configs(config.hyperliquid.market_name, config.price)
4749

4850
publisher = Publisher(config, price_state, metrics)
4951
hyperliquid_listener = HyperliquidListener(config, price_state.hl_oracle_state, price_state.hl_mark_state, price_state.hl_mid_state)

apps/hip-3-pusher/src/pusher/metrics.py

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
from opentelemetry.metrics import get_meter_provider, set_meter_provider
44
from opentelemetry.sdk.metrics import MeterProvider
55

6-
from pusher.config import Config
6+
from pusher.config import Config, PriceConfig, PriceSourceConfig, ConstantSourceConfig, SingleSourceConfig, \
7+
PairSourceConfig, OracleMidAverageConfig, PriceSource
78

89
METER_NAME = "hip3pusher"
910

@@ -21,20 +22,62 @@ def __init__(self, config: Config):
2122
self._init_metrics()
2223

2324
def _init_metrics(self):
24-
self.no_oracle_price_counter = self.meter.create_counter(
25-
name="hip_3_pusher_no_oracle_price_count",
26-
description="Number of failed push attempts with no valid oracle price",
25+
# labels: dex, symbol
26+
self.last_pushed_time = self.meter.create_gauge(
27+
name="hip_3_relayer_last_published_time",
28+
description="Time of last successful oracle update",
2729
)
28-
self.successful_push_counter = self.meter.create_counter(
29-
name="hip_3_pusher_successful_push_count",
30-
description="Number of successful push attempts",
30+
# labels: dex, symbol, status, error_reason
31+
self.update_attempts_total = self.meter.create_counter(
32+
name="hip_3_relayer_update_attempts_total",
33+
description="Number of update attempts",
3134
)
32-
self.failed_push_counter = self.meter.create_counter(
33-
name="hip_3_pusher_failed_push_count",
34-
description="Number of failed push attempts",
35+
# labels: dex
36+
self.no_oracle_price_counter = self.meter.create_counter(
37+
name="hip_3_relayer_no_oracle_price_count",
38+
description="Number of failed push attempts with no valid oracle price",
3539
)
40+
# labels: dex
3641
self.push_interval_histogram = self.meter.create_histogram(
37-
name="hip_3_pusher_push_interval",
42+
name="hip_3_relayer_push_interval",
3843
description="Interval between push requests (seconds)",
3944
unit="s",
4045
)
46+
# labels: dex, price_type, symbol
47+
self.price_config_counter = self.meter.create_counter(
48+
name="hip_3_relayer_price_config",
49+
description="Price source config",
50+
)
51+
52+
def set_price_configs(self, dex: str, price_config: PriceConfig):
53+
self._set_price_config_type(dex, price_config.oracle, "oracle")
54+
self._set_price_config_type(dex, price_config.mark, "mark")
55+
self._set_price_config_type(dex, price_config.external, "external")
56+
57+
def _set_price_config_type(self, dex: str, price_source_config: dict[str, list[PriceSourceConfig]], price_type: str):
58+
for symbol in price_source_config:
59+
source_config_str = ' | '.join(self._get_source_config_str(source_config) for source_config in price_source_config[symbol])
60+
labels = {
61+
"dex": dex,
62+
"symbol": symbol,
63+
"price_type": price_type,
64+
"config": source_config_str,
65+
}
66+
self.price_config_counter.add(1, labels)
67+
68+
def _get_source_config_str(self, source_config: PriceSourceConfig):
69+
if isinstance(source_config, ConstantSourceConfig):
70+
return f"constant({source_config.value})"
71+
elif isinstance(source_config, SingleSourceConfig):
72+
return self._get_price_source_str(source_config.source)
73+
elif isinstance(source_config, PairSourceConfig):
74+
base_str = self._get_price_source_str(source_config.base_source)
75+
quote_str = self._get_price_source_str(source_config.quote_source)
76+
return f"pair({base_str},{quote_str})"
77+
elif isinstance(source_config, OracleMidAverageConfig):
78+
return f"oracle_mid_average({source_config.symbol})"
79+
else:
80+
return "unknown"
81+
82+
def _get_price_source_str(self, price_source: PriceSource):
83+
return f"{price_source.source_name}({str(price_source.source_id)[:8]})"

apps/hip-3-pusher/src/pusher/publisher.py

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
from enum import StrEnum
23
import time
34

45
from loguru import logger
@@ -16,6 +17,20 @@
1617
from pusher.price_state import PriceState
1718

1819

20+
class PushErrorReason(StrEnum):
21+
""" setOracle push failure modes """
22+
# 2.5s rate limit reject, expected with redundant relayers
23+
RATE_LIMIT = "rate_limit"
24+
# Per-account limit, need to purchase more transactions with reserveRequestWeight
25+
USER_LIMIT = "user_limit"
26+
# Some exception thrown internally
27+
INTERNAL_ERROR = "internal_error"
28+
# Invalid nonce, if the pusher account pushes multiple transactions with the same ms timestamp
29+
INVALID_NONCE = "invalid_nonce"
30+
# Some error string we haven't categorized yet
31+
UNKNOWN = "unknown"
32+
33+
1934
class Publisher:
2035
"""
2136
HIP-3 oracle publisher handler
@@ -51,6 +66,8 @@ def __init__(self, config: Config, price_state: PriceState, metrics: Metrics):
5166
if not config.multisig.multisig_address:
5267
raise Exception("Multisig enabled but missing multisig address")
5368
self.multisig_address = config.multisig.multisig_address
69+
else:
70+
self.multisig_address = None
5471

5572
self.market_name = config.hyperliquid.market_name
5673
self.enable_publish = config.hyperliquid.enable_publish
@@ -79,9 +96,6 @@ def publish(self):
7996
# markPxs is a list of dicts of length 0-2, and so can be empty
8097
mark_pxs = [mark_pxs] if mark_pxs else []
8198

82-
# TODO: "Each update can change oraclePx and markPx by at most 1%."
83-
# TODO: "The markPx cannot be updated such that open interest would be 10x the open interest cap."
84-
8599
if self.enable_publish:
86100
try:
87101
if self.enable_kms:
@@ -103,12 +117,13 @@ def publish(self):
103117
all_mark_pxs=mark_pxs,
104118
external_perp_pxs=external_perp_pxs,
105119
)
106-
self._handle_response(push_response)
120+
self._handle_response(push_response, list(oracle_pxs.keys()))
107121
except PushError:
108-
logger.error("All push attempts failed")
109-
self.metrics.failed_push_counter.add(1, self.metrics_labels)
122+
# since rate limiting is expected, don't necessarily log
123+
pass
110124
except Exception as e:
111125
logger.exception("Unexpected exception in push request: {}", repr(e))
126+
self._update_attempts_total("error", PushErrorReason.INTERNAL_ERROR, list(oracle_pxs.keys()))
112127
else:
113128
logger.debug("push disabled")
114129

@@ -128,14 +143,27 @@ def _send_update(self, oracle_pxs, all_mark_pxs, external_perp_pxs):
128143

129144
raise PushError("all push endpoints failed")
130145

131-
def _handle_response(self, response):
146+
def _handle_response(self, response, symbols: list[str]):
132147
logger.debug("oracle update response: {}", response)
133148
status = response.get("status")
134149
if status == "ok":
135-
self.metrics.successful_push_counter.add(1, self.metrics_labels)
150+
self._update_attempts_total("success", None, symbols)
151+
time_secs = int(time.time())
152+
153+
# update last publish time for each symbol in dex
154+
for symbol in symbols:
155+
labels = {**self.metrics_labels, "symbol": symbol}
156+
self.metrics.last_pushed_time.set(time_secs, labels)
157+
158+
# log any data in the ok response (likely price clamping issues)
159+
ok_data = response.get("response", {}).get("data")
160+
if ok_data:
161+
logger.info("ok response data: {}", ok_data)
136162
elif status == "err":
137-
self.metrics.failed_push_counter.add(1, self.metrics_labels)
138-
logger.error("oracle update error response: {}", response)
163+
error_reason = self._get_error_reason(response)
164+
self._update_attempts_total("error", error_reason, symbols)
165+
if error_reason != "rate_limit":
166+
logger.error("Error response: {}", response)
139167

140168
def _record_push_interval_metric(self):
141169
now = time.time()
@@ -183,3 +211,29 @@ def _send_single_multisig_update(self, exchange, oracle_pxs, all_mark_pxs, exter
183211
outer_signer=self.oracle_account.address,
184212
)]
185213
return exchange.multi_sig(self.multisig_address, action, signatures, timestamp)
214+
215+
def _update_attempts_total(self, status: str, error_reason: str | None, symbols: list[str]):
216+
labels = {**self.metrics_labels, "status": status}
217+
if error_reason:
218+
# don't flag rate limiting as this is expected with redundancy
219+
if error_reason == "rate_limit":
220+
return
221+
labels["error_reason"] = error_reason
222+
223+
for symbol in symbols:
224+
labels["symbol"] = symbol
225+
self.metrics.update_attempts_total.add(1, labels)
226+
227+
def _get_error_reason(self, response):
228+
response = response.get("response")
229+
if not response:
230+
return None
231+
elif "Oracle price update too often" in response:
232+
return PushErrorReason.RATE_LIMIT
233+
elif "Too many cumulative requests" in response:
234+
return PushErrorReason.USER_LIMIT
235+
elif "Invalid nonce" in response:
236+
return PushErrorReason.INVALID_NONCE
237+
else:
238+
logger.warning("Unrecognized error response: {}", response)
239+
return PushErrorReason.UNKNOWN

apps/hip-3-pusher/src/pusher/seda_listener.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class SedaListener:
1717
"""
1818
def __init__(self, config: Config, seda_state: PriceSourceState):
1919
self.url = config.seda.url
20-
self.api_key = Path(config.seda.api_key_path).read_text().strip()
20+
self.api_key = Path(config.seda.api_key_path).read_text().strip() if config.seda.api_key_path else None
2121
self.feeds = config.seda.feeds
2222
self.poll_interval = config.seda.poll_interval
2323
self.poll_failure_interval = config.seda.poll_failure_interval

apps/hip-3-pusher/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)