arize-phoenix 11.23.1__py3-none-any.whl → 12.28.1__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.
Files changed (221) hide show
  1. {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/METADATA +61 -36
  2. {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/RECORD +212 -162
  3. {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/WHEEL +1 -1
  4. {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/licenses/IP_NOTICE +1 -1
  5. phoenix/__generated__/__init__.py +0 -0
  6. phoenix/__generated__/classification_evaluator_configs/__init__.py +20 -0
  7. phoenix/__generated__/classification_evaluator_configs/_document_relevance_classification_evaluator_config.py +17 -0
  8. phoenix/__generated__/classification_evaluator_configs/_hallucination_classification_evaluator_config.py +17 -0
  9. phoenix/__generated__/classification_evaluator_configs/_models.py +18 -0
  10. phoenix/__generated__/classification_evaluator_configs/_tool_selection_classification_evaluator_config.py +17 -0
  11. phoenix/__init__.py +2 -1
  12. phoenix/auth.py +27 -2
  13. phoenix/config.py +1594 -81
  14. phoenix/db/README.md +546 -28
  15. phoenix/db/bulk_inserter.py +119 -116
  16. phoenix/db/engines.py +140 -33
  17. phoenix/db/facilitator.py +22 -1
  18. phoenix/db/helpers.py +818 -65
  19. phoenix/db/iam_auth.py +64 -0
  20. phoenix/db/insertion/dataset.py +133 -1
  21. phoenix/db/insertion/document_annotation.py +9 -6
  22. phoenix/db/insertion/evaluation.py +2 -3
  23. phoenix/db/insertion/helpers.py +2 -2
  24. phoenix/db/insertion/session_annotation.py +176 -0
  25. phoenix/db/insertion/span_annotation.py +3 -4
  26. phoenix/db/insertion/trace_annotation.py +3 -4
  27. phoenix/db/insertion/types.py +41 -18
  28. phoenix/db/migrations/versions/01a8342c9cdf_add_user_id_on_datasets.py +40 -0
  29. phoenix/db/migrations/versions/0df286449799_add_session_annotations_table.py +105 -0
  30. phoenix/db/migrations/versions/272b66ff50f8_drop_single_indices.py +119 -0
  31. phoenix/db/migrations/versions/58228d933c91_dataset_labels.py +67 -0
  32. phoenix/db/migrations/versions/699f655af132_experiment_tags.py +57 -0
  33. phoenix/db/migrations/versions/735d3d93c33e_add_composite_indices.py +41 -0
  34. phoenix/db/migrations/versions/ab513d89518b_add_user_id_on_dataset_versions.py +40 -0
  35. phoenix/db/migrations/versions/d0690a79ea51_users_on_experiments.py +40 -0
  36. phoenix/db/migrations/versions/deb2c81c0bb2_dataset_splits.py +139 -0
  37. phoenix/db/migrations/versions/e76cbd66ffc3_add_experiments_dataset_examples.py +87 -0
  38. phoenix/db/models.py +364 -56
  39. phoenix/db/pg_config.py +10 -0
  40. phoenix/db/types/trace_retention.py +7 -6
  41. phoenix/experiments/functions.py +69 -19
  42. phoenix/inferences/inferences.py +1 -2
  43. phoenix/server/api/auth.py +9 -0
  44. phoenix/server/api/auth_messages.py +46 -0
  45. phoenix/server/api/context.py +60 -0
  46. phoenix/server/api/dataloaders/__init__.py +36 -0
  47. phoenix/server/api/dataloaders/annotation_summaries.py +60 -8
  48. phoenix/server/api/dataloaders/average_experiment_repeated_run_group_latency.py +50 -0
  49. phoenix/server/api/dataloaders/average_experiment_run_latency.py +17 -24
  50. phoenix/server/api/dataloaders/cache/two_tier_cache.py +1 -2
  51. phoenix/server/api/dataloaders/dataset_dataset_splits.py +52 -0
  52. phoenix/server/api/dataloaders/dataset_example_revisions.py +0 -1
  53. phoenix/server/api/dataloaders/dataset_example_splits.py +40 -0
  54. phoenix/server/api/dataloaders/dataset_examples_and_versions_by_experiment_run.py +47 -0
  55. phoenix/server/api/dataloaders/dataset_labels.py +36 -0
  56. phoenix/server/api/dataloaders/document_evaluation_summaries.py +2 -2
  57. phoenix/server/api/dataloaders/document_evaluations.py +6 -9
  58. phoenix/server/api/dataloaders/experiment_annotation_summaries.py +88 -34
  59. phoenix/server/api/dataloaders/experiment_dataset_splits.py +43 -0
  60. phoenix/server/api/dataloaders/experiment_error_rates.py +21 -28
  61. phoenix/server/api/dataloaders/experiment_repeated_run_group_annotation_summaries.py +77 -0
  62. phoenix/server/api/dataloaders/experiment_repeated_run_groups.py +57 -0
  63. phoenix/server/api/dataloaders/experiment_runs_by_experiment_and_example.py +44 -0
  64. phoenix/server/api/dataloaders/latency_ms_quantile.py +40 -8
  65. phoenix/server/api/dataloaders/record_counts.py +37 -10
  66. phoenix/server/api/dataloaders/session_annotations_by_session.py +29 -0
  67. phoenix/server/api/dataloaders/span_cost_summary_by_experiment_repeated_run_group.py +64 -0
  68. phoenix/server/api/dataloaders/span_cost_summary_by_project.py +28 -14
  69. phoenix/server/api/dataloaders/span_costs.py +3 -9
  70. phoenix/server/api/dataloaders/table_fields.py +2 -2
  71. phoenix/server/api/dataloaders/token_prices_by_model.py +30 -0
  72. phoenix/server/api/dataloaders/trace_annotations_by_trace.py +27 -0
  73. phoenix/server/api/exceptions.py +5 -1
  74. phoenix/server/api/helpers/playground_clients.py +263 -83
  75. phoenix/server/api/helpers/playground_spans.py +2 -1
  76. phoenix/server/api/helpers/playground_users.py +26 -0
  77. phoenix/server/api/helpers/prompts/conversions/google.py +103 -0
  78. phoenix/server/api/helpers/prompts/models.py +61 -19
  79. phoenix/server/api/input_types/{SpanAnnotationFilter.py → AnnotationFilter.py} +22 -14
  80. phoenix/server/api/input_types/ChatCompletionInput.py +3 -0
  81. phoenix/server/api/input_types/CreateProjectSessionAnnotationInput.py +37 -0
  82. phoenix/server/api/input_types/DatasetFilter.py +5 -2
  83. phoenix/server/api/input_types/ExperimentRunSort.py +237 -0
  84. phoenix/server/api/input_types/GenerativeModelInput.py +3 -0
  85. phoenix/server/api/input_types/ProjectSessionSort.py +158 -1
  86. phoenix/server/api/input_types/PromptVersionInput.py +47 -1
  87. phoenix/server/api/input_types/SpanSort.py +3 -2
  88. phoenix/server/api/input_types/UpdateAnnotationInput.py +34 -0
  89. phoenix/server/api/input_types/UserRoleInput.py +1 -0
  90. phoenix/server/api/mutations/__init__.py +8 -0
  91. phoenix/server/api/mutations/annotation_config_mutations.py +8 -8
  92. phoenix/server/api/mutations/api_key_mutations.py +15 -20
  93. phoenix/server/api/mutations/chat_mutations.py +106 -37
  94. phoenix/server/api/mutations/dataset_label_mutations.py +243 -0
  95. phoenix/server/api/mutations/dataset_mutations.py +21 -16
  96. phoenix/server/api/mutations/dataset_split_mutations.py +351 -0
  97. phoenix/server/api/mutations/experiment_mutations.py +2 -2
  98. phoenix/server/api/mutations/export_events_mutations.py +3 -3
  99. phoenix/server/api/mutations/model_mutations.py +11 -9
  100. phoenix/server/api/mutations/project_mutations.py +4 -4
  101. phoenix/server/api/mutations/project_session_annotations_mutations.py +158 -0
  102. phoenix/server/api/mutations/project_trace_retention_policy_mutations.py +8 -4
  103. phoenix/server/api/mutations/prompt_label_mutations.py +74 -65
  104. phoenix/server/api/mutations/prompt_mutations.py +65 -129
  105. phoenix/server/api/mutations/prompt_version_tag_mutations.py +11 -8
  106. phoenix/server/api/mutations/span_annotations_mutations.py +15 -10
  107. phoenix/server/api/mutations/trace_annotations_mutations.py +13 -8
  108. phoenix/server/api/mutations/trace_mutations.py +3 -3
  109. phoenix/server/api/mutations/user_mutations.py +55 -26
  110. phoenix/server/api/queries.py +501 -617
  111. phoenix/server/api/routers/__init__.py +2 -2
  112. phoenix/server/api/routers/auth.py +141 -87
  113. phoenix/server/api/routers/ldap.py +229 -0
  114. phoenix/server/api/routers/oauth2.py +349 -101
  115. phoenix/server/api/routers/v1/__init__.py +22 -4
  116. phoenix/server/api/routers/v1/annotation_configs.py +19 -30
  117. phoenix/server/api/routers/v1/annotations.py +455 -13
  118. phoenix/server/api/routers/v1/datasets.py +355 -68
  119. phoenix/server/api/routers/v1/documents.py +142 -0
  120. phoenix/server/api/routers/v1/evaluations.py +20 -28
  121. phoenix/server/api/routers/v1/experiment_evaluations.py +16 -6
  122. phoenix/server/api/routers/v1/experiment_runs.py +335 -59
  123. phoenix/server/api/routers/v1/experiments.py +475 -47
  124. phoenix/server/api/routers/v1/projects.py +16 -50
  125. phoenix/server/api/routers/v1/prompts.py +50 -39
  126. phoenix/server/api/routers/v1/sessions.py +108 -0
  127. phoenix/server/api/routers/v1/spans.py +156 -96
  128. phoenix/server/api/routers/v1/traces.py +51 -77
  129. phoenix/server/api/routers/v1/users.py +64 -24
  130. phoenix/server/api/routers/v1/utils.py +3 -7
  131. phoenix/server/api/subscriptions.py +257 -93
  132. phoenix/server/api/types/Annotation.py +90 -23
  133. phoenix/server/api/types/ApiKey.py +13 -17
  134. phoenix/server/api/types/AuthMethod.py +1 -0
  135. phoenix/server/api/types/ChatCompletionSubscriptionPayload.py +1 -0
  136. phoenix/server/api/types/Dataset.py +199 -72
  137. phoenix/server/api/types/DatasetExample.py +88 -18
  138. phoenix/server/api/types/DatasetExperimentAnnotationSummary.py +10 -0
  139. phoenix/server/api/types/DatasetLabel.py +57 -0
  140. phoenix/server/api/types/DatasetSplit.py +98 -0
  141. phoenix/server/api/types/DatasetVersion.py +49 -4
  142. phoenix/server/api/types/DocumentAnnotation.py +212 -0
  143. phoenix/server/api/types/Experiment.py +215 -68
  144. phoenix/server/api/types/ExperimentComparison.py +3 -9
  145. phoenix/server/api/types/ExperimentRepeatedRunGroup.py +155 -0
  146. phoenix/server/api/types/ExperimentRepeatedRunGroupAnnotationSummary.py +9 -0
  147. phoenix/server/api/types/ExperimentRun.py +120 -70
  148. phoenix/server/api/types/ExperimentRunAnnotation.py +158 -39
  149. phoenix/server/api/types/GenerativeModel.py +95 -42
  150. phoenix/server/api/types/GenerativeProvider.py +1 -1
  151. phoenix/server/api/types/ModelInterface.py +7 -2
  152. phoenix/server/api/types/PlaygroundModel.py +12 -2
  153. phoenix/server/api/types/Project.py +218 -185
  154. phoenix/server/api/types/ProjectSession.py +146 -29
  155. phoenix/server/api/types/ProjectSessionAnnotation.py +187 -0
  156. phoenix/server/api/types/ProjectTraceRetentionPolicy.py +1 -1
  157. phoenix/server/api/types/Prompt.py +119 -39
  158. phoenix/server/api/types/PromptLabel.py +42 -25
  159. phoenix/server/api/types/PromptVersion.py +11 -8
  160. phoenix/server/api/types/PromptVersionTag.py +65 -25
  161. phoenix/server/api/types/Span.py +130 -123
  162. phoenix/server/api/types/SpanAnnotation.py +189 -42
  163. phoenix/server/api/types/SystemApiKey.py +65 -1
  164. phoenix/server/api/types/Trace.py +184 -53
  165. phoenix/server/api/types/TraceAnnotation.py +149 -50
  166. phoenix/server/api/types/User.py +128 -33
  167. phoenix/server/api/types/UserApiKey.py +73 -26
  168. phoenix/server/api/types/node.py +10 -0
  169. phoenix/server/api/types/pagination.py +11 -2
  170. phoenix/server/app.py +154 -36
  171. phoenix/server/authorization.py +5 -4
  172. phoenix/server/bearer_auth.py +13 -5
  173. phoenix/server/cost_tracking/cost_model_lookup.py +42 -14
  174. phoenix/server/cost_tracking/model_cost_manifest.json +1085 -194
  175. phoenix/server/daemons/generative_model_store.py +61 -9
  176. phoenix/server/daemons/span_cost_calculator.py +10 -8
  177. phoenix/server/dml_event.py +13 -0
  178. phoenix/server/email/sender.py +29 -2
  179. phoenix/server/grpc_server.py +9 -9
  180. phoenix/server/jwt_store.py +8 -6
  181. phoenix/server/ldap.py +1449 -0
  182. phoenix/server/main.py +9 -3
  183. phoenix/server/oauth2.py +330 -12
  184. phoenix/server/prometheus.py +43 -6
  185. phoenix/server/rate_limiters.py +4 -9
  186. phoenix/server/retention.py +33 -20
  187. phoenix/server/session_filters.py +49 -0
  188. phoenix/server/static/.vite/manifest.json +51 -53
  189. phoenix/server/static/assets/components-BreFUQQa.js +6702 -0
  190. phoenix/server/static/assets/{index-BPCwGQr8.js → index-CTQoemZv.js} +42 -35
  191. phoenix/server/static/assets/pages-DBE5iYM3.js +9524 -0
  192. phoenix/server/static/assets/vendor-BGzfc4EU.css +1 -0
  193. phoenix/server/static/assets/vendor-DCE4v-Ot.js +920 -0
  194. phoenix/server/static/assets/vendor-codemirror-D5f205eT.js +25 -0
  195. phoenix/server/static/assets/{vendor-recharts-Bw30oz1A.js → vendor-recharts-V9cwpXsm.js} +7 -7
  196. phoenix/server/static/assets/{vendor-shiki-DZajAPeq.js → vendor-shiki-Do--csgv.js} +1 -1
  197. phoenix/server/static/assets/vendor-three-CmB8bl_y.js +3840 -0
  198. phoenix/server/templates/index.html +7 -1
  199. phoenix/server/thread_server.py +1 -2
  200. phoenix/server/utils.py +74 -0
  201. phoenix/session/client.py +55 -1
  202. phoenix/session/data_extractor.py +5 -0
  203. phoenix/session/evaluation.py +8 -4
  204. phoenix/session/session.py +44 -8
  205. phoenix/settings.py +2 -0
  206. phoenix/trace/attributes.py +80 -13
  207. phoenix/trace/dsl/query.py +2 -0
  208. phoenix/trace/projects.py +5 -0
  209. phoenix/utilities/template_formatters.py +1 -1
  210. phoenix/version.py +1 -1
  211. phoenix/server/api/types/Evaluation.py +0 -39
  212. phoenix/server/static/assets/components-D0DWAf0l.js +0 -5650
  213. phoenix/server/static/assets/pages-Creyamao.js +0 -8612
  214. phoenix/server/static/assets/vendor-CU36oj8y.js +0 -905
  215. phoenix/server/static/assets/vendor-CqDb5u4o.css +0 -1
  216. phoenix/server/static/assets/vendor-arizeai-Ctgw0e1G.js +0 -168
  217. phoenix/server/static/assets/vendor-codemirror-Cojjzqb9.js +0 -25
  218. phoenix/server/static/assets/vendor-three-BLWp5bic.js +0 -2998
  219. phoenix/utilities/deprecation.py +0 -31
  220. {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/entry_points.txt +0 -0
  221. {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/licenses/LICENSE +0 -0
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  from asyncio import sleep
5
- from datetime import datetime
5
+ from datetime import datetime, timedelta, timezone
6
6
  from typing import Any, Mapping, Optional
7
7
 
8
8
  import sqlalchemy as sa
@@ -16,13 +16,39 @@ logger = logging.getLogger(__name__)
16
16
 
17
17
 
18
18
  class GenerativeModelStore(DaemonTask):
19
+ """A daemon that periodically fetches generative models and maintains an in-memory cache.
20
+
21
+ This daemon periodically fetches generative models and their token prices from the
22
+ database and maintains an in-memory cache for fast lookups. It uses an incremental
23
+ fetch strategy to minimize database egress costs. Instead of fetching all models on
24
+ every refresh, we track the last fetch time and only query for models that have
25
+ changed since then (using updated_at/deleted_at).
26
+
27
+ Rationale: Database egress is expensive in cloud environments (especially managed
28
+ databases), and generative models change infrequently (mostly static reference data).
29
+ The cost calculation daemon queries this store frequently (once per span), so trading
30
+ memory for reduced database egress provides significant cost savings.
31
+
32
+ Note:
33
+ This strategy relies on GenerativeModel.updated_at being properly maintained. Any
34
+ code that modifies GenerativeModel or TokenPrice records MUST ensure updated_at
35
+ is explicitly set (see model_mutations.py). Relying solely on SQLAlchemy's
36
+ onupdate=func.now() is insufficient because SQLAlchemy may skip the UPDATE if it
37
+ detects no "real" changes to scalar fields (even if child records like TokenPrice
38
+ are modified).
39
+ """
40
+
19
41
  def __init__(
20
42
  self,
21
43
  db: DbSessionFactory,
44
+ refresh_interval_seconds: int = 5,
22
45
  ) -> None:
23
46
  super().__init__()
24
47
  self._db = db
25
48
  self._lookup = CostModelLookup()
49
+ self._last_fetch_time: Optional[datetime] = None
50
+ self._last_fetch_id: Optional[int] = None
51
+ self._refresh_interval_seconds = refresh_interval_seconds
26
52
 
27
53
  def find_model(
28
54
  self,
@@ -33,19 +59,45 @@ class GenerativeModelStore(DaemonTask):
33
59
 
34
60
  async def _run(self) -> None:
35
61
  while self._running:
62
+ # Capture time before query with 2-second buffer for clock skew tolerance
63
+ fetch_start_time = datetime.now(timezone.utc) - timedelta(seconds=2)
36
64
  try:
37
65
  await self._fetch_models()
38
66
  except Exception:
39
67
  logger.exception("Failed to refresh generative models")
40
- await sleep(5) # Refresh every 5 seconds
68
+ else:
69
+ self._last_fetch_time = fetch_start_time
70
+ await sleep(self._refresh_interval_seconds)
41
71
 
42
72
  async def _fetch_models(self) -> None:
43
- stmt = (
44
- sa.select(models.GenerativeModel)
45
- .where(models.GenerativeModel.deleted_at.is_(None))
46
- .options(joinedload(models.GenerativeModel.token_prices))
47
- .order_by(models.GenerativeModel.name)
73
+ """
74
+ Fetch generative models from the database using an incremental strategy.
75
+
76
+ On the first run, fetches all models. On subsequent runs, only fetches models
77
+ where updated_at or deleted_at is at or after the last fetch time (with a 2-second
78
+ buffer). Some models may be refetched, but .merge() handles duplicates idempotently.
79
+ """
80
+ stmt = sa.select(models.GenerativeModel).options(
81
+ joinedload(models.GenerativeModel.token_prices)
48
82
  )
83
+ if self._last_fetch_time:
84
+ # Incremental fetch: get models changed since last fetch.
85
+ # Use >= for updated_at/deleted_at to catch models from the buffer window.
86
+ # Include id check as redundant safety check.
87
+ stmt = stmt.where(
88
+ sa.or_(
89
+ models.GenerativeModel.id > self._last_fetch_id,
90
+ models.GenerativeModel.updated_at >= self._last_fetch_time,
91
+ models.GenerativeModel.deleted_at >= self._last_fetch_time,
92
+ )
93
+ )
49
94
  async with self._db() as session:
50
- result = await session.scalars(stmt)
51
- self._lookup = CostModelLookup(result.unique())
95
+ generative_models = (await session.scalars(stmt)).unique().all()
96
+
97
+ if not generative_models:
98
+ return
99
+
100
+ self._lookup.merge(generative_models)
101
+
102
+ # Track max id for redundant safety check.
103
+ self._last_fetch_id = max(model.id for model in generative_models)
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  from asyncio import sleep
5
+ from collections import deque
5
6
  from datetime import datetime
6
7
  from typing import Any, Mapping, NamedTuple, Optional
7
8
 
@@ -35,21 +36,25 @@ class SpanCostCalculator(DaemonTask):
35
36
  super().__init__()
36
37
  self._db = db
37
38
  self._model_store = model_store
38
- self._queue: list[SpanCostCalculatorQueueItem] = []
39
+ self._queue: deque[SpanCostCalculatorQueueItem] = deque()
40
+ self._max_items_per_transaction = 1000
39
41
 
40
42
  async def _run(self) -> None:
41
43
  while self._running:
44
+ num_items_to_insert = min(self._max_items_per_transaction, len(self._queue))
42
45
  try:
43
- await self._insert_costs()
46
+ await self._insert_costs(num_items_to_insert)
44
47
  except Exception as e:
45
48
  logger.exception(f"Failed to insert costs: {e}")
46
49
  await sleep(self._SLEEP_INTERVAL)
47
50
 
48
- async def _insert_costs(self) -> None:
49
- if not self._queue:
51
+ async def _insert_costs(self, num_items_to_insert: int) -> None:
52
+ if not num_items_to_insert or not self._queue:
50
53
  return
51
54
  costs: list[models.SpanCost] = []
52
- for item in self._queue:
55
+ while num_items_to_insert > 0:
56
+ num_items_to_insert -= 1
57
+ item = self._queue.popleft()
53
58
  try:
54
59
  cost = self.calculate_cost(item.span_start_time, item.attributes)
55
60
  except Exception as e:
@@ -65,9 +70,6 @@ class SpanCostCalculator(DaemonTask):
65
70
  session.add_all(costs)
66
71
  except Exception as e:
67
72
  logger.exception(f"Failed to insert costs: {e}")
68
- finally:
69
- # Clear the queue after processing
70
- self._queue.clear()
71
73
 
72
74
  def put_nowait(self, item: SpanCostCalculatorQueueItem) -> None:
73
75
  self._queue.append(item)
@@ -127,6 +127,19 @@ class TraceAnnotationInsertEvent(TraceAnnotationDmlEvent): ...
127
127
  class TraceAnnotationDeleteEvent(TraceAnnotationDmlEvent): ...
128
128
 
129
129
 
130
+ @dataclass(frozen=True)
131
+ class ProjectSessionAnnotationDmlEvent(DmlEvent):
132
+ table = models.ProjectSessionAnnotation
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class ProjectSessionAnnotationInsertEvent(ProjectSessionAnnotationDmlEvent): ...
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class ProjectSessionAnnotationDeleteEvent(ProjectSessionAnnotationDmlEvent): ...
141
+
142
+
130
143
  @dataclass(frozen=True)
131
144
  class DocumentAnnotationDmlEvent(DmlEvent):
132
145
  table = models.DocumentAnnotation
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  import smtplib
2
3
  import ssl
3
4
  from email.message import EmailMessage
@@ -5,11 +6,14 @@ from pathlib import Path
5
6
  from typing import Literal
6
7
 
7
8
  from anyio import to_thread
9
+ from email_validator import EmailNotValidError, validate_email
8
10
  from jinja2 import Environment, FileSystemLoader, select_autoescape
9
11
  from typing_extensions import TypeAlias
10
12
 
11
13
  from phoenix.config import get_env_root_url, get_env_support_email
12
14
 
15
+ logger = logging.getLogger(__name__)
16
+
13
17
  EMAIL_TEMPLATE_FOLDER = Path(__file__).parent / "templates"
14
18
 
15
19
  ConnectionMethod: TypeAlias = Literal["STARTTLS", "SSL", "PLAIN"]
@@ -44,6 +48,12 @@ class SimpleEmailSender:
44
48
  email: str,
45
49
  name: str,
46
50
  ) -> None:
51
+ try:
52
+ email = validate_email(email, check_deliverability=False).normalized
53
+ except EmailNotValidError:
54
+ logger.warning("Skipping welcome email for user with invalid email address")
55
+ return
56
+
47
57
  subject = "[Phoenix] Welcome to Arize Phoenix"
48
58
  template_name = "welcome.html"
49
59
 
@@ -67,6 +77,12 @@ class SimpleEmailSender:
67
77
  email: str,
68
78
  reset_url: str,
69
79
  ) -> None:
80
+ try:
81
+ email = validate_email(email, check_deliverability=False).normalized
82
+ except EmailNotValidError:
83
+ logger.warning("Skipping password reset email for user with invalid email address")
84
+ return
85
+
70
86
  subject = "[Phoenix] Password Reset Request"
71
87
  template_name = "password_reset.html"
72
88
 
@@ -88,21 +104,32 @@ class SimpleEmailSender:
88
104
  allocated_storage_gibibytes: float,
89
105
  notification_threshold_percentage: float,
90
106
  ) -> None:
91
- subject = "[Phoenix] Database Usage Threshold Exceeded"
107
+ try:
108
+ email = validate_email(email, check_deliverability=False).normalized
109
+ except EmailNotValidError:
110
+ logger.warning(
111
+ "Skipping database usage warning email for user with invalid email address"
112
+ )
113
+ return
114
+
115
+ subject = "[Phoenix] Database Disk Space Usage Threshold Exceeded"
92
116
  template_name = "db_disk_usage_notification.html"
93
117
 
118
+ support_email = get_env_support_email()
94
119
  template = self.env.get_template(template_name)
95
120
  html_content = template.render(
96
121
  current_usage_gibibytes=current_usage_gibibytes,
97
122
  allocated_storage_gibibytes=allocated_storage_gibibytes,
98
123
  notification_threshold_percentage=notification_threshold_percentage,
99
- support_email=get_env_support_email(),
124
+ support_email=support_email,
100
125
  )
101
126
 
102
127
  msg = EmailMessage()
103
128
  msg["Subject"] = subject
104
129
  msg["From"] = self.sender_email
105
130
  msg["To"] = email
131
+ if support_email:
132
+ msg["Cc"] = support_email
106
133
  msg.set_content(html_content, subtype="html")
107
134
 
108
135
  await to_thread.run_sync(self._send_email, msg)
@@ -1,5 +1,4 @@
1
- from collections.abc import Awaitable, Callable
2
- from typing import TYPE_CHECKING, Any, Iterable, Optional
1
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterable, Optional
3
2
 
4
3
  import grpc
5
4
  from grpc.aio import RpcContext, Server, ServerInterceptor
@@ -11,6 +10,7 @@ from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
11
10
  TraceServiceServicer,
12
11
  add_TraceServiceServicer_to_server,
13
12
  )
13
+ from starlette.concurrency import run_in_threadpool
14
14
  from typing_extensions import TypeAlias
15
15
 
16
16
  from phoenix.auth import CanReadToken
@@ -34,10 +34,10 @@ ProjectName: TypeAlias = str
34
34
  class Servicer(TraceServiceServicer): # type: ignore[misc,unused-ignore]
35
35
  def __init__(
36
36
  self,
37
- callback: Callable[[Span, ProjectName], Awaitable[None]],
37
+ enqueue_span: Callable[[Span, ProjectName], Awaitable[None]],
38
38
  ) -> None:
39
39
  super().__init__()
40
- self._callback = callback
40
+ self._enqueue_span = enqueue_span
41
41
 
42
42
  async def Export(
43
43
  self,
@@ -48,22 +48,22 @@ class Servicer(TraceServiceServicer): # type: ignore[misc,unused-ignore]
48
48
  project_name = get_project_name(resource_spans.resource.attributes)
49
49
  for scope_span in resource_spans.scope_spans:
50
50
  for otlp_span in scope_span.spans:
51
- span = decode_otlp_span(otlp_span)
52
- await self._callback(span, project_name)
51
+ span = await run_in_threadpool(decode_otlp_span, otlp_span)
52
+ await self._enqueue_span(span, project_name)
53
53
  return ExportTraceServiceResponse()
54
54
 
55
55
 
56
56
  class GrpcServer:
57
57
  def __init__(
58
58
  self,
59
- callback: Callable[[Span, ProjectName], Awaitable[None]],
59
+ enqueue_span: Callable[[Span, ProjectName], Awaitable[None]],
60
60
  tracer_provider: Optional["TracerProvider"] = None,
61
61
  enable_prometheus: bool = False,
62
62
  disabled: bool = False,
63
63
  token_store: Optional[CanReadToken] = None,
64
64
  interceptors: Iterable[ServerInterceptor] = (),
65
65
  ) -> None:
66
- self._callback = callback
66
+ self._enqueue_span = enqueue_span
67
67
  self._server: Optional[Server] = None
68
68
  self._tracer_provider = tracer_provider
69
69
  self._enable_prometheus = enable_prometheus
@@ -106,7 +106,7 @@ class GrpcServer:
106
106
  server.add_secure_port(f"[::]:{get_env_grpc_port()}", server_credentials)
107
107
  else:
108
108
  server.add_insecure_port(f"[::]:{get_env_grpc_port()}")
109
- add_TraceServiceServicer_to_server(Servicer(self._callback), server) # type: ignore[no-untyped-call,unused-ignore]
109
+ add_TraceServiceServicer_to_server(Servicer(self._enqueue_span), server) # type: ignore[no-untyped-call,unused-ignore]
110
110
  await server.start()
111
111
  self._server = server
112
112
 
@@ -164,7 +164,7 @@ class JwtStore:
164
164
  for token_id in token_ids:
165
165
  if isinstance(token_id, PasswordResetTokenId):
166
166
  password_reset_token_ids.append(token_id)
167
- if isinstance(token_id, AccessTokenId):
167
+ elif isinstance(token_id, AccessTokenId):
168
168
  access_token_ids.append(token_id)
169
169
  elif isinstance(token_id, RefreshTokenId):
170
170
  refresh_token_ids.append(token_id)
@@ -182,10 +182,10 @@ class JwtStore:
182
182
  await gather(*coroutines)
183
183
 
184
184
  async def log_out(self, user_id: UserId) -> None:
185
- for cls in (AccessTokenId, RefreshTokenId):
186
- table = cls.table
187
- stmt = delete(table).where(table.user_id == int(user_id)).returning(table.id)
188
- async with self._db() as session:
185
+ async with self._db() as session:
186
+ for cls in (AccessTokenId, RefreshTokenId):
187
+ table = cls.table
188
+ stmt = delete(table).where(table.user_id == int(user_id)).returning(table.id)
189
189
  async for id_ in await session.stream_scalars(stmt):
190
190
  await self._evict(cls(id_))
191
191
 
@@ -314,7 +314,9 @@ class _Store(DaemonTask, Generic[_ClaimSetT, _TokenT, _TokenIdT, _RecordT], ABC)
314
314
 
315
315
  async def _delete_expired_tokens(self, session: Any) -> None:
316
316
  now = datetime.now(timezone.utc)
317
- await session.execute(delete(self._table).where(self._table.expires_at < now))
317
+ # Per JWT RFC 7519 Section 4.1.4, tokens expire "on or after" the expiration time.
318
+ # Use <= to include tokens expiring at exactly this moment.
319
+ await session.execute(delete(self._table).where(self._table.expires_at <= now))
318
320
 
319
321
  async def _run(self) -> None:
320
322
  while self._running: