arize-phoenix 11.7.0__py3-none-any.whl → 11.9.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.7.0.dist-info → arize_phoenix-11.9.0.dist-info}/METADATA +14 -2
  2. {arize_phoenix-11.7.0.dist-info → arize_phoenix-11.9.0.dist-info}/RECORD +39 -37
  3. phoenix/config.py +33 -0
  4. phoenix/datetime_utils.py +112 -1
  5. phoenix/db/helpers.py +156 -1
  6. phoenix/db/insertion/span.py +12 -10
  7. phoenix/db/insertion/types.py +9 -2
  8. phoenix/server/api/auth.py +28 -6
  9. phoenix/server/api/dataloaders/span_cost_summary_by_experiment.py +6 -7
  10. phoenix/server/api/exceptions.py +6 -0
  11. phoenix/server/api/input_types/CreateProjectInput.py +27 -0
  12. phoenix/server/api/input_types/TimeBinConfig.py +23 -0
  13. phoenix/server/api/mutations/project_mutations.py +37 -1
  14. phoenix/server/api/mutations/trace_mutations.py +45 -1
  15. phoenix/server/api/routers/oauth2.py +19 -2
  16. phoenix/server/api/types/CostBreakdown.py +4 -7
  17. phoenix/server/api/types/Project.py +891 -69
  18. phoenix/server/app.py +7 -3
  19. phoenix/server/authorization.py +27 -2
  20. phoenix/server/cost_tracking/cost_details_calculator.py +22 -16
  21. phoenix/server/cost_tracking/model_cost_manifest.json +85 -0
  22. phoenix/server/daemons/span_cost_calculator.py +2 -8
  23. phoenix/server/dml_event.py +4 -0
  24. phoenix/server/email/sender.py +2 -1
  25. phoenix/server/email/templates/db_disk_usage_notification.html +3 -0
  26. phoenix/server/static/.vite/manifest.json +36 -36
  27. phoenix/server/static/assets/{components-J3qjrjBf.js → components-IBd-PDxA.js} +452 -293
  28. phoenix/server/static/assets/{index-CEObsQf_.js → index-B8EBC_Z5.js} +17 -11
  29. phoenix/server/static/assets/{pages-CW1UdBht.js → pages-6D1duYIe.js} +569 -439
  30. phoenix/server/static/assets/vendor-BzZ0oklU.js +939 -0
  31. phoenix/server/static/assets/vendor-arizeai-CvjUqTrl.js +168 -0
  32. phoenix/server/static/assets/{vendor-codemirror-k3zCIjlN.js → vendor-codemirror-CKK25Gd7.js} +1 -1
  33. phoenix/server/static/assets/vendor-recharts-CWtaRhQC.js +37 -0
  34. phoenix/server/static/assets/{vendor-shiki-DPtuv2M4.js → vendor-shiki-D30GF-p9.js} +1 -1
  35. phoenix/version.py +1 -1
  36. phoenix/server/static/assets/vendor-BnPh9i9e.js +0 -911
  37. phoenix/server/static/assets/vendor-arizeai-Cr9o_Iu_.js +0 -642
  38. phoenix/server/static/assets/vendor-recharts-BdblEuGB.js +0 -59
  39. {arize_phoenix-11.7.0.dist-info → arize_phoenix-11.9.0.dist-info}/WHEEL +0 -0
  40. {arize_phoenix-11.7.0.dist-info → arize_phoenix-11.9.0.dist-info}/entry_points.txt +0 -0
  41. {arize_phoenix-11.7.0.dist-info → arize_phoenix-11.9.0.dist-info}/licenses/IP_NOTICE +0 -0
  42. {arize_phoenix-11.7.0.dist-info → arize_phoenix-11.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -28,15 +28,6 @@ async def insert_span(
28
28
  project_name: str,
29
29
  ) -> Optional[SpanInsertionEvent]:
30
30
  dialect = SupportedSQLDialect(session.bind.dialect.name)
31
- if (
32
- project_rowid := await session.scalar(
33
- select(models.Project.id).filter_by(name=project_name)
34
- )
35
- ) is None:
36
- project_rowid = await session.scalar(
37
- insert(models.Project).values(name=project_name).returning(models.Project.id)
38
- )
39
- assert project_rowid is not None
40
31
 
41
32
  trace_id = span.context.trace_id
42
33
  trace: models.Trace = await session.scalar(
@@ -44,16 +35,27 @@ async def insert_span(
44
35
  ) or models.Trace(trace_id=trace_id)
45
36
 
46
37
  if trace.id is not None:
38
+ # We use the existing project_rowid on the trace because we allow users to transfer traces
39
+ # between projects, so the project_name parameter is ignored for existing traces.
40
+ project_rowid = trace.project_rowid
47
41
  # Trace record may need to be updated.
48
42
  if trace.end_time < span.end_time:
49
43
  trace.end_time = span.end_time
50
- trace.project_rowid = project_rowid
51
44
  if span.start_time < trace.start_time:
52
45
  trace.start_time = span.start_time
53
46
  else:
54
47
  # Trace record needs to be persisted for the first time.
55
48
  trace.start_time = span.start_time
56
49
  trace.end_time = span.end_time
50
+ if (
51
+ project_rowid := await session.scalar(
52
+ select(models.Project.id).filter_by(name=project_name)
53
+ )
54
+ ) is None:
55
+ project_rowid = await session.scalar(
56
+ insert(models.Project).values(name=project_name).returning(models.Project.id)
57
+ )
58
+ assert project_rowid is not None
57
59
  trace.project_rowid = project_rowid
58
60
  session.add(trace)
59
61
 
@@ -94,7 +94,10 @@ class QueueInserter(ABC, Generic[_PrecursorT, _InsertableT, _RowT, _DmlEventT]):
94
94
  async def insert(self) -> Optional[list[_DmlEventT]]:
95
95
  if not self._queue:
96
96
  return None
97
- self._queue, parcels = [], self._queue
97
+ parcels = self._queue.copy()
98
+ # IMPORTANT: Use .clear() instead of reassignment, i.e. self._queue = [], to
99
+ # avoid potential race conditions when appending postponed items to the queue.
100
+ self._queue.clear()
98
101
  events: list[_DmlEventT] = []
99
102
  async with self._db() as session:
100
103
  to_insert, to_postpone, _ = await self._partition(session, *parcels)
@@ -104,9 +107,13 @@ class QueueInserter(ABC, Generic[_PrecursorT, _InsertableT, _RowT, _DmlEventT]):
104
107
  to_postpone.extend(to_retry)
105
108
  if to_postpone:
106
109
  loop = asyncio.get_running_loop()
107
- loop.call_later(self._retry_delay_sec, self._queue.extend, to_postpone)
110
+ loop.call_later(self._retry_delay_sec, self._add_postponed_to_queue, to_postpone)
108
111
  return events
109
112
 
113
+ def _add_postponed_to_queue(self, items: list[Postponed[_PrecursorT]]) -> None:
114
+ """Add postponed items back to the queue for retry."""
115
+ self._queue.extend(items)
116
+
110
117
  def _insert_on_conflict(self, *records: Mapping[str, Any]) -> Insert:
111
118
  return insert_on_conflict(
112
119
  *records,
@@ -3,8 +3,10 @@ from typing import Any
3
3
 
4
4
  from strawberry import Info
5
5
  from strawberry.permission import BasePermission
6
+ from typing_extensions import override
6
7
 
7
- from phoenix.server.api.exceptions import Unauthorized
8
+ from phoenix.config import get_env_support_email
9
+ from phoenix.server.api.exceptions import InsufficientStorage, Unauthorized
8
10
  from phoenix.server.bearer_auth import PhoenixUser
9
11
 
10
12
 
@@ -20,15 +22,35 @@ class IsNotReadOnly(Authorization):
20
22
  return not info.context.read_only
21
23
 
22
24
 
23
- class IsLocked(Authorization):
24
- """
25
- Disables mutations and subscriptions that create or update data but allows
26
- queries and delete mutations.
25
+ class IsLocked(BasePermission):
27
26
  """
27
+ Permission class that restricts data-modifying operations when insufficient storage.
28
+
29
+ When database storage capacity is exceeded, this permission blocks mutations and
30
+ subscriptions that create or update data, while allowing queries and delete mutations
31
+ to continue. This prevents database overflow while maintaining read access and the
32
+ ability to free up space through deletions.
28
33
 
29
- message = "Operations that write or modify data are locked"
34
+ Raises:
35
+ InsufficientStorage: When storage capacity is exceeded and data operations
36
+ are temporarily disabled. The error includes guidance for resolution
37
+ and support contact information if configured.
38
+ """
30
39
 
40
+ @override
41
+ def on_unauthorized(self) -> None:
42
+ """Create user-friendly error message when storage operations are blocked."""
43
+ message = (
44
+ "Database operations are disabled due to insufficient storage. "
45
+ "Please delete old data or increase storage."
46
+ )
47
+ if support_email := get_env_support_email():
48
+ message += f" Need help? Contact us at {support_email}"
49
+ raise InsufficientStorage(message)
50
+
51
+ @override
31
52
  def has_permission(self, source: Any, info: Info, **kwargs: Any) -> bool:
53
+ """Check if database operations are allowed based on storage capacity and lock status."""
32
54
  return not (info.context.db.should_not_insert_or_update or info.context.locked)
33
55
 
34
56
 
@@ -1,7 +1,6 @@
1
1
  from collections import defaultdict
2
2
 
3
3
  from sqlalchemy import func, select
4
- from sqlalchemy.sql.functions import coalesce
5
4
  from strawberry.dataloader import DataLoader
6
5
  from typing_extensions import TypeAlias
7
6
 
@@ -23,12 +22,12 @@ class SpanCostSummaryByExperimentDataLoader(DataLoader[Key, Result]):
23
22
  stmt = (
24
23
  select(
25
24
  models.ExperimentRun.experiment_id,
26
- coalesce(func.sum(models.SpanCost.prompt_cost), 0).label("prompt_cost"),
27
- coalesce(func.sum(models.SpanCost.completion_cost), 0).label("completion_cost"),
28
- coalesce(func.sum(models.SpanCost.total_cost), 0).label("total_cost"),
29
- coalesce(func.sum(models.SpanCost.prompt_tokens), 0).label("prompt_tokens"),
30
- coalesce(func.sum(models.SpanCost.completion_tokens), 0).label("completion_tokens"),
31
- coalesce(func.sum(models.SpanCost.total_tokens), 0).label("total_tokens"),
25
+ func.sum(models.SpanCost.prompt_cost).label("prompt_cost"),
26
+ func.sum(models.SpanCost.completion_cost).label("completion_cost"),
27
+ func.sum(models.SpanCost.total_cost).label("total_cost"),
28
+ func.sum(models.SpanCost.prompt_tokens).label("prompt_tokens"),
29
+ func.sum(models.SpanCost.completion_tokens).label("completion_tokens"),
30
+ func.sum(models.SpanCost.total_tokens).label("total_tokens"),
32
31
  )
33
32
  .select_from(models.ExperimentRun)
34
33
  .join(models.Trace, models.ExperimentRun.trace_id == models.Trace.trace_id)
@@ -27,6 +27,12 @@ class Unauthorized(CustomGraphQLError):
27
27
  """
28
28
 
29
29
 
30
+ class InsufficientStorage(CustomGraphQLError):
31
+ """
32
+ An error raised when the database has insufficient storage to complete a request.
33
+ """
34
+
35
+
30
36
  class Conflict(CustomGraphQLError):
31
37
  """
32
38
  An error raised when a mutation cannot be completed due to a conflict with
@@ -0,0 +1,27 @@
1
+ import re
2
+ from typing import Optional
3
+
4
+ import strawberry
5
+ from strawberry import UNSET
6
+
7
+ from phoenix.server.api.exceptions import BadRequest
8
+
9
+
10
+ @strawberry.input
11
+ class CreateProjectInput:
12
+ name: str
13
+ description: Optional[str] = UNSET
14
+ gradient_start_color: Optional[str] = UNSET
15
+ gradient_end_color: Optional[str] = UNSET
16
+
17
+ def __post_init__(self) -> None:
18
+ if not self.name.strip():
19
+ raise BadRequest("Name cannot be empty")
20
+ if self.gradient_start_color and not re.match(
21
+ r"^#([0-9a-fA-F]{6})$", self.gradient_start_color
22
+ ):
23
+ raise BadRequest("Gradient start color must be a valid hex color")
24
+ if self.gradient_end_color and not re.match(
25
+ r"^#([0-9a-fA-F]{6})$", self.gradient_end_color
26
+ ):
27
+ raise BadRequest("Gradient end color must be a valid hex color")
@@ -0,0 +1,23 @@
1
+ from enum import Enum
2
+
3
+ import strawberry
4
+
5
+
6
+ @strawberry.enum
7
+ class TimeBinScale(Enum):
8
+ MINUTE = "minute"
9
+ HOUR = "hour"
10
+ DAY = "day"
11
+ WEEK = "week"
12
+ MONTH = "month"
13
+ YEAR = "year"
14
+
15
+
16
+ @strawberry.input
17
+ class TimeBinConfig:
18
+ scale: TimeBinScale = strawberry.field(
19
+ default=TimeBinScale.HOUR, description="The scale of time bins for aggregation."
20
+ )
21
+ utc_offset_minutes: int = strawberry.field(
22
+ default=0, description="Offset in minutes from UTC for local time binning."
23
+ )
@@ -1,6 +1,8 @@
1
1
  import strawberry
2
2
  from sqlalchemy import delete, select
3
+ from sqlalchemy.exc import IntegrityError as PostgreSQLIntegrityError
3
4
  from sqlalchemy.orm import load_only
5
+ from sqlean.dbapi2 import IntegrityError as SQLiteIntegrityError # type: ignore[import-untyped]
4
6
  from strawberry.relay import GlobalID
5
7
  from strawberry.types import Info
6
8
 
@@ -8,14 +10,48 @@ from phoenix.config import DEFAULT_PROJECT_NAME
8
10
  from phoenix.db import models
9
11
  from phoenix.server.api.auth import IsNotReadOnly
10
12
  from phoenix.server.api.context import Context
13
+ from phoenix.server.api.exceptions import BadRequest, Conflict
11
14
  from phoenix.server.api.input_types.ClearProjectInput import ClearProjectInput
15
+ from phoenix.server.api.input_types.CreateProjectInput import CreateProjectInput
12
16
  from phoenix.server.api.queries import Query
13
17
  from phoenix.server.api.types.node import from_global_id_with_expected_type
14
- from phoenix.server.dml_event import ProjectDeleteEvent, SpanDeleteEvent
18
+ from phoenix.server.api.types.Project import Project, to_gql_project
19
+ from phoenix.server.dml_event import ProjectDeleteEvent, ProjectInsertEvent, SpanDeleteEvent
20
+
21
+
22
+ @strawberry.type
23
+ class ProjectMutationPayload:
24
+ project: Project
25
+ query: Query
15
26
 
16
27
 
17
28
  @strawberry.type
18
29
  class ProjectMutationMixin:
30
+ @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
31
+ async def create_project(
32
+ self,
33
+ info: Info[Context, None],
34
+ input: CreateProjectInput,
35
+ ) -> ProjectMutationPayload:
36
+ if not (name := input.name.strip()):
37
+ raise BadRequest("Name cannot be empty")
38
+ description = (input.description or "").strip() or None
39
+ gradient_start_color = (input.gradient_start_color or "").strip() or None
40
+ gradient_end_color = (input.gradient_end_color or "").strip() or None
41
+ project = models.Project(
42
+ name=name,
43
+ description=description,
44
+ gradient_start_color=gradient_start_color,
45
+ gradient_end_color=gradient_end_color,
46
+ )
47
+ try:
48
+ async with info.context.db() as session:
49
+ session.add(project)
50
+ except (PostgreSQLIntegrityError, SQLiteIntegrityError):
51
+ raise Conflict(f"Project with name '{name}' already exists")
52
+ info.context.event_queue.put(ProjectInsertEvent((project.id,)))
53
+ return ProjectMutationPayload(project=to_gql_project(project), query=Query())
54
+
19
55
  @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
20
56
  async def delete_project(self, info: Info[Context, None], id: GlobalID) -> Query:
21
57
  project_id = from_global_id_with_expected_type(global_id=id, expected_type_name="Project")
@@ -1,5 +1,5 @@
1
1
  import strawberry
2
- from sqlalchemy import and_, delete, not_, select
2
+ from sqlalchemy import and_, delete, not_, select, update
3
3
  from sqlalchemy.orm import load_only
4
4
  from sqlalchemy.sql import literal
5
5
  from strawberry.relay import GlobalID
@@ -72,3 +72,47 @@ class TraceMutationMixin:
72
72
  )
73
73
  info.context.event_queue.put(SpanDeleteEvent(project_ids))
74
74
  return Query()
75
+
76
+ @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
77
+ async def transfer_traces_to_project(
78
+ self,
79
+ info: Info[Context, None],
80
+ trace_ids: list[GlobalID],
81
+ project_id: GlobalID,
82
+ ) -> Query:
83
+ if not trace_ids:
84
+ raise BadRequest("Must provide at least one trace ID to transfer")
85
+ trace_ids = list(set(trace_ids))
86
+ try:
87
+ trace_rowids = [
88
+ from_global_id_with_expected_type(global_id=id, expected_type_name="Trace")
89
+ for id in trace_ids
90
+ ]
91
+ dest_project_rowid = from_global_id_with_expected_type(
92
+ global_id=project_id, expected_type_name="Project"
93
+ )
94
+ except ValueError as error:
95
+ raise BadRequest(str(error))
96
+
97
+ async with info.context.db() as session:
98
+ dest_project = await session.get(models.Project, dest_project_rowid)
99
+ if dest_project is None:
100
+ raise BadRequest("Destination project does not exist")
101
+
102
+ traces = (
103
+ await session.scalars(select(models.Trace).where(models.Trace.id.in_(trace_rowids)))
104
+ ).all()
105
+ if len(traces) < len(trace_rowids):
106
+ raise BadRequest("Invalid trace IDs provided")
107
+
108
+ source_project_ids = set(trace.project_rowid for trace in traces)
109
+ if len(source_project_ids) > 1:
110
+ raise BadRequest("Cannot transfer traces from multiple projects")
111
+
112
+ await session.execute(
113
+ update(models.Trace)
114
+ .where(models.Trace.id.in_(trace_rowids))
115
+ .values(project_rowid=dest_project_rowid)
116
+ )
117
+
118
+ return Query()
@@ -169,7 +169,11 @@ async def create_tokens(
169
169
  error=f"OAuth2 IDP {idp_name} does not appear to support OpenID Connect.",
170
170
  )
171
171
  user_info = await oauth2_client.parse_id_token(token_data, nonce=stored_nonce)
172
- user_info = _parse_user_info(user_info)
172
+ try:
173
+ user_info = _parse_user_info(user_info)
174
+ except MissingEmailScope as error:
175
+ return _redirect_to_login(request=request, error=str(error))
176
+
173
177
  try:
174
178
  async with request.app.state.db() as session:
175
179
  user = await _process_oauth2_user(
@@ -237,7 +241,12 @@ def _parse_user_info(user_info: dict[str, Any]) -> UserInfo:
237
241
  """
238
242
  assert isinstance(subject := user_info.get("sub"), (str, int))
239
243
  idp_user_id = str(subject)
240
- assert isinstance(email := user_info.get("email"), str)
244
+ email = user_info.get("email")
245
+ if not isinstance(email, str):
246
+ raise MissingEmailScope(
247
+ "Please ensure your OIDC provider is configured to use the 'email' scope."
248
+ )
249
+
241
250
  assert isinstance(username := user_info.get("name"), str) or username is None
242
251
  assert (
243
252
  isinstance(profile_picture_url := user_info.get("picture"), str)
@@ -541,6 +550,14 @@ class NotInvited(Exception):
541
550
  pass
542
551
 
543
552
 
553
+ class MissingEmailScope(Exception):
554
+ """
555
+ Raised when the OIDC provider does not return the email scope.
556
+ """
557
+
558
+ pass
559
+
560
+
544
561
  def _redirect_to_login(*, request: Request, error: str) -> RedirectResponse:
545
562
  """
546
563
  Creates a RedirectResponse to the login page to display an error message.
@@ -5,11 +5,8 @@ import strawberry
5
5
 
6
6
  @strawberry.type
7
7
  class CostBreakdown:
8
- tokens: Optional[float] = None
8
+ tokens: Optional[float] = strawberry.field(
9
+ default=None,
10
+ description="Total number of tokens, including tokens for which no cost was computed.",
11
+ )
9
12
  cost: Optional[float] = None
10
-
11
- @strawberry.field
12
- def cost_per_token(self) -> Optional[float]:
13
- if self.tokens and self.cost:
14
- return self.cost / self.tokens
15
- return None