arize-phoenix 8.32.0__py3-none-any.whl → 9.0.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 (81) hide show
  1. {arize_phoenix-8.32.0.dist-info → arize_phoenix-9.0.0.dist-info}/METADATA +3 -2
  2. {arize_phoenix-8.32.0.dist-info → arize_phoenix-9.0.0.dist-info}/RECORD +78 -58
  3. phoenix/db/constants.py +1 -0
  4. phoenix/db/facilitator.py +55 -0
  5. phoenix/db/insertion/document_annotation.py +31 -13
  6. phoenix/db/insertion/evaluation.py +15 -3
  7. phoenix/db/insertion/helpers.py +2 -1
  8. phoenix/db/insertion/span_annotation.py +26 -9
  9. phoenix/db/insertion/trace_annotation.py +25 -9
  10. phoenix/db/insertion/types.py +7 -0
  11. phoenix/db/migrations/versions/2f9d1a65945f_annotation_config_migration.py +322 -0
  12. phoenix/db/migrations/versions/8a3764fe7f1a_change_jsonb_to_json_for_prompts.py +76 -0
  13. phoenix/db/migrations/versions/bb8139330879_create_project_trace_retention_policies_table.py +77 -0
  14. phoenix/db/models.py +151 -10
  15. phoenix/db/types/annotation_configs.py +97 -0
  16. phoenix/db/types/db_models.py +41 -0
  17. phoenix/db/types/trace_retention.py +267 -0
  18. phoenix/experiments/functions.py +5 -1
  19. phoenix/server/api/auth.py +9 -0
  20. phoenix/server/api/context.py +5 -0
  21. phoenix/server/api/dataloaders/__init__.py +4 -0
  22. phoenix/server/api/dataloaders/annotation_summaries.py +203 -24
  23. phoenix/server/api/dataloaders/project_ids_by_trace_retention_policy_id.py +42 -0
  24. phoenix/server/api/dataloaders/trace_retention_policy_id_by_project_id.py +34 -0
  25. phoenix/server/api/helpers/annotations.py +9 -0
  26. phoenix/server/api/helpers/prompts/models.py +34 -67
  27. phoenix/server/api/input_types/CreateSpanAnnotationInput.py +9 -0
  28. phoenix/server/api/input_types/CreateTraceAnnotationInput.py +3 -0
  29. phoenix/server/api/input_types/PatchAnnotationInput.py +3 -0
  30. phoenix/server/api/input_types/SpanAnnotationFilter.py +67 -0
  31. phoenix/server/api/mutations/__init__.py +6 -0
  32. phoenix/server/api/mutations/annotation_config_mutations.py +413 -0
  33. phoenix/server/api/mutations/dataset_mutations.py +62 -39
  34. phoenix/server/api/mutations/project_trace_retention_policy_mutations.py +245 -0
  35. phoenix/server/api/mutations/span_annotations_mutations.py +272 -70
  36. phoenix/server/api/mutations/trace_annotations_mutations.py +203 -74
  37. phoenix/server/api/queries.py +86 -0
  38. phoenix/server/api/routers/v1/__init__.py +4 -0
  39. phoenix/server/api/routers/v1/annotation_configs.py +449 -0
  40. phoenix/server/api/routers/v1/annotations.py +161 -0
  41. phoenix/server/api/routers/v1/evaluations.py +6 -0
  42. phoenix/server/api/routers/v1/projects.py +1 -50
  43. phoenix/server/api/routers/v1/spans.py +37 -8
  44. phoenix/server/api/routers/v1/traces.py +22 -13
  45. phoenix/server/api/routers/v1/utils.py +60 -0
  46. phoenix/server/api/types/Annotation.py +7 -0
  47. phoenix/server/api/types/AnnotationConfig.py +124 -0
  48. phoenix/server/api/types/AnnotationSource.py +9 -0
  49. phoenix/server/api/types/AnnotationSummary.py +28 -14
  50. phoenix/server/api/types/AnnotatorKind.py +1 -0
  51. phoenix/server/api/types/CronExpression.py +15 -0
  52. phoenix/server/api/types/Evaluation.py +4 -30
  53. phoenix/server/api/types/Project.py +50 -2
  54. phoenix/server/api/types/ProjectTraceRetentionPolicy.py +110 -0
  55. phoenix/server/api/types/Span.py +78 -0
  56. phoenix/server/api/types/SpanAnnotation.py +24 -0
  57. phoenix/server/api/types/Trace.py +2 -2
  58. phoenix/server/api/types/TraceAnnotation.py +23 -0
  59. phoenix/server/app.py +20 -0
  60. phoenix/server/retention.py +76 -0
  61. phoenix/server/static/.vite/manifest.json +36 -36
  62. phoenix/server/static/assets/components-B2MWTXnm.js +4326 -0
  63. phoenix/server/static/assets/{index-B0CbpsxD.js → index-Bfvpea_-.js} +10 -10
  64. phoenix/server/static/assets/pages-CZ2vKu8H.js +7268 -0
  65. phoenix/server/static/assets/vendor-BRDkBC5J.js +903 -0
  66. phoenix/server/static/assets/{vendor-arizeai-CxXYQNUl.js → vendor-arizeai-BvTqp_W8.js} +3 -3
  67. phoenix/server/static/assets/{vendor-codemirror-B0NIFPOL.js → vendor-codemirror-COt9UfW7.js} +1 -1
  68. phoenix/server/static/assets/{vendor-recharts-CrrDFWK1.js → vendor-recharts-BoHX9Hvs.js} +2 -2
  69. phoenix/server/static/assets/{vendor-shiki-C5bJ-RPf.js → vendor-shiki-Cw1dsDAz.js} +1 -1
  70. phoenix/session/client.py +6 -1
  71. phoenix/trace/dsl/filter.py +25 -5
  72. phoenix/trace/dsl/query.py +93 -13
  73. phoenix/utilities/__init__.py +18 -0
  74. phoenix/version.py +1 -1
  75. phoenix/server/static/assets/components-x-gKFJ8C.js +0 -3414
  76. phoenix/server/static/assets/pages-BU4VdyeH.js +0 -5867
  77. phoenix/server/static/assets/vendor-BfhM_F1u.js +0 -902
  78. {arize_phoenix-8.32.0.dist-info → arize_phoenix-9.0.0.dist-info}/WHEEL +0 -0
  79. {arize_phoenix-8.32.0.dist-info → arize_phoenix-9.0.0.dist-info}/entry_points.txt +0 -0
  80. {arize_phoenix-8.32.0.dist-info → arize_phoenix-9.0.0.dist-info}/licenses/IP_NOTICE +0 -0
  81. {arize_phoenix-8.32.0.dist-info → arize_phoenix-9.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated, Optional, Union
4
+
5
+ import strawberry
6
+ from strawberry import UNSET, Private
7
+ from strawberry.relay import Connection, Node, NodeID
8
+ from strawberry.types import Info
9
+ from typing_extensions import TypeAlias, assert_never
10
+
11
+ from phoenix.db import models
12
+ from phoenix.db.types.trace_retention import MaxCountRule, MaxDaysOrCountRule, MaxDaysRule
13
+ from phoenix.server.api.context import Context
14
+ from phoenix.server.api.types.CronExpression import CronExpression
15
+ from phoenix.server.api.types.pagination import ConnectionArgs, CursorString, connection_from_list
16
+ from phoenix.server.api.types.Project import Project
17
+
18
+
19
+ @strawberry.type
20
+ class TraceRetentionRuleMaxDays:
21
+ max_days: float
22
+
23
+
24
+ @strawberry.type
25
+ class TraceRetentionRuleMaxCount:
26
+ max_count: int
27
+
28
+
29
+ @strawberry.type
30
+ class TraceRetentionRuleMaxDaysOrCount(TraceRetentionRuleMaxDays, TraceRetentionRuleMaxCount): ...
31
+
32
+
33
+ TraceRetentionRule: TypeAlias = Annotated[
34
+ Union[TraceRetentionRuleMaxDays, TraceRetentionRuleMaxCount, TraceRetentionRuleMaxDaysOrCount],
35
+ strawberry.union("TraceRetentionRule"),
36
+ ]
37
+
38
+
39
+ @strawberry.type
40
+ class ProjectTraceRetentionPolicy(Node):
41
+ id: NodeID[int]
42
+ db_policy: Private[Optional[models.ProjectTraceRetentionPolicy]] = None
43
+
44
+ @strawberry.field
45
+ async def name(
46
+ self,
47
+ info: Info[Context, None],
48
+ ) -> str:
49
+ if self.db_policy:
50
+ value = self.db_policy.name
51
+ else:
52
+ value = await info.context.data_loaders.project_trace_retention_policy_fields.load(
53
+ (self.id, models.ProjectTraceRetentionPolicy.name),
54
+ )
55
+ return value
56
+
57
+ @strawberry.field
58
+ async def cron_expression(
59
+ self,
60
+ info: Info[Context, None],
61
+ ) -> CronExpression:
62
+ if self.db_policy:
63
+ value = self.db_policy.cron_expression
64
+ else:
65
+ value = await info.context.data_loaders.project_trace_retention_policy_fields.load(
66
+ (self.id, models.ProjectTraceRetentionPolicy.cron_expression),
67
+ )
68
+ return CronExpression(value.root)
69
+
70
+ @strawberry.field
71
+ async def rule(
72
+ self,
73
+ info: Info[Context, None],
74
+ ) -> TraceRetentionRule:
75
+ if self.db_policy:
76
+ value = self.db_policy.rule
77
+ else:
78
+ value = await info.context.data_loaders.project_trace_retention_policy_fields.load(
79
+ (self.id, models.ProjectTraceRetentionPolicy.rule),
80
+ )
81
+ if isinstance(value.root, MaxDaysRule):
82
+ return TraceRetentionRuleMaxDays(max_days=value.root.max_days)
83
+ if isinstance(value.root, MaxCountRule):
84
+ return TraceRetentionRuleMaxCount(max_count=value.root.max_count)
85
+ if isinstance(value.root, MaxDaysOrCountRule):
86
+ return TraceRetentionRuleMaxDaysOrCount(
87
+ max_days=value.root.max_days, max_count=value.root.max_count
88
+ )
89
+ assert_never(value.root)
90
+
91
+ @strawberry.field
92
+ async def projects(
93
+ self,
94
+ info: Info[Context, None],
95
+ first: Optional[int] = 100,
96
+ last: Optional[int] = UNSET,
97
+ after: Optional[CursorString] = UNSET,
98
+ before: Optional[CursorString] = UNSET,
99
+ ) -> Connection[Project]:
100
+ args = ConnectionArgs(
101
+ first=first,
102
+ after=after if isinstance(after, CursorString) else None,
103
+ last=last,
104
+ before=before if isinstance(before, CursorString) else None,
105
+ )
106
+ project_rowids = await info.context.data_loaders.projects_by_trace_retention_policy_id.load(
107
+ self.id
108
+ )
109
+ data = [Project(project_rowid=project_rowid) for project_rowid in project_rowids]
110
+ return connection_from_list(data=data, args=args)
@@ -1,11 +1,14 @@
1
1
  import json
2
2
  from asyncio import gather
3
+ from collections import defaultdict
3
4
  from collections.abc import Mapping
5
+ from dataclasses import asdict, dataclass
4
6
  from datetime import datetime
5
7
  from enum import Enum
6
8
  from typing import TYPE_CHECKING, Any, Iterable, Optional, cast
7
9
 
8
10
  import numpy as np
11
+ import pandas as pd
9
12
  import strawberry
10
13
  from openinference.semconv.trace import SpanAttributes
11
14
  from strawberry import ID, UNSET
@@ -21,10 +24,15 @@ from phoenix.server.api.helpers.dataset_helpers import (
21
24
  get_dataset_example_output,
22
25
  )
23
26
  from phoenix.server.api.input_types.InvocationParameters import InvocationParameter
27
+ from phoenix.server.api.input_types.SpanAnnotationFilter import (
28
+ SpanAnnotationFilter,
29
+ satisfies_filter,
30
+ )
24
31
  from phoenix.server.api.input_types.SpanAnnotationSort import (
25
32
  SpanAnnotationColumn,
26
33
  SpanAnnotationSort,
27
34
  )
35
+ from phoenix.server.api.types.AnnotationSummary import AnnotationSummary
28
36
  from phoenix.server.api.types.DocumentRetrievalMetrics import DocumentRetrievalMetrics
29
37
  from phoenix.server.api.types.Evaluation import DocumentEvaluation
30
38
  from phoenix.server.api.types.ExampleRevisionInterface import ExampleRevision
@@ -490,11 +498,16 @@ class Span(Node):
490
498
  self,
491
499
  info: Info[Context, None],
492
500
  sort: Optional[SpanAnnotationSort] = UNSET,
501
+ filter: Optional[SpanAnnotationFilter] = None,
493
502
  ) -> list[SpanAnnotation]:
494
503
  span_id = self.span_rowid
495
504
  annotations = await info.context.data_loaders.span_annotations.load(span_id)
496
505
  sort_key = SpanAnnotationColumn.name.value
497
506
  sort_descending = False
507
+ if filter:
508
+ annotations = [
509
+ annotation for annotation in annotations if satisfies_filter(annotation, filter)
510
+ ]
498
511
  if sort:
499
512
  sort_key = sort.col.value
500
513
  sort_descending = sort.dir is SortDir.desc
@@ -503,6 +516,71 @@ class Span(Node):
503
516
  )
504
517
  return [to_gql_span_annotation(annotation) for annotation in annotations]
505
518
 
519
+ @strawberry.field(description=("Notes associated with the span.")) # type: ignore
520
+ async def span_notes(
521
+ self,
522
+ info: Info[Context, None],
523
+ ) -> list[SpanAnnotation]:
524
+ span_id = self.span_rowid
525
+ annotations = await info.context.data_loaders.span_annotations.load(span_id)
526
+ annotations = [annotation for annotation in annotations if annotation.name == "note"]
527
+ annotations.sort(key=lambda annotation: getattr(annotation, "created_at"), reverse=False)
528
+ return [to_gql_span_annotation(annotation) for annotation in annotations]
529
+
530
+ @strawberry.field(description="Summarizes each annotation (by name) associated with the span") # type: ignore
531
+ async def span_annotation_summaries(
532
+ self,
533
+ info: Info[Context, None],
534
+ filter: Optional[SpanAnnotationFilter] = None,
535
+ ) -> list[AnnotationSummary]:
536
+ """
537
+ Retrieves and summarizes annotations associated with this span.
538
+
539
+ This method aggregates annotation data by name and label, calculating metrics
540
+ such as count of occurrences and sum of scores. The results are organized
541
+ into a structured format that can be easily converted to a DataFrame.
542
+
543
+ Args:
544
+ info: GraphQL context information
545
+ filter: Optional filter to apply to annotations before processing
546
+
547
+ Returns:
548
+ A list of AnnotationSummary objects, each containing:
549
+ - name: The name of the annotation
550
+ - data: A list of dictionaries with label statistics
551
+ """
552
+ # Load all annotations for this span from the data loader
553
+ annotations = await info.context.data_loaders.span_annotations.load(self.span_rowid)
554
+
555
+ # Apply filter if provided to narrow down the annotations
556
+ if filter:
557
+ annotations = [
558
+ annotation for annotation in annotations if satisfies_filter(annotation, filter)
559
+ ]
560
+
561
+ @dataclass
562
+ class Metrics:
563
+ record_count: int = 0
564
+ label_count: int = 0
565
+ score_sum: float = 0
566
+ score_count: int = 0
567
+
568
+ summaries: defaultdict[str, defaultdict[Optional[str], Metrics]] = defaultdict(
569
+ lambda: defaultdict(Metrics)
570
+ )
571
+ for annotation in annotations:
572
+ metrics = summaries[annotation.name][annotation.label]
573
+ metrics.record_count += 1
574
+ metrics.label_count += int(annotation.label is not None)
575
+ metrics.score_sum += annotation.score or 0
576
+ metrics.score_count += int(annotation.score is not None)
577
+
578
+ result: list[AnnotationSummary] = []
579
+ for name, label_metrics in summaries.items():
580
+ rows = [{"label": label, **asdict(metrics)} for label, metrics in label_metrics.items()]
581
+ result.append(AnnotationSummary(name=name, df=pd.DataFrame(rows), simple_avg=True))
582
+ return result
583
+
506
584
  @strawberry.field(
507
585
  description="Evaluations of the documents associated with the span, e.g. "
508
586
  "if the span is a RETRIEVER with a list of documents in its RETRIEVAL_DOCUMENTS "
@@ -4,19 +4,26 @@ import strawberry
4
4
  from strawberry import Private
5
5
  from strawberry.relay import GlobalID, Node, NodeID
6
6
  from strawberry.scalars import JSON
7
+ from strawberry.types import Info
7
8
 
8
9
  from phoenix.db import models
10
+ from phoenix.server.api.context import Context
9
11
 
10
12
  from .Annotation import Annotation
13
+ from .AnnotationSource import AnnotationSource
11
14
  from .AnnotatorKind import AnnotatorKind
15
+ from .User import User, to_gql_user
12
16
 
13
17
 
14
18
  @strawberry.type
15
19
  class SpanAnnotation(Node, Annotation):
16
20
  id_attr: NodeID[int]
21
+ user_id: Private[Optional[int]]
17
22
  annotator_kind: AnnotatorKind
18
23
  metadata: JSON
19
24
  span_rowid: Private[Optional[int]]
25
+ source: AnnotationSource
26
+ identifier: str
20
27
 
21
28
  @strawberry.field
22
29
  async def span_id(self) -> GlobalID:
@@ -24,6 +31,18 @@ class SpanAnnotation(Node, Annotation):
24
31
 
25
32
  return GlobalID(type_name=Span.__name__, node_id=str(self.span_rowid))
26
33
 
34
+ @strawberry.field
35
+ async def user(
36
+ self,
37
+ info: Info[Context, None],
38
+ ) -> Optional[User]:
39
+ if self.user_id is None:
40
+ return None
41
+ user = await info.context.data_loaders.users.load(self.user_id)
42
+ if user is None:
43
+ return None
44
+ return to_gql_user(user)
45
+
27
46
 
28
47
  def to_gql_span_annotation(
29
48
  annotation: models.SpanAnnotation,
@@ -33,6 +52,7 @@ def to_gql_span_annotation(
33
52
  """
34
53
  return SpanAnnotation(
35
54
  id_attr=annotation.id,
55
+ user_id=annotation.user_id,
36
56
  span_rowid=annotation.span_rowid,
37
57
  name=annotation.name,
38
58
  annotator_kind=AnnotatorKind(annotation.annotator_kind),
@@ -40,4 +60,8 @@ def to_gql_span_annotation(
40
60
  score=annotation.score,
41
61
  explanation=annotation.explanation,
42
62
  metadata=annotation.metadata_,
63
+ source=AnnotationSource(annotation.source),
64
+ identifier=annotation.identifier,
65
+ created_at=annotation.created_at,
66
+ updated_at=annotation.updated_at,
43
67
  )
@@ -208,13 +208,13 @@ class Trace(Node):
208
208
  return connection_from_list(data=data, args=args)
209
209
 
210
210
  @strawberry.field(description="Annotations associated with the trace.") # type: ignore
211
- async def span_annotations(
211
+ async def trace_annotations(
212
212
  self,
213
213
  info: Info[Context, None],
214
214
  sort: Optional[TraceAnnotationSort] = None,
215
215
  ) -> list[TraceAnnotation]:
216
216
  async with info.context.db() as session:
217
- stmt = select(models.TraceAnnotation).filter_by(span_rowid=self.trace_rowid)
217
+ stmt = select(models.TraceAnnotation).filter_by(trace_rowid=self.trace_rowid)
218
218
  if sort:
219
219
  sort_col = getattr(models.TraceAnnotation, sort.col.value)
220
220
  if sort.dir is SortDir.desc:
@@ -4,14 +4,20 @@ import strawberry
4
4
  from strawberry import Private
5
5
  from strawberry.relay import GlobalID, Node, NodeID
6
6
  from strawberry.scalars import JSON
7
+ from strawberry.types import Info
7
8
 
8
9
  from phoenix.db import models
10
+ from phoenix.server.api.context import Context
9
11
  from phoenix.server.api.types.AnnotatorKind import AnnotatorKind
10
12
 
13
+ from .AnnotationSource import AnnotationSource
14
+ from .User import User, to_gql_user
15
+
11
16
 
12
17
  @strawberry.type
13
18
  class TraceAnnotation(Node):
14
19
  id_attr: NodeID[int]
20
+ user_id: Private[Optional[int]]
15
21
  name: str
16
22
  annotator_kind: AnnotatorKind
17
23
  label: Optional[str]
@@ -19,6 +25,8 @@ class TraceAnnotation(Node):
19
25
  explanation: Optional[str]
20
26
  metadata: JSON
21
27
  trace_rowid: Private[Optional[int]]
28
+ identifier: str
29
+ source: AnnotationSource
22
30
 
23
31
  @strawberry.field
24
32
  async def trace_id(self) -> GlobalID:
@@ -26,6 +34,18 @@ class TraceAnnotation(Node):
26
34
 
27
35
  return GlobalID(type_name=Trace.__name__, node_id=str(self.trace_rowid))
28
36
 
37
+ @strawberry.field
38
+ async def user(
39
+ self,
40
+ info: Info[Context, None],
41
+ ) -> Optional[User]:
42
+ if self.user_id is None:
43
+ return None
44
+ user = await info.context.data_loaders.users.load(self.user_id)
45
+ if user is None:
46
+ return None
47
+ return to_gql_user(user)
48
+
29
49
 
30
50
  def to_gql_trace_annotation(
31
51
  annotation: models.TraceAnnotation,
@@ -35,6 +55,7 @@ def to_gql_trace_annotation(
35
55
  """
36
56
  return TraceAnnotation(
37
57
  id_attr=annotation.id,
58
+ user_id=annotation.user_id,
38
59
  trace_rowid=annotation.trace_rowid,
39
60
  name=annotation.name,
40
61
  annotator_kind=AnnotatorKind(annotation.annotator_kind),
@@ -42,4 +63,6 @@ def to_gql_trace_annotation(
42
63
  score=annotation.score,
43
64
  explanation=annotation.explanation,
44
65
  metadata=annotation.metadata_,
66
+ identifier=annotation.identifier,
67
+ source=AnnotationSource(annotation.source),
45
68
  )
phoenix/server/app.py CHANGED
@@ -88,6 +88,7 @@ from phoenix.server.api.dataloaders import (
88
88
  NumChildSpansDataLoader,
89
89
  NumSpansPerTraceDataLoader,
90
90
  ProjectByNameDataLoader,
91
+ ProjectIdsByTraceRetentionPolicyIdDataLoader,
91
92
  PromptVersionSequenceNumberDataLoader,
92
93
  RecordCountDataLoader,
93
94
  SessionIODataLoader,
@@ -103,6 +104,7 @@ from phoenix.server.api.dataloaders import (
103
104
  TableFieldsDataLoader,
104
105
  TokenCountDataLoader,
105
106
  TraceByTraceIdsDataLoader,
107
+ TraceRetentionPolicyIdByProjectIdDataLoader,
106
108
  TraceRootSpansDataLoader,
107
109
  UserRolesDataLoader,
108
110
  UsersDataLoader,
@@ -123,6 +125,7 @@ from phoenix.server.grpc_server import GrpcServer
123
125
  from phoenix.server.jwt_store import JwtStore
124
126
  from phoenix.server.middleware.gzip import GZipMiddleware
125
127
  from phoenix.server.oauth2 import OAuth2Clients
128
+ from phoenix.server.retention import TraceDataSweeper
126
129
  from phoenix.server.telemetry import initialize_opentelemetry_tracer_provider
127
130
  from phoenix.server.types import (
128
131
  CanGetLastUpdatedAt,
@@ -475,6 +478,7 @@ def _lifespan(
475
478
  db: DbSessionFactory,
476
479
  bulk_inserter: BulkInserter,
477
480
  dml_event_handler: DmlEventHandler,
481
+ trace_data_sweeper: Optional[TraceDataSweeper],
478
482
  token_store: Optional[TokenStore] = None,
479
483
  tracer_provider: Optional["TracerProvider"] = None,
480
484
  enable_prometheus: bool = False,
@@ -507,6 +511,8 @@ def _lifespan(
507
511
  )
508
512
  await stack.enter_async_context(grpc_server)
509
513
  await stack.enter_async_context(dml_event_handler)
514
+ if trace_data_sweeper:
515
+ await stack.enter_async_context(trace_data_sweeper)
510
516
  if scaffolder_config:
511
517
  scaffolder = Scaffolder(
512
518
  config=scaffolder_config,
@@ -633,6 +639,9 @@ def create_graphql_router(
633
639
  num_child_spans=NumChildSpansDataLoader(db),
634
640
  num_spans_per_trace=NumSpansPerTraceDataLoader(db),
635
641
  project_fields=TableFieldsDataLoader(db, models.Project),
642
+ projects_by_trace_retention_policy_id=ProjectIdsByTraceRetentionPolicyIdDataLoader(
643
+ db
644
+ ),
636
645
  prompt_version_sequence_number=PromptVersionSequenceNumberDataLoader(db),
637
646
  record_counts=RecordCountDataLoader(
638
647
  db,
@@ -656,6 +665,12 @@ def create_graphql_router(
656
665
  ),
657
666
  trace_by_trace_ids=TraceByTraceIdsDataLoader(db),
658
667
  trace_fields=TableFieldsDataLoader(db, models.Trace),
668
+ trace_retention_policy_id_by_project_id=TraceRetentionPolicyIdByProjectIdDataLoader(
669
+ db
670
+ ),
671
+ project_trace_retention_policy_fields=TableFieldsDataLoader(
672
+ db, models.ProjectTraceRetentionPolicy
673
+ ),
659
674
  trace_root_spans=TraceRootSpansDataLoader(db),
660
675
  project_by_name=ProjectByNameDataLoader(db),
661
676
  users=UsersDataLoader(db),
@@ -817,6 +832,10 @@ def create_app(
817
832
  cache_for_dataloaders=cache_for_dataloaders,
818
833
  last_updated_at=last_updated_at,
819
834
  )
835
+ trace_data_sweeper = TraceDataSweeper(
836
+ db=db,
837
+ dml_event_handler=dml_event_handler,
838
+ )
820
839
  bulk_inserter = bulk_inserter_factory(
821
840
  db,
822
841
  enable_prometheus=enable_prometheus,
@@ -874,6 +893,7 @@ def create_app(
874
893
  read_only=read_only,
875
894
  bulk_inserter=bulk_inserter,
876
895
  dml_event_handler=dml_event_handler,
896
+ trace_data_sweeper=trace_data_sweeper,
877
897
  token_store=token_store,
878
898
  tracer_provider=tracer_provider,
879
899
  enable_prometheus=enable_prometheus,
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from asyncio import create_task, gather, sleep
4
+ from datetime import datetime, timedelta, timezone
5
+
6
+ import sqlalchemy as sa
7
+ from sqlalchemy.orm import selectinload
8
+
9
+ from phoenix.db.constants import DEFAULT_PROJECT_TRACE_RETENTION_POLICY_ID
10
+ from phoenix.db.models import Project, ProjectTraceRetentionPolicy
11
+ from phoenix.server.dml_event import SpanDeleteEvent
12
+ from phoenix.server.dml_event_handler import DmlEventHandler
13
+ from phoenix.server.types import DaemonTask, DbSessionFactory
14
+ from phoenix.utilities import hour_of_week
15
+
16
+
17
+ class TraceDataSweeper(DaemonTask):
18
+ def __init__(self, db: DbSessionFactory, dml_event_handler: DmlEventHandler):
19
+ super().__init__()
20
+ self._db = db
21
+ self._dml_event_handler = dml_event_handler
22
+
23
+ async def _run(self) -> None:
24
+ """Check hourly and apply policies."""
25
+ while self._running:
26
+ await self._sleep_until_next_hour()
27
+ if not (policies := await self._get_policies()):
28
+ continue
29
+ current_hour = self._current_hour()
30
+ if tasks := [
31
+ create_task(self._apply(policy))
32
+ for policy in policies
33
+ if self._should_apply(policy, current_hour)
34
+ ]:
35
+ await gather(*tasks, return_exceptions=True)
36
+
37
+ async def _get_policies(self) -> list[ProjectTraceRetentionPolicy]:
38
+ stmt = sa.select(ProjectTraceRetentionPolicy).options(
39
+ selectinload(ProjectTraceRetentionPolicy.projects).load_only(Project.id)
40
+ )
41
+ async with self._db() as session:
42
+ result = await session.scalars(stmt)
43
+ # filter out no-op policies, e.g. max_days == 0
44
+ return [policy for policy in result if bool(policy.rule)]
45
+
46
+ @staticmethod
47
+ def _now() -> datetime:
48
+ return datetime.now(timezone.utc)
49
+
50
+ def _current_hour(self) -> int:
51
+ return hour_of_week(self._now())
52
+
53
+ def _should_apply(self, policy: ProjectTraceRetentionPolicy, current_hour: int) -> bool:
54
+ if current_hour != policy.cron_expression.get_hour_of_prev_run():
55
+ return False
56
+ if policy.id != DEFAULT_PROJECT_TRACE_RETENTION_POLICY_ID and not policy.projects:
57
+ return False
58
+ return True
59
+
60
+ async def _apply(self, policy: ProjectTraceRetentionPolicy) -> None:
61
+ project_rowids = (
62
+ (
63
+ sa.select(Project.id)
64
+ .where(Project.trace_retention_policy_id.is_(None))
65
+ .scalar_subquery()
66
+ )
67
+ if policy.id == DEFAULT_PROJECT_TRACE_RETENTION_POLICY_ID
68
+ else [p.id for p in policy.projects]
69
+ )
70
+ async with self._db() as session:
71
+ result = await policy.rule.delete_traces(session, project_rowids)
72
+ self._dml_event_handler.put(SpanDeleteEvent(tuple(result)))
73
+
74
+ async def _sleep_until_next_hour(self) -> None:
75
+ next_hour = self._now().replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
76
+ await sleep((next_hour - self._now()).total_seconds())
@@ -1,28 +1,28 @@
1
1
  {
2
- "_components-x-gKFJ8C.js": {
3
- "file": "assets/components-x-gKFJ8C.js",
2
+ "_components-B2MWTXnm.js": {
3
+ "file": "assets/components-B2MWTXnm.js",
4
4
  "name": "components",
5
5
  "imports": [
6
- "_vendor-BfhM_F1u.js",
7
- "_pages-BU4VdyeH.js",
8
- "_vendor-arizeai-CxXYQNUl.js",
9
- "_vendor-codemirror-B0NIFPOL.js",
6
+ "_vendor-BRDkBC5J.js",
7
+ "_pages-CZ2vKu8H.js",
8
+ "_vendor-arizeai-BvTqp_W8.js",
9
+ "_vendor-codemirror-COt9UfW7.js",
10
10
  "_vendor-three-C5WAXd5r.js"
11
11
  ]
12
12
  },
13
- "_pages-BU4VdyeH.js": {
14
- "file": "assets/pages-BU4VdyeH.js",
13
+ "_pages-CZ2vKu8H.js": {
14
+ "file": "assets/pages-CZ2vKu8H.js",
15
15
  "name": "pages",
16
16
  "imports": [
17
- "_vendor-BfhM_F1u.js",
18
- "_vendor-arizeai-CxXYQNUl.js",
19
- "_components-x-gKFJ8C.js",
20
- "_vendor-codemirror-B0NIFPOL.js",
21
- "_vendor-recharts-CrrDFWK1.js"
17
+ "_vendor-BRDkBC5J.js",
18
+ "_vendor-arizeai-BvTqp_W8.js",
19
+ "_components-B2MWTXnm.js",
20
+ "_vendor-codemirror-COt9UfW7.js",
21
+ "_vendor-recharts-BoHX9Hvs.js"
22
22
  ]
23
23
  },
24
- "_vendor-BfhM_F1u.js": {
25
- "file": "assets/vendor-BfhM_F1u.js",
24
+ "_vendor-BRDkBC5J.js": {
25
+ "file": "assets/vendor-BRDkBC5J.js",
26
26
  "name": "vendor",
27
27
  "imports": [
28
28
  "_vendor-three-C5WAXd5r.js"
@@ -35,33 +35,33 @@
35
35
  "file": "assets/vendor-Cg6lcjUC.css",
36
36
  "src": "_vendor-Cg6lcjUC.css"
37
37
  },
38
- "_vendor-arizeai-CxXYQNUl.js": {
39
- "file": "assets/vendor-arizeai-CxXYQNUl.js",
38
+ "_vendor-arizeai-BvTqp_W8.js": {
39
+ "file": "assets/vendor-arizeai-BvTqp_W8.js",
40
40
  "name": "vendor-arizeai",
41
41
  "imports": [
42
- "_vendor-BfhM_F1u.js"
42
+ "_vendor-BRDkBC5J.js"
43
43
  ]
44
44
  },
45
- "_vendor-codemirror-B0NIFPOL.js": {
46
- "file": "assets/vendor-codemirror-B0NIFPOL.js",
45
+ "_vendor-codemirror-COt9UfW7.js": {
46
+ "file": "assets/vendor-codemirror-COt9UfW7.js",
47
47
  "name": "vendor-codemirror",
48
48
  "imports": [
49
- "_vendor-BfhM_F1u.js",
50
- "_vendor-shiki-C5bJ-RPf.js"
49
+ "_vendor-BRDkBC5J.js",
50
+ "_vendor-shiki-Cw1dsDAz.js"
51
51
  ]
52
52
  },
53
- "_vendor-recharts-CrrDFWK1.js": {
54
- "file": "assets/vendor-recharts-CrrDFWK1.js",
53
+ "_vendor-recharts-BoHX9Hvs.js": {
54
+ "file": "assets/vendor-recharts-BoHX9Hvs.js",
55
55
  "name": "vendor-recharts",
56
56
  "imports": [
57
- "_vendor-BfhM_F1u.js"
57
+ "_vendor-BRDkBC5J.js"
58
58
  ]
59
59
  },
60
- "_vendor-shiki-C5bJ-RPf.js": {
61
- "file": "assets/vendor-shiki-C5bJ-RPf.js",
60
+ "_vendor-shiki-Cw1dsDAz.js": {
61
+ "file": "assets/vendor-shiki-Cw1dsDAz.js",
62
62
  "name": "vendor-shiki",
63
63
  "imports": [
64
- "_vendor-BfhM_F1u.js"
64
+ "_vendor-BRDkBC5J.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-B0CbpsxD.js",
72
+ "file": "assets/index-Bfvpea_-.js",
73
73
  "name": "index",
74
74
  "src": "index.tsx",
75
75
  "isEntry": true,
76
76
  "imports": [
77
- "_vendor-BfhM_F1u.js",
78
- "_vendor-arizeai-CxXYQNUl.js",
79
- "_pages-BU4VdyeH.js",
80
- "_components-x-gKFJ8C.js",
77
+ "_vendor-BRDkBC5J.js",
78
+ "_vendor-arizeai-BvTqp_W8.js",
79
+ "_pages-CZ2vKu8H.js",
80
+ "_components-B2MWTXnm.js",
81
81
  "_vendor-three-C5WAXd5r.js",
82
- "_vendor-codemirror-B0NIFPOL.js",
83
- "_vendor-shiki-C5bJ-RPf.js",
84
- "_vendor-recharts-CrrDFWK1.js"
82
+ "_vendor-codemirror-COt9UfW7.js",
83
+ "_vendor-shiki-Cw1dsDAz.js",
84
+ "_vendor-recharts-BoHX9Hvs.js"
85
85
  ]
86
86
  }
87
87
  }