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
@@ -5,21 +5,16 @@ from collections.abc import AsyncIterator
5
5
  from datetime import datetime, timezone
6
6
  from enum import Enum
7
7
  from secrets import token_urlsafe
8
- from typing import Annotated, Any, Literal, Optional, Union
8
+ from typing import Annotated, Any, Optional, Union
9
9
 
10
10
  import pandas as pd
11
11
  import sqlalchemy as sa
12
12
  from fastapi import APIRouter, Depends, Header, HTTPException, Path, Query
13
- from pydantic import BaseModel, Field
13
+ from pydantic import BaseModel, BeforeValidator, Field
14
14
  from sqlalchemy import exists, select, update
15
15
  from starlette.requests import Request
16
16
  from starlette.responses import Response, StreamingResponse
17
- from starlette.status import (
18
- HTTP_202_ACCEPTED,
19
- HTTP_400_BAD_REQUEST,
20
- HTTP_404_NOT_FOUND,
21
- HTTP_422_UNPROCESSABLE_ENTITY,
22
- )
17
+ from starlette.status import HTTP_404_NOT_FOUND
23
18
  from strawberry.relay import GlobalID
24
19
 
25
20
  from phoenix.config import DEFAULT_PROJECT_NAME
@@ -27,13 +22,13 @@ from phoenix.datetime_utils import normalize_datetime
27
22
  from phoenix.db import models
28
23
  from phoenix.db.helpers import SupportedSQLDialect, get_ancestor_span_rowids
29
24
  from phoenix.db.insertion.helpers import as_kv, insert_on_conflict
30
- from phoenix.db.insertion.types import Precursors
31
25
  from phoenix.server.api.routers.utils import df_to_bytes
26
+ from phoenix.server.api.routers.v1.annotations import SpanAnnotationData
32
27
  from phoenix.server.api.types.node import from_global_id_with_expected_type
33
28
  from phoenix.server.authorization import is_not_locked
34
29
  from phoenix.server.bearer_auth import PhoenixUser
35
30
  from phoenix.server.dml_event import SpanAnnotationInsertEvent, SpanDeleteEvent
36
- from phoenix.trace.attributes import flatten
31
+ from phoenix.trace.attributes import flatten, unflatten
37
32
  from phoenix.trace.dsl import SpanQuery as SpanQuery_
38
33
  from phoenix.trace.schemas import (
39
34
  Span as SpanForInsertion,
@@ -440,7 +435,7 @@ class SpansResponseBody(PaginatedResponseBody[Span]):
440
435
  "/spans",
441
436
  operation_id="querySpans",
442
437
  summary="Query spans with query DSL",
443
- responses=add_errors_to_responses([HTTP_404_NOT_FOUND, HTTP_422_UNPROCESSABLE_ENTITY]),
438
+ responses=add_errors_to_responses([404, 422]),
444
439
  include_in_schema=False,
445
440
  )
446
441
  async def query_spans_handler(
@@ -467,30 +462,30 @@ async def query_spans_handler(
467
462
  except Exception as e:
468
463
  raise HTTPException(
469
464
  detail=f"Invalid query: {e}",
470
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
465
+ status_code=422,
471
466
  )
467
+
472
468
  async with request.app.state.db() as session:
473
- results = []
469
+ results: list[pd.DataFrame] = []
474
470
  for query in span_queries:
475
- results.append(
476
- await session.run_sync(
477
- query,
478
- project_name=project_name,
479
- start_time=normalize_datetime(
480
- request_body.start_time,
481
- timezone.utc,
482
- ),
483
- end_time=normalize_datetime(
484
- end_time,
485
- timezone.utc,
486
- ),
487
- limit=request_body.limit,
488
- root_spans_only=request_body.root_spans_only,
489
- orphan_span_as_root_span=request_body.orphan_span_as_root_span,
490
- )
471
+ df = await session.run_sync(
472
+ query,
473
+ project_name=project_name,
474
+ start_time=normalize_datetime(
475
+ request_body.start_time,
476
+ timezone.utc,
477
+ ),
478
+ end_time=normalize_datetime(
479
+ end_time,
480
+ timezone.utc,
481
+ ),
482
+ limit=request_body.limit,
483
+ root_spans_only=request_body.root_spans_only,
484
+ orphan_span_as_root_span=request_body.orphan_span_as_root_span,
491
485
  )
486
+ results.append(df)
492
487
  if not results:
493
- raise HTTPException(status_code=HTTP_404_NOT_FOUND)
488
+ raise HTTPException(status_code=404)
494
489
 
495
490
  if accept == "application/json":
496
491
  boundary_token = token_urlsafe(64)
@@ -574,7 +569,7 @@ def _to_any_value(value: Any) -> OtlpAnyValue:
574
569
  summary="Search spans with simple filters (no DSL)",
575
570
  description="Return spans within a project filtered by time range. "
576
571
  "Supports cursor-based pagination.",
577
- responses=add_errors_to_responses([HTTP_404_NOT_FOUND, HTTP_422_UNPROCESSABLE_ENTITY]),
572
+ responses=add_errors_to_responses([404, 422]),
578
573
  )
579
574
  async def span_search_otlpv1(
580
575
  request: Request,
@@ -617,7 +612,7 @@ async def span_search_otlpv1(
617
612
  cursor_rowid = int(GlobalID.from_id(cursor).node_id)
618
613
  stmt = stmt.where(models.Span.id <= cursor_rowid)
619
614
  except Exception:
620
- raise HTTPException(status_code=HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid cursor")
615
+ raise HTTPException(status_code=422, detail="Invalid cursor")
621
616
 
622
617
  stmt = stmt.limit(limit + 1)
623
618
 
@@ -711,7 +706,7 @@ async def span_search_otlpv1(
711
706
  summary="List spans with simple filters (no DSL)",
712
707
  description="Return spans within a project filtered by time range. "
713
708
  "Supports cursor-based pagination.",
714
- responses=add_errors_to_responses([HTTP_404_NOT_FOUND, HTTP_422_UNPROCESSABLE_ENTITY]),
709
+ responses=add_errors_to_responses([404, 422]),
715
710
  )
716
711
  async def span_search(
717
712
  request: Request,
@@ -751,7 +746,7 @@ async def span_search(
751
746
  try:
752
747
  cursor_rowid = int(GlobalID.from_id(cursor).node_id)
753
748
  except Exception:
754
- raise HTTPException(status_code=HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid cursor")
749
+ raise HTTPException(status_code=422, detail="Invalid cursor")
755
750
  stmt = stmt.where(models.Span.id <= cursor_rowid)
756
751
 
757
752
  stmt = stmt.limit(limit + 1)
@@ -850,51 +845,6 @@ async def get_spans_handler(
850
845
  return await query_spans_handler(request, request_body, project_name)
851
846
 
852
847
 
853
- class SpanAnnotationResult(V1RoutesBaseModel):
854
- label: Optional[str] = Field(default=None, description="The label assigned by the annotation")
855
- score: Optional[float] = Field(default=None, description="The score assigned by the annotation")
856
- explanation: Optional[str] = Field(
857
- default=None, description="Explanation of the annotation result"
858
- )
859
-
860
-
861
- class SpanAnnotationData(V1RoutesBaseModel):
862
- span_id: str = Field(description="OpenTelemetry Span ID (hex format w/o 0x prefix)")
863
- name: str = Field(description="The name of the annotation")
864
- annotator_kind: Literal["LLM", "CODE", "HUMAN"] = Field(
865
- description="The kind of annotator used for the annotation"
866
- )
867
- result: Optional[SpanAnnotationResult] = Field(
868
- default=None, description="The result of the annotation"
869
- )
870
- metadata: Optional[dict[str, Any]] = Field(
871
- default=None, description="Metadata for the annotation"
872
- )
873
- identifier: str = Field(
874
- default="",
875
- description=(
876
- "The identifier of the annotation. "
877
- "If provided, the annotation will be updated if it already exists."
878
- ),
879
- )
880
-
881
- def as_precursor(self, *, user_id: Optional[int] = None) -> Precursors.SpanAnnotation:
882
- return Precursors.SpanAnnotation(
883
- self.span_id,
884
- models.SpanAnnotation(
885
- name=self.name,
886
- annotator_kind=self.annotator_kind,
887
- score=self.result.score if self.result else None,
888
- label=self.result.label if self.result else None,
889
- explanation=self.result.explanation if self.result else None,
890
- metadata_=self.metadata or {},
891
- identifier=self.identifier,
892
- source="API",
893
- user_id=user_id,
894
- ),
895
- )
896
-
897
-
898
848
  class AnnotateSpansRequestBody(RequestBody[list[SpanAnnotationData]]):
899
849
  data: list[SpanAnnotationData]
900
850
 
@@ -912,9 +862,7 @@ class AnnotateSpansResponseBody(ResponseBody[list[InsertedSpanAnnotation]]):
912
862
  dependencies=[Depends(is_not_locked)],
913
863
  operation_id="annotateSpans",
914
864
  summary="Create span annotations",
915
- responses=add_errors_to_responses(
916
- [{"status_code": HTTP_404_NOT_FOUND, "description": "Span not found"}]
917
- ),
865
+ responses=add_errors_to_responses([{"status_code": 404, "description": "Span not found"}]),
918
866
  response_description="Span annotations inserted successfully",
919
867
  include_in_schema=True,
920
868
  )
@@ -942,15 +890,17 @@ async def annotate_spans(
942
890
  )
943
891
  precursors = [d.as_precursor(user_id=user_id) for d in filtered_span_annotations]
944
892
  if not sync:
945
- await request.state.enqueue(*precursors)
893
+ await request.state.enqueue_annotations(*precursors)
946
894
  return AnnotateSpansResponseBody(data=[])
947
895
 
948
896
  span_ids = {p.span_id for p in precursors}
949
897
  async with request.app.state.db() as session:
950
898
  existing_spans = {
951
- span.span_id: span.id
952
- async for span in await session.stream_scalars(
953
- select(models.Span).filter(models.Span.span_id.in_(span_ids))
899
+ span_id: id_
900
+ async for span_id, id_ in await session.stream(
901
+ select(models.Span.span_id, models.Span.id).filter(
902
+ models.Span.span_id.in_(span_ids)
903
+ )
954
904
  )
955
905
  }
956
906
 
@@ -958,7 +908,7 @@ async def annotate_spans(
958
908
  if missing_span_ids:
959
909
  raise HTTPException(
960
910
  detail=f"Spans with IDs {', '.join(missing_span_ids)} do not exist.",
961
- status_code=HTTP_404_NOT_FOUND,
911
+ status_code=404,
962
912
  )
963
913
  inserted_ids = []
964
914
  dialect = SupportedSQLDialect(session.bind.dialect.name)
@@ -982,6 +932,100 @@ async def annotate_spans(
982
932
  )
983
933
 
984
934
 
935
+ class SpanNoteData(V1RoutesBaseModel):
936
+ span_id: Annotated[str, BeforeValidator(lambda v: v.strip() if isinstance(v, str) else v)] = (
937
+ Field(min_length=1, description="OpenTelemetry Span ID (hex format w/o 0x prefix)")
938
+ )
939
+ note: Annotated[str, BeforeValidator(lambda v: v.strip() if isinstance(v, str) else v)] = Field(
940
+ min_length=1, description="The note text to add to the span"
941
+ )
942
+
943
+
944
+ class CreateSpanNoteRequestBody(RequestBody[SpanNoteData]):
945
+ data: SpanNoteData
946
+
947
+
948
+ class CreateSpanNoteResponseBody(ResponseBody[InsertedSpanAnnotation]):
949
+ pass
950
+
951
+
952
+ @router.post(
953
+ "/span_notes",
954
+ dependencies=[Depends(is_not_locked)],
955
+ operation_id="createSpanNote",
956
+ summary="Create a span note",
957
+ description=(
958
+ "Add a note annotation to a span. Notes are special annotations that allow "
959
+ "multiple entries per span (unlike regular annotations which are unique by name "
960
+ "and identifier). Each note gets a unique timestamp-based identifier."
961
+ ),
962
+ responses=add_errors_to_responses([{"status_code": 404, "description": "Span not found"}]),
963
+ response_description="Span note created successfully",
964
+ status_code=200,
965
+ )
966
+ async def create_span_note(
967
+ request: Request,
968
+ request_body: CreateSpanNoteRequestBody,
969
+ ) -> CreateSpanNoteResponseBody:
970
+ """
971
+ Create a note annotation for a span.
972
+
973
+ Notes are a special type of annotation that:
974
+ - Have the fixed name "note"
975
+ - Use a timestamp-based identifier to allow multiple notes per span
976
+ - Are always created with annotator_kind="HUMAN" and source="API"
977
+ - Store the note text in the explanation field
978
+ """
979
+ note_data = request_body.data
980
+
981
+ user_id: Optional[int] = None
982
+ if request.app.state.authentication_enabled and isinstance(request.user, PhoenixUser):
983
+ user_id = int(request.user.identity)
984
+
985
+ async with request.app.state.db() as session:
986
+ # Find the span by OpenTelemetry span_id
987
+ span_rowid = await session.scalar(
988
+ select(models.Span.id).where(models.Span.span_id == note_data.span_id)
989
+ )
990
+
991
+ if span_rowid is None:
992
+ raise HTTPException(
993
+ status_code=404,
994
+ detail=f"Span with ID {note_data.span_id} not found",
995
+ )
996
+
997
+ # Generate a unique identifier for the note using timestamp
998
+ timestamp = datetime.now(timezone.utc).isoformat()
999
+ note_identifier = f"px-span-note:{timestamp}"
1000
+
1001
+ # Create the annotation values
1002
+ values = {
1003
+ "span_rowid": span_rowid,
1004
+ "name": "note",
1005
+ "label": None,
1006
+ "score": None,
1007
+ "explanation": note_data.note,
1008
+ "annotator_kind": "HUMAN",
1009
+ "metadata_": {},
1010
+ "identifier": note_identifier,
1011
+ "source": "API",
1012
+ "user_id": user_id,
1013
+ }
1014
+
1015
+ # Insert the annotation
1016
+ result = await session.execute(
1017
+ sa.insert(models.SpanAnnotation).values(**values).returning(models.SpanAnnotation.id)
1018
+ )
1019
+ annotation_id = result.scalar_one()
1020
+
1021
+ # Put event on queue after successful insert
1022
+ request.state.event_queue.put(SpanAnnotationInsertEvent((annotation_id,)))
1023
+
1024
+ return CreateSpanNoteResponseBody(
1025
+ data=InsertedSpanAnnotation(id=str(GlobalID("SpanAnnotation", str(annotation_id))))
1026
+ )
1027
+
1028
+
985
1029
  class CreateSpansRequestBody(RequestBody[list[Span]]):
986
1030
  data: list[Span]
987
1031
 
@@ -1000,8 +1044,8 @@ class CreateSpansResponseBody(V1RoutesBaseModel):
1000
1044
  "Submit spans to be inserted into a project. If any spans are invalid or "
1001
1045
  "duplicates, no spans will be inserted."
1002
1046
  ),
1003
- responses=add_errors_to_responses([HTTP_404_NOT_FOUND, HTTP_400_BAD_REQUEST]),
1004
- status_code=HTTP_202_ACCEPTED,
1047
+ responses=add_errors_to_responses([404, 400]),
1048
+ status_code=202,
1005
1049
  )
1006
1050
  async def create_spans(
1007
1051
  request: Request,
@@ -1040,6 +1084,7 @@ async def create_spans(
1040
1084
  # Add back the openinference.span.kind attribute since it's stored separately in the API
1041
1085
  attributes = dict(api_span.attributes)
1042
1086
  attributes["openinference.span.kind"] = api_span.span_kind
1087
+ attributes = unflatten(attributes.items())
1043
1088
 
1044
1089
  # Create span for insertion - note we ignore the 'id' field as it's server-generated
1045
1090
  return SpanForInsertion(
@@ -1058,8 +1103,23 @@ async def create_spans(
1058
1103
  conversation=None, # Unused
1059
1104
  )
1060
1105
 
1061
- async with request.app.state.db() as session:
1062
- project = await _get_project_by_identifier(session, project_identifier)
1106
+ try:
1107
+ id_ = from_global_id_with_expected_type(
1108
+ GlobalID.from_id(project_identifier),
1109
+ "Project",
1110
+ )
1111
+ except Exception:
1112
+ project_name = project_identifier
1113
+ else:
1114
+ stmt = select(models.Project).filter_by(id=id_)
1115
+ async with request.app.state.db() as session:
1116
+ project = await session.scalar(stmt)
1117
+ if project is None:
1118
+ raise HTTPException(
1119
+ status_code=HTTP_404_NOT_FOUND,
1120
+ detail=f"Project with ID {project_identifier} not found",
1121
+ )
1122
+ project_name = project.name
1063
1123
 
1064
1124
  total_received = len(request_body.data)
1065
1125
  duplicate_spans: list[dict[str, str]] = []
@@ -1087,7 +1147,7 @@ async def create_spans(
1087
1147
 
1088
1148
  try:
1089
1149
  span_for_insertion = convert_api_span_for_insertion(api_span)
1090
- spans_to_queue.append((span_for_insertion, project.name))
1150
+ spans_to_queue.append((span_for_insertion, project_name))
1091
1151
  except Exception as e:
1092
1152
  invalid_spans.append(
1093
1153
  {
@@ -1109,13 +1169,13 @@ async def create_spans(
1109
1169
  "invalid_spans": invalid_spans,
1110
1170
  }
1111
1171
  raise HTTPException(
1112
- status_code=HTTP_400_BAD_REQUEST,
1172
+ status_code=400,
1113
1173
  detail=json.dumps(error_detail),
1114
1174
  )
1115
1175
 
1116
1176
  # All spans are valid, queue them all
1117
1177
  for span_for_insertion, project_name in spans_to_queue:
1118
- await request.state.queue_span_for_bulk_insert(span_for_insertion, project_name)
1178
+ await request.state.enqueue_span(span_for_insertion, project_name)
1119
1179
 
1120
1180
  return CreateSpansResponseBody(
1121
1181
  total_received=total_received,
@@ -1145,7 +1205,7 @@ async def create_spans(
1145
1205
  **Note**: This operation is irreversible and may create orphaned spans.
1146
1206
  """
1147
1207
  ),
1148
- responses=add_errors_to_responses([HTTP_404_NOT_FOUND]),
1208
+ responses=add_errors_to_responses([404]),
1149
1209
  status_code=204, # No Content for successful deletion
1150
1210
  )
1151
1211
  async def delete_span(
@@ -1197,7 +1257,7 @@ async def delete_span(
1197
1257
 
1198
1258
  if target_span is None:
1199
1259
  raise HTTPException(
1200
- status_code=HTTP_404_NOT_FOUND,
1260
+ status_code=404,
1201
1261
  detail=error_detail,
1202
1262
  )
1203
1263
 
@@ -1,6 +1,6 @@
1
1
  import gzip
2
2
  import zlib
3
- from typing import Any, Literal, Optional
3
+ from typing import Optional
4
4
 
5
5
  from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Path, Query
6
6
  from google.protobuf.message import DecodeError
@@ -9,48 +9,62 @@ from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
9
9
  ExportTraceServiceResponse,
10
10
  )
11
11
  from pydantic import Field
12
- from sqlalchemy import delete, insert, select
12
+ from sqlalchemy import delete, select
13
13
  from starlette.concurrency import run_in_threadpool
14
14
  from starlette.datastructures import State
15
15
  from starlette.requests import Request
16
16
  from starlette.responses import Response
17
- from starlette.status import (
18
- HTTP_404_NOT_FOUND,
19
- HTTP_415_UNSUPPORTED_MEDIA_TYPE,
20
- HTTP_422_UNPROCESSABLE_ENTITY,
21
- )
22
17
  from strawberry.relay import GlobalID
23
18
 
24
19
  from phoenix.db import models
25
- from phoenix.db.insertion.helpers import as_kv
26
- from phoenix.db.insertion.types import Precursors
20
+ from phoenix.db.helpers import SupportedSQLDialect
21
+ from phoenix.db.insertion.helpers import as_kv, insert_on_conflict
22
+ from phoenix.server.api.routers.v1.annotations import TraceAnnotationData
27
23
  from phoenix.server.api.types.node import from_global_id_with_expected_type
28
24
  from phoenix.server.authorization import is_not_locked
29
25
  from phoenix.server.bearer_auth import PhoenixUser
30
26
  from phoenix.server.dml_event import SpanDeleteEvent, TraceAnnotationInsertEvent
27
+ from phoenix.server.prometheus import SPAN_QUEUE_REJECTIONS
31
28
  from phoenix.trace.otel import decode_otlp_span
32
29
  from phoenix.utilities.project import get_project_name
33
30
 
34
31
  from .models import V1RoutesBaseModel
35
- from .utils import RequestBody, ResponseBody, add_errors_to_responses
32
+ from .utils import (
33
+ RequestBody,
34
+ ResponseBody,
35
+ add_errors_to_responses,
36
+ )
36
37
 
37
38
  router = APIRouter(tags=["traces"])
38
39
 
39
40
 
41
+ def is_not_at_capacity(request: Request) -> None:
42
+ if request.app.state.span_queue_is_full():
43
+ SPAN_QUEUE_REJECTIONS.inc()
44
+ raise HTTPException(
45
+ detail="Server is at capacity and cannot process more requests",
46
+ status_code=503,
47
+ )
48
+
49
+
40
50
  @router.post(
41
51
  "/traces",
42
- dependencies=[Depends(is_not_locked)],
52
+ dependencies=[Depends(is_not_locked), Depends(is_not_at_capacity)],
43
53
  operation_id="addTraces",
44
54
  summary="Send traces",
45
55
  responses=add_errors_to_responses(
46
56
  [
47
57
  {
48
- "status_code": HTTP_415_UNSUPPORTED_MEDIA_TYPE,
58
+ "status_code": 415,
49
59
  "description": (
50
60
  "Unsupported content type (only `application/x-protobuf` is supported)"
51
61
  ),
52
62
  },
53
- {"status_code": HTTP_422_UNPROCESSABLE_ENTITY, "description": "Invalid request body"},
63
+ {"status_code": 422, "description": "Invalid request body"},
64
+ {
65
+ "status_code": 503,
66
+ "description": "Server is at capacity and cannot process more requests",
67
+ },
54
68
  ]
55
69
  ),
56
70
  openapi_extra={
@@ -72,12 +86,12 @@ async def post_traces(
72
86
  if content_type != "application/x-protobuf":
73
87
  raise HTTPException(
74
88
  detail=f"Unsupported content type: {content_type}",
75
- status_code=HTTP_415_UNSUPPORTED_MEDIA_TYPE,
89
+ status_code=415,
76
90
  )
77
91
  if content_encoding and content_encoding not in ("gzip", "deflate"):
78
92
  raise HTTPException(
79
93
  detail=f"Unsupported content encoding: {content_encoding}",
80
- status_code=HTTP_415_UNSUPPORTED_MEDIA_TYPE,
94
+ status_code=415,
81
95
  )
82
96
  body = await request.body()
83
97
  if content_encoding == "gzip":
@@ -90,7 +104,7 @@ async def post_traces(
90
104
  except DecodeError:
91
105
  raise HTTPException(
92
106
  detail="Request body is invalid ExportTraceServiceRequest",
93
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
107
+ status_code=422,
94
108
  )
95
109
  background_tasks.add_task(_add_spans, req, request.state)
96
110
 
@@ -104,53 +118,8 @@ async def post_traces(
104
118
  )
105
119
 
106
120
 
107
- class TraceAnnotationResult(V1RoutesBaseModel):
108
- label: Optional[str] = Field(default=None, description="The label assigned by the annotation")
109
- score: Optional[float] = Field(default=None, description="The score assigned by the annotation")
110
- explanation: Optional[str] = Field(
111
- default=None, description="Explanation of the annotation result"
112
- )
113
-
114
-
115
- class TraceAnnotation(V1RoutesBaseModel):
116
- trace_id: str = Field(description="OpenTelemetry Trace ID (hex format w/o 0x prefix)")
117
- name: str = Field(description="The name of the annotation")
118
- annotator_kind: Literal["LLM", "HUMAN"] = Field(
119
- description="The kind of annotator used for the annotation"
120
- )
121
- result: Optional[TraceAnnotationResult] = Field(
122
- default=None, description="The result of the annotation"
123
- )
124
- metadata: Optional[dict[str, Any]] = Field(
125
- default=None, description="Metadata for the annotation"
126
- )
127
- identifier: str = Field(
128
- default="",
129
- description=(
130
- "The identifier of the annotation. "
131
- "If provided, the annotation will be updated if it already exists."
132
- ),
133
- )
134
-
135
- def as_precursor(self, *, user_id: Optional[int] = None) -> Precursors.TraceAnnotation:
136
- return Precursors.TraceAnnotation(
137
- self.trace_id,
138
- models.TraceAnnotation(
139
- name=self.name,
140
- annotator_kind=self.annotator_kind,
141
- score=self.result.score if self.result else None,
142
- label=self.result.label if self.result else None,
143
- explanation=self.result.explanation if self.result else None,
144
- metadata_=self.metadata or {},
145
- identifier=self.identifier,
146
- source="APP",
147
- user_id=user_id,
148
- ),
149
- )
150
-
151
-
152
- class AnnotateTracesRequestBody(RequestBody[list[TraceAnnotation]]):
153
- data: list[TraceAnnotation] = Field(description="The trace annotations to be upserted")
121
+ class AnnotateTracesRequestBody(RequestBody[list[TraceAnnotationData]]):
122
+ data: list[TraceAnnotationData] = Field(description="The trace annotations to be upserted")
154
123
 
155
124
 
156
125
  class InsertedTraceAnnotation(V1RoutesBaseModel):
@@ -166,15 +135,12 @@ class AnnotateTracesResponseBody(ResponseBody[list[InsertedTraceAnnotation]]):
166
135
  dependencies=[Depends(is_not_locked)],
167
136
  operation_id="annotateTraces",
168
137
  summary="Create trace annotations",
169
- responses=add_errors_to_responses(
170
- [{"status_code": HTTP_404_NOT_FOUND, "description": "Trace not found"}]
171
- ),
172
- include_in_schema=False,
138
+ responses=add_errors_to_responses([{"status_code": 404, "description": "Trace not found"}]),
173
139
  )
174
140
  async def annotate_traces(
175
141
  request: Request,
176
142
  request_body: AnnotateTracesRequestBody,
177
- sync: bool = Query(default=True, description="If true, fulfill request synchronously."),
143
+ sync: bool = Query(default=False, description="If true, fulfill request synchronously."),
178
144
  ) -> AnnotateTracesResponseBody:
179
145
  if not request_body.data:
180
146
  return AnnotateTracesResponseBody(data=[])
@@ -185,15 +151,17 @@ async def annotate_traces(
185
151
 
186
152
  precursors = [d.as_precursor(user_id=user_id) for d in request_body.data]
187
153
  if not sync:
188
- await request.state.enqueue(*precursors)
154
+ await request.state.enqueue_annotations(*precursors)
189
155
  return AnnotateTracesResponseBody(data=[])
190
156
 
191
157
  trace_ids = {p.trace_id for p in precursors}
192
158
  async with request.app.state.db() as session:
193
159
  existing_traces = {
194
- trace.trace_id: trace.id
195
- async for trace in await session.stream_scalars(
196
- select(models.Trace).filter(models.Trace.trace_id.in_(trace_ids))
160
+ trace_id: id_
161
+ async for trace_id, id_ in await session.stream(
162
+ select(models.Trace.trace_id, models.Trace.id).filter(
163
+ models.Trace.trace_id.in_(trace_ids)
164
+ )
197
165
  )
198
166
  }
199
167
 
@@ -201,13 +169,19 @@ async def annotate_traces(
201
169
  if missing_trace_ids:
202
170
  raise HTTPException(
203
171
  detail=f"Traces with IDs {', '.join(missing_trace_ids)} do not exist.",
204
- status_code=HTTP_404_NOT_FOUND,
172
+ status_code=404,
205
173
  )
206
174
  inserted_ids = []
175
+ dialect = SupportedSQLDialect(session.bind.dialect.name)
207
176
  for p in precursors:
208
177
  values = dict(as_kv(p.as_insertable(existing_traces[p.trace_id]).row))
209
178
  trace_annotation_id = await session.scalar(
210
- insert(models.TraceAnnotation).values(**values).returning(models.TraceAnnotation.id)
179
+ insert_on_conflict(
180
+ values,
181
+ dialect=dialect,
182
+ table=models.TraceAnnotation,
183
+ unique_by=("name", "trace_rowid", "identifier"),
184
+ ).returning(models.TraceAnnotation.id)
211
185
  )
212
186
  inserted_ids.append(trace_annotation_id)
213
187
  request.state.event_queue.put(TraceAnnotationInsertEvent(tuple(inserted_ids)))
@@ -225,7 +199,7 @@ async def _add_spans(req: ExportTraceServiceRequest, state: State) -> None:
225
199
  for scope_span in resource_spans.scope_spans:
226
200
  for otlp_span in scope_span.spans:
227
201
  span = await run_in_threadpool(decode_otlp_span, otlp_span)
228
- await state.queue_span_for_bulk_insert(span, project_name)
202
+ await state.enqueue_span(span, project_name)
229
203
 
230
204
 
231
205
  @router.delete(
@@ -238,7 +212,7 @@ async def _add_spans(req: ExportTraceServiceRequest, state: State) -> None:
238
212
  "2. An OpenTelemetry trace_id (hex string)\n\n"
239
213
  "This will permanently remove all spans in the trace and their associated data."
240
214
  ),
241
- responses=add_errors_to_responses([HTTP_404_NOT_FOUND]),
215
+ responses=add_errors_to_responses([404]),
242
216
  status_code=204, # No Content for successful deletion
243
217
  )
244
218
  async def delete_trace(
@@ -286,7 +260,7 @@ async def delete_trace(
286
260
 
287
261
  if project_id is None:
288
262
  raise HTTPException(
289
- status_code=HTTP_404_NOT_FOUND,
263
+ status_code=404,
290
264
  detail=error_detail,
291
265
  )
292
266