arize-phoenix 11.3.0__py3-none-any.whl → 11.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arize-phoenix might be problematic. Click here for more details.

Files changed (42) hide show
  1. {arize_phoenix-11.3.0.dist-info → arize_phoenix-11.5.0.dist-info}/METADATA +4 -2
  2. {arize_phoenix-11.3.0.dist-info → arize_phoenix-11.5.0.dist-info}/RECORD +42 -40
  3. phoenix/config.py +51 -2
  4. phoenix/db/facilitator.py +15 -24
  5. phoenix/server/api/auth.py +1 -1
  6. phoenix/server/api/queries.py +34 -22
  7. phoenix/server/api/routers/v1/annotation_configs.py +4 -1
  8. phoenix/server/api/routers/v1/datasets.py +3 -1
  9. phoenix/server/api/routers/v1/evaluations.py +3 -1
  10. phoenix/server/api/routers/v1/experiment_evaluations.py +3 -2
  11. phoenix/server/api/routers/v1/experiment_runs.py +3 -1
  12. phoenix/server/api/routers/v1/experiments.py +3 -1
  13. phoenix/server/api/routers/v1/projects.py +4 -1
  14. phoenix/server/api/routers/v1/prompts.py +4 -1
  15. phoenix/server/api/routers/v1/spans.py +4 -1
  16. phoenix/server/api/routers/v1/traces.py +4 -1
  17. phoenix/server/api/routers/v1/users.py +2 -2
  18. phoenix/server/app.py +41 -2
  19. phoenix/server/authorization.py +9 -0
  20. phoenix/server/bearer_auth.py +18 -15
  21. phoenix/server/cost_tracking/model_cost_manifest.json +2343 -587
  22. phoenix/server/daemons/db_disk_usage_monitor.py +209 -0
  23. phoenix/server/email/sender.py +25 -0
  24. phoenix/server/email/templates/db_disk_usage_notification.html +16 -0
  25. phoenix/server/email/types.py +11 -0
  26. phoenix/server/grpc_server.py +3 -3
  27. phoenix/server/prometheus.py +22 -0
  28. phoenix/server/static/.vite/manifest.json +36 -36
  29. phoenix/server/static/assets/{components-DlL7ybQ2.js → components-Bwf6zNbg.js} +186 -187
  30. phoenix/server/static/assets/{index-QP9R8k34.js → index-Bfg9uQ43.js} +2 -2
  31. phoenix/server/static/assets/{pages-B7wCtpad.js → pages-BCR8hW_l.js} +447 -431
  32. phoenix/server/static/assets/{vendor-DqQvHbPa.js → vendor-DRWIRkSJ.js} +1 -1
  33. phoenix/server/static/assets/{vendor-arizeai-CLX44PFA.js → vendor-arizeai-DUhQaeau.js} +2 -2
  34. phoenix/server/static/assets/{vendor-codemirror-Du3XyJnB.js → vendor-codemirror-D_6Q6Auv.js} +1 -1
  35. phoenix/server/static/assets/{vendor-recharts-B2PJDrnX.js → vendor-recharts-BNBwj7vz.js} +1 -1
  36. phoenix/server/static/assets/{vendor-shiki-CNbrFjf9.js → vendor-shiki-k1qj_XjP.js} +1 -1
  37. phoenix/server/types.py +7 -0
  38. phoenix/version.py +1 -1
  39. {arize_phoenix-11.3.0.dist-info → arize_phoenix-11.5.0.dist-info}/WHEEL +0 -0
  40. {arize_phoenix-11.3.0.dist-info → arize_phoenix-11.5.0.dist-info}/entry_points.txt +0 -0
  41. {arize_phoenix-11.3.0.dist-info → arize_phoenix-11.5.0.dist-info}/licenses/IP_NOTICE +0 -0
  42. {arize_phoenix-11.3.0.dist-info → arize_phoenix-11.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,209 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from asyncio import sleep
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Optional
7
+
8
+ import sqlalchemy as sa
9
+ from email_validator import EmailNotValidError, validate_email
10
+ from sqlalchemy import text
11
+ from typing_extensions import assert_never
12
+
13
+ from phoenix.config import (
14
+ get_env_database_allocated_storage_capacity_gibibytes,
15
+ get_env_database_usage_email_warning_threshold_percentage,
16
+ get_env_database_usage_insertion_blocking_threshold_percentage,
17
+ )
18
+ from phoenix.db import models
19
+ from phoenix.db.helpers import SupportedSQLDialect
20
+ from phoenix.server.email.types import DbUsageWarningEmailSender
21
+ from phoenix.server.prometheus import (
22
+ DB_DISK_USAGE_BYTES,
23
+ DB_DISK_USAGE_RATIO,
24
+ DB_DISK_USAGE_WARNING_EMAIL_ERRORS,
25
+ DB_DISK_USAGE_WARNING_EMAILS_SENT,
26
+ DB_INSERTIONS_BLOCKED,
27
+ )
28
+ from phoenix.server.types import DaemonTask, DbSessionFactory
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ _SLEEP_SECONDS = 60
33
+ _EMAIL_FREQUENCY_HOURS = 24
34
+ _BYTES_PER_GIBIBYTE = 1024**3
35
+
36
+
37
+ class DbDiskUsageMonitor(DaemonTask):
38
+ """
39
+ Monitors database disk space usage and triggers warnings/blocking when thresholds are exceeded.
40
+
41
+ This daemon:
42
+ - Periodically checks current database size
43
+ - Compares usage against configured thresholds
44
+ - Sends warning emails to admins when warning threshold is reached
45
+ - Toggles insertion blocking when blocking threshold is reached
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ db: DbSessionFactory,
51
+ email_sender: Optional[DbUsageWarningEmailSender] = None,
52
+ ) -> None:
53
+ super().__init__()
54
+ self._db = db
55
+ self._email_sender = email_sender
56
+ # Tracks last email send time per admin email address to prevent spam
57
+ self._last_email_sent: dict[str, datetime] = {}
58
+
59
+ @property
60
+ def _is_disabled(self) -> bool:
61
+ return not bool(
62
+ get_env_database_allocated_storage_capacity_gibibytes()
63
+ and (
64
+ get_env_database_usage_email_warning_threshold_percentage()
65
+ or get_env_database_usage_insertion_blocking_threshold_percentage()
66
+ )
67
+ )
68
+
69
+ async def _run(self) -> None:
70
+ if self._is_disabled:
71
+ return
72
+
73
+ while self._running:
74
+ try:
75
+ current_usage_bytes = await self._check_disk_usage_bytes()
76
+ except Exception:
77
+ logger.exception("Failed to check disk space")
78
+ else:
79
+ DB_DISK_USAGE_BYTES.set(current_usage_bytes)
80
+ current_usage_gibibytes = current_usage_bytes / _BYTES_PER_GIBIBYTE
81
+ try:
82
+ await self._check_thresholds(current_usage_gibibytes)
83
+ except Exception:
84
+ logger.exception("Failed to check database usage thresholds")
85
+ await sleep(_SLEEP_SECONDS)
86
+
87
+ async def _check_disk_usage_bytes(self) -> float:
88
+ if self._db.dialect is SupportedSQLDialect.SQLITE:
89
+ async with self._db() as session:
90
+ page_count = await session.scalar(text("PRAGMA page_count;"))
91
+ freelist_count = await session.scalar(text("PRAGMA freelist_count;"))
92
+ page_size = await session.scalar(text("PRAGMA page_size;"))
93
+ current_usage_bytes = (page_count - freelist_count) * page_size
94
+ elif self._db.dialect is SupportedSQLDialect.POSTGRESQL:
95
+ async with self._db() as session:
96
+ current_usage_bytes = await session.scalar(
97
+ text("SELECT pg_database_size(current_database());")
98
+ )
99
+ else:
100
+ assert_never(self._db.dialect)
101
+ if not isinstance(current_usage_bytes, (int, float)):
102
+ raise TypeError(f"Expected int or float, got {type(current_usage_bytes)}")
103
+ return float(current_usage_bytes)
104
+
105
+ async def _check_thresholds(self, current_usage_gibibytes: float) -> None:
106
+ allocated_capacity_gibibytes = get_env_database_allocated_storage_capacity_gibibytes()
107
+ if not allocated_capacity_gibibytes:
108
+ return
109
+
110
+ used_ratio = current_usage_gibibytes / allocated_capacity_gibibytes
111
+ DB_DISK_USAGE_RATIO.set(used_ratio)
112
+ used_percentage = used_ratio * 100
113
+
114
+ # Check insertion blocking threshold
115
+ if (
116
+ insertion_blocking_threshold_percentage
117
+ := get_env_database_usage_insertion_blocking_threshold_percentage()
118
+ ):
119
+ should_not_insert_or_update = used_percentage > insertion_blocking_threshold_percentage
120
+ self._db.should_not_insert_or_update = should_not_insert_or_update
121
+ DB_INSERTIONS_BLOCKED.set(int(should_not_insert_or_update))
122
+
123
+ # Check warning email threshold
124
+ if (
125
+ notification_threshold_percentage
126
+ := get_env_database_usage_email_warning_threshold_percentage()
127
+ ):
128
+ if used_percentage > notification_threshold_percentage:
129
+ await self._send_warning_emails(
130
+ used_percentage,
131
+ allocated_capacity_gibibytes,
132
+ notification_threshold_percentage,
133
+ )
134
+
135
+ async def _send_warning_emails(
136
+ self,
137
+ used_percentage: float,
138
+ allocated_capacity_gibibytes: float,
139
+ notification_threshold_percentage: float,
140
+ ) -> None:
141
+ if not self._email_sender:
142
+ return
143
+
144
+ current_usage_gibibytes = used_percentage / 100 * allocated_capacity_gibibytes
145
+ stmt = (
146
+ sa.select(models.User.email)
147
+ .join(models.UserRole)
148
+ .where(models.UserRole.name == "ADMIN")
149
+ )
150
+
151
+ try:
152
+ async with self._db() as session:
153
+ admin_emails = (await session.scalars(stmt)).all()
154
+ except Exception:
155
+ logger.exception(
156
+ "Failed to fetch admin emails from database, "
157
+ "skipping database usage warning emails"
158
+ )
159
+ return
160
+
161
+ if not admin_emails:
162
+ return
163
+
164
+ # Validate email addresses
165
+ valid_emails: list[str] = []
166
+
167
+ for email in admin_emails:
168
+ try:
169
+ normalized_email = validate_email(email, check_deliverability=False).normalized
170
+ except EmailNotValidError:
171
+ pass
172
+ else:
173
+ valid_emails.append(normalized_email)
174
+
175
+ if not valid_emails:
176
+ return
177
+
178
+ self._last_email_sent = {
179
+ email: timestamp
180
+ for email, timestamp in self._last_email_sent.items()
181
+ if email in valid_emails
182
+ }
183
+
184
+ now = datetime.now(timezone.utc)
185
+ emails_sent = 0
186
+ send_attempts = 0
187
+
188
+ for email in valid_emails:
189
+ if email in self._last_email_sent and now - self._last_email_sent[email] < timedelta(
190
+ hours=_EMAIL_FREQUENCY_HOURS
191
+ ):
192
+ continue
193
+ send_attempts += 1
194
+ try:
195
+ await self._email_sender.send_db_usage_warning_email(
196
+ email=email,
197
+ current_usage_gibibytes=current_usage_gibibytes,
198
+ allocated_storage_gibibytes=allocated_capacity_gibibytes,
199
+ notification_threshold_percentage=notification_threshold_percentage,
200
+ )
201
+ except Exception:
202
+ logger.exception(f"Failed to send database usage warning email to {email}")
203
+ # Count email send errors
204
+ DB_DISK_USAGE_WARNING_EMAIL_ERRORS.inc()
205
+ else:
206
+ self._last_email_sent[email] = now
207
+ emails_sent += 1
208
+ # Count successful warning email sends
209
+ DB_DISK_USAGE_WARNING_EMAILS_SENT.inc()
@@ -81,6 +81,31 @@ class SimpleEmailSender:
81
81
 
82
82
  await to_thread.run_sync(self._send_email, msg)
83
83
 
84
+ async def send_db_usage_warning_email(
85
+ self,
86
+ email: str,
87
+ current_usage_gibibytes: float,
88
+ allocated_storage_gibibytes: float,
89
+ notification_threshold_percentage: float,
90
+ ) -> None:
91
+ subject = "[Phoenix] Database Usage Threshold Exceeded"
92
+ template_name = "db_disk_usage_notification.html"
93
+
94
+ template = self.env.get_template(template_name)
95
+ html_content = template.render(
96
+ current_usage_gibibytes=current_usage_gibibytes,
97
+ allocated_storage_gibibytes=allocated_storage_gibibytes,
98
+ notification_threshold_percentage=notification_threshold_percentage,
99
+ )
100
+
101
+ msg = EmailMessage()
102
+ msg["Subject"] = subject
103
+ msg["From"] = self.sender_email
104
+ msg["To"] = email
105
+ msg.set_content(html_content, subtype="html")
106
+
107
+ await to_thread.run_sync(self._send_email, msg)
108
+
84
109
  def _send_email(self, msg: EmailMessage) -> None:
85
110
  context: ssl.SSLContext
86
111
  if self.validate_certs:
@@ -0,0 +1,16 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>Database Usage Notification</title>
6
+ </head>
7
+ <body>
8
+ <h1>Database Usage Notification</h1>
9
+ <p>Your Phoenix database usage has exceeded the notification threshold.</p>
10
+ <p><strong>Current Usage:</strong> {{ current_usage_gibibytes|round(1) }} GiB</p>
11
+ <p><strong>Allocated Storage:</strong> {{ allocated_storage_gibibytes|round(1) }} GiB</p>
12
+ <p><strong>Usage Percentage:</strong> {{ ((current_usage_gibibytes / allocated_storage_gibibytes) * 100)|round(1) }}%</p>
13
+ <p><strong>Notification Threshold:</strong> {{ notification_threshold_percentage }}%</p>
14
+ <p>Please consider removing old data or increasing your storage allocation to prevent interruption.</p>
15
+ </body>
16
+ </html>
@@ -19,8 +19,19 @@ class PasswordResetEmailSender(Protocol):
19
19
  ) -> None: ...
20
20
 
21
21
 
22
+ class DbUsageWarningEmailSender(Protocol):
23
+ async def send_db_usage_warning_email(
24
+ self,
25
+ email: str,
26
+ current_usage_gibibytes: float,
27
+ allocated_storage_gibibytes: float,
28
+ notification_threshold_percentage: float,
29
+ ) -> None: ...
30
+
31
+
22
32
  class EmailSender(
23
33
  WelcomeEmailSender,
24
34
  PasswordResetEmailSender,
35
+ DbUsageWarningEmailSender,
25
36
  Protocol,
26
37
  ): ...
@@ -1,5 +1,5 @@
1
1
  from collections.abc import Awaitable, Callable
2
- from typing import TYPE_CHECKING, Any, Optional
2
+ from typing import TYPE_CHECKING, Any, Iterable, Optional
3
3
 
4
4
  import grpc
5
5
  from grpc.aio import RpcContext, Server, ServerInterceptor
@@ -61,7 +61,7 @@ class GrpcServer:
61
61
  enable_prometheus: bool = False,
62
62
  disabled: bool = False,
63
63
  token_store: Optional[CanReadToken] = None,
64
- interceptors: list[ServerInterceptor] = [],
64
+ interceptors: Iterable[ServerInterceptor] = (),
65
65
  ) -> None:
66
66
  self._callback = callback
67
67
  self._server: Optional[Server] = None
@@ -69,7 +69,7 @@ class GrpcServer:
69
69
  self._enable_prometheus = enable_prometheus
70
70
  self._disabled = disabled
71
71
  self._token_store = token_store
72
- self._interceptors = interceptors
72
+ self._interceptors = list(interceptors)
73
73
 
74
74
  async def __aenter__(self) -> None:
75
75
  interceptors = self._interceptors
@@ -73,6 +73,28 @@ JWT_STORE_API_KEYS_ACTIVE = Gauge(
73
73
  documentation="Current number of API keys in the JWT store",
74
74
  )
75
75
 
76
+ DB_DISK_USAGE_BYTES = Gauge(
77
+ name="database_disk_usage_bytes",
78
+ documentation="Current database disk usage in bytes",
79
+ )
80
+ DB_DISK_USAGE_RATIO = Gauge(
81
+ name="database_disk_usage_ratio",
82
+ documentation="Current database disk usage as ratio of allocated capacity (0-1)",
83
+ )
84
+ DB_INSERTIONS_BLOCKED = Gauge(
85
+ name="database_insertions_blocked",
86
+ documentation="Whether database insertions are currently blocked due to disk usage "
87
+ "(1 = blocked, 0 = not blocked)",
88
+ )
89
+ DB_DISK_USAGE_WARNING_EMAILS_SENT = Counter(
90
+ name="database_disk_usage_warning_emails_sent_total",
91
+ documentation="Total count of database disk usage warning emails sent",
92
+ )
93
+ DB_DISK_USAGE_WARNING_EMAIL_ERRORS = Counter(
94
+ name="database_disk_usage_warning_email_errors_total",
95
+ documentation="Total count of database disk usage warning email send errors",
96
+ )
97
+
76
98
 
77
99
  class PrometheusMiddleware(BaseHTTPMiddleware):
78
100
  async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
@@ -1,28 +1,28 @@
1
1
  {
2
- "_components-DlL7ybQ2.js": {
3
- "file": "assets/components-DlL7ybQ2.js",
2
+ "_components-Bwf6zNbg.js": {
3
+ "file": "assets/components-Bwf6zNbg.js",
4
4
  "name": "components",
5
5
  "imports": [
6
- "_vendor-DqQvHbPa.js",
7
- "_pages-B7wCtpad.js",
8
- "_vendor-arizeai-CLX44PFA.js",
9
- "_vendor-codemirror-Du3XyJnB.js",
6
+ "_vendor-DRWIRkSJ.js",
7
+ "_pages-BCR8hW_l.js",
8
+ "_vendor-arizeai-DUhQaeau.js",
9
+ "_vendor-codemirror-D_6Q6Auv.js",
10
10
  "_vendor-three-C5WAXd5r.js"
11
11
  ]
12
12
  },
13
- "_pages-B7wCtpad.js": {
14
- "file": "assets/pages-B7wCtpad.js",
13
+ "_pages-BCR8hW_l.js": {
14
+ "file": "assets/pages-BCR8hW_l.js",
15
15
  "name": "pages",
16
16
  "imports": [
17
- "_vendor-DqQvHbPa.js",
18
- "_vendor-arizeai-CLX44PFA.js",
19
- "_components-DlL7ybQ2.js",
20
- "_vendor-codemirror-Du3XyJnB.js",
21
- "_vendor-recharts-B2PJDrnX.js"
17
+ "_vendor-DRWIRkSJ.js",
18
+ "_vendor-arizeai-DUhQaeau.js",
19
+ "_components-Bwf6zNbg.js",
20
+ "_vendor-codemirror-D_6Q6Auv.js",
21
+ "_vendor-recharts-BNBwj7vz.js"
22
22
  ]
23
23
  },
24
- "_vendor-DqQvHbPa.js": {
25
- "file": "assets/vendor-DqQvHbPa.js",
24
+ "_vendor-DRWIRkSJ.js": {
25
+ "file": "assets/vendor-DRWIRkSJ.js",
26
26
  "name": "vendor",
27
27
  "imports": [
28
28
  "_vendor-three-C5WAXd5r.js"
@@ -35,33 +35,33 @@
35
35
  "file": "assets/vendor-WIZid84E.css",
36
36
  "src": "_vendor-WIZid84E.css"
37
37
  },
38
- "_vendor-arizeai-CLX44PFA.js": {
39
- "file": "assets/vendor-arizeai-CLX44PFA.js",
38
+ "_vendor-arizeai-DUhQaeau.js": {
39
+ "file": "assets/vendor-arizeai-DUhQaeau.js",
40
40
  "name": "vendor-arizeai",
41
41
  "imports": [
42
- "_vendor-DqQvHbPa.js"
42
+ "_vendor-DRWIRkSJ.js"
43
43
  ]
44
44
  },
45
- "_vendor-codemirror-Du3XyJnB.js": {
46
- "file": "assets/vendor-codemirror-Du3XyJnB.js",
45
+ "_vendor-codemirror-D_6Q6Auv.js": {
46
+ "file": "assets/vendor-codemirror-D_6Q6Auv.js",
47
47
  "name": "vendor-codemirror",
48
48
  "imports": [
49
- "_vendor-DqQvHbPa.js",
50
- "_vendor-shiki-CNbrFjf9.js"
49
+ "_vendor-DRWIRkSJ.js",
50
+ "_vendor-shiki-k1qj_XjP.js"
51
51
  ]
52
52
  },
53
- "_vendor-recharts-B2PJDrnX.js": {
54
- "file": "assets/vendor-recharts-B2PJDrnX.js",
53
+ "_vendor-recharts-BNBwj7vz.js": {
54
+ "file": "assets/vendor-recharts-BNBwj7vz.js",
55
55
  "name": "vendor-recharts",
56
56
  "imports": [
57
- "_vendor-DqQvHbPa.js"
57
+ "_vendor-DRWIRkSJ.js"
58
58
  ]
59
59
  },
60
- "_vendor-shiki-CNbrFjf9.js": {
61
- "file": "assets/vendor-shiki-CNbrFjf9.js",
60
+ "_vendor-shiki-k1qj_XjP.js": {
61
+ "file": "assets/vendor-shiki-k1qj_XjP.js",
62
62
  "name": "vendor-shiki",
63
63
  "imports": [
64
- "_vendor-DqQvHbPa.js"
64
+ "_vendor-DRWIRkSJ.js"
65
65
  ]
66
66
  },
67
67
  "_vendor-three-C5WAXd5r.js": {
@@ -69,19 +69,19 @@
69
69
  "name": "vendor-three"
70
70
  },
71
71
  "index.tsx": {
72
- "file": "assets/index-QP9R8k34.js",
72
+ "file": "assets/index-Bfg9uQ43.js",
73
73
  "name": "index",
74
74
  "src": "index.tsx",
75
75
  "isEntry": true,
76
76
  "imports": [
77
- "_vendor-DqQvHbPa.js",
78
- "_vendor-arizeai-CLX44PFA.js",
79
- "_pages-B7wCtpad.js",
80
- "_components-DlL7ybQ2.js",
77
+ "_vendor-DRWIRkSJ.js",
78
+ "_vendor-arizeai-DUhQaeau.js",
79
+ "_pages-BCR8hW_l.js",
80
+ "_components-Bwf6zNbg.js",
81
81
  "_vendor-three-C5WAXd5r.js",
82
- "_vendor-codemirror-Du3XyJnB.js",
83
- "_vendor-shiki-CNbrFjf9.js",
84
- "_vendor-recharts-B2PJDrnX.js"
82
+ "_vendor-codemirror-D_6Q6Auv.js",
83
+ "_vendor-shiki-k1qj_XjP.js",
84
+ "_vendor-recharts-BNBwj7vz.js"
85
85
  ]
86
86
  }
87
87
  }