arize-phoenix 11.8.0__py3-none-any.whl → 11.10.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 (30) hide show
  1. {arize_phoenix-11.8.0.dist-info → arize_phoenix-11.10.0.dist-info}/METADATA +1 -1
  2. {arize_phoenix-11.8.0.dist-info → arize_phoenix-11.10.0.dist-info}/RECORD +26 -25
  3. phoenix/db/insertion/span.py +12 -10
  4. phoenix/db/insertion/types.py +9 -2
  5. phoenix/server/api/input_types/CreateProjectInput.py +27 -0
  6. phoenix/server/api/mutations/project_mutations.py +37 -1
  7. phoenix/server/api/mutations/trace_mutations.py +45 -1
  8. phoenix/server/api/types/Project.py +589 -11
  9. phoenix/server/cost_tracking/model_cost_manifest.json +85 -0
  10. phoenix/server/dml_event.py +4 -0
  11. phoenix/server/static/.vite/manifest.json +41 -41
  12. phoenix/server/static/assets/{components-5M9nebi4.js → components-XAeml0-1.js} +400 -326
  13. phoenix/server/static/assets/{index-OU2WTnGN.js → index-D7EtHUpz.js} +37 -9
  14. phoenix/server/static/assets/{pages-DF8rqxJ4.js → pages-CPfaxiKa.js} +642 -437
  15. phoenix/server/static/assets/vendor-CqDb5u4o.css +1 -0
  16. phoenix/server/static/assets/vendor-DhvamIr8.js +939 -0
  17. phoenix/server/static/assets/vendor-arizeai-4fVwwnrI.js +168 -0
  18. phoenix/server/static/assets/{vendor-codemirror-vlcH1_iR.js → vendor-codemirror-DRfFHb57.js} +1 -1
  19. phoenix/server/static/assets/vendor-recharts-w6bSawXG.js +37 -0
  20. phoenix/server/static/assets/{vendor-shiki-BsknB7bv.js → vendor-shiki-CplrhwOk.js} +1 -1
  21. phoenix/server/templates/index.html +3 -4
  22. phoenix/version.py +1 -1
  23. phoenix/server/static/assets/vendor-Bl7CyFDw.js +0 -911
  24. phoenix/server/static/assets/vendor-WIZid84E.css +0 -1
  25. phoenix/server/static/assets/vendor-arizeai-B_viEUUA.js +0 -180
  26. phoenix/server/static/assets/vendor-recharts-C9cQu72o.js +0 -59
  27. {arize_phoenix-11.8.0.dist-info → arize_phoenix-11.10.0.dist-info}/WHEEL +0 -0
  28. {arize_phoenix-11.8.0.dist-info → arize_phoenix-11.10.0.dist-info}/entry_points.txt +0 -0
  29. {arize_phoenix-11.8.0.dist-info → arize_phoenix-11.10.0.dist-info}/licenses/IP_NOTICE +0 -0
  30. {arize_phoenix-11.8.0.dist-info → arize_phoenix-11.10.0.dist-info}/licenses/LICENSE +0 -0
@@ -7,10 +7,11 @@ from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, Optional, c
7
7
  import strawberry
8
8
  from aioitertools.itertools import groupby, islice
9
9
  from openinference.semconv.trace import SpanAttributes
10
- from sqlalchemy import and_, desc, distinct, exists, func, or_, select
10
+ from sqlalchemy import and_, case, desc, distinct, exists, func, or_, select
11
11
  from sqlalchemy.dialects import postgresql, sqlite
12
12
  from sqlalchemy.sql.elements import ColumnElement
13
13
  from sqlalchemy.sql.expression import tuple_
14
+ from sqlalchemy.sql.functions import percentile_cont
14
15
  from strawberry import ID, UNSET, Private, lazy
15
16
  from strawberry.relay import Connection, Edge, Node, NodeID, PageInfo
16
17
  from strawberry.types import Info
@@ -718,6 +719,7 @@ class Project(Node):
718
719
  info: Info[Context, None],
719
720
  time_range: TimeRange,
720
721
  time_bin_config: Optional[TimeBinConfig] = UNSET,
722
+ filter_condition: Optional[str] = UNSET,
721
723
  ) -> SpanCountTimeSeries:
722
724
  if time_range.start is None:
723
725
  raise BadRequest("Start time is required")
@@ -741,7 +743,17 @@ class Project(Node):
741
743
  field = "year"
742
744
  bucket = date_trunc(dialect, field, models.Span.start_time, utc_offset_minutes)
743
745
  stmt = (
744
- select(bucket, func.count(models.Span.id))
746
+ select(
747
+ bucket,
748
+ func.count(models.Span.id).label("total_count"),
749
+ func.sum(case((models.Span.status_code == "OK", 1), else_=0)).label("ok_count"),
750
+ func.sum(case((models.Span.status_code == "ERROR", 1), else_=0)).label(
751
+ "error_count"
752
+ ),
753
+ func.sum(case((models.Span.status_code == "UNSET", 1), else_=0)).label(
754
+ "unset_count"
755
+ ),
756
+ )
745
757
  .join_from(models.Span, models.Trace)
746
758
  .where(models.Trace.project_rowid == self.project_rowid)
747
759
  .group_by(bucket)
@@ -751,21 +763,31 @@ class Project(Node):
751
763
  stmt = stmt.where(time_range.start <= models.Span.start_time)
752
764
  if time_range.end:
753
765
  stmt = stmt.where(models.Span.start_time < time_range.end)
766
+ if filter_condition:
767
+ span_filter = SpanFilter(condition=filter_condition)
768
+ stmt = span_filter(stmt)
754
769
 
755
770
  data = {}
756
771
  async with info.context.db() as session:
757
- async for t, v in await session.stream(stmt):
772
+ async for t, total_count, ok_count, error_count, unset_count in await session.stream(
773
+ stmt
774
+ ):
758
775
  timestamp = _as_datetime(t)
759
- data[timestamp] = TimeSeriesDataPoint(timestamp=timestamp, value=v)
776
+ data[timestamp] = SpanCountTimeSeriesDataPoint(
777
+ timestamp=timestamp,
778
+ ok_count=ok_count,
779
+ error_count=error_count,
780
+ unset_count=unset_count,
781
+ total_count=total_count,
782
+ )
760
783
 
761
784
  data_timestamps: list[datetime] = [data_point.timestamp for data_point in data.values()]
762
785
  min_time = min([*data_timestamps, time_range.start])
763
786
  max_time = max(
764
787
  [
765
788
  *data_timestamps,
766
- *([time_range.end] if time_range.end else []),
789
+ *([time_range.end] if time_range.end else [datetime.now(timezone.utc)]),
767
790
  ],
768
- default=datetime.now(timezone.utc),
769
791
  )
770
792
  for timestamp in get_timestamp_range(
771
793
  start_time=min_time,
@@ -774,7 +796,7 @@ class Project(Node):
774
796
  utc_offset_minutes=utc_offset_minutes,
775
797
  ):
776
798
  if timestamp not in data:
777
- data[timestamp] = TimeSeriesDataPoint(timestamp=timestamp)
799
+ data[timestamp] = SpanCountTimeSeriesDataPoint(timestamp=timestamp)
778
800
  return SpanCountTimeSeries(data=sorted(data.values(), key=lambda x: x.timestamp))
779
801
 
780
802
  @strawberry.field
@@ -827,9 +849,8 @@ class Project(Node):
827
849
  max_time = max(
828
850
  [
829
851
  *data_timestamps,
830
- *([time_range.end] if time_range.end else []),
852
+ *([time_range.end] if time_range.end else [datetime.now(timezone.utc)]),
831
853
  ],
832
- default=datetime.now(timezone.utc),
833
854
  )
834
855
  for timestamp in get_timestamp_range(
835
856
  start_time=min_time,
@@ -841,10 +862,483 @@ class Project(Node):
841
862
  data[timestamp] = TimeSeriesDataPoint(timestamp=timestamp)
842
863
  return TraceCountTimeSeries(data=sorted(data.values(), key=lambda x: x.timestamp))
843
864
 
865
+ @strawberry.field
866
+ async def trace_count_by_status_time_series(
867
+ self,
868
+ info: Info[Context, None],
869
+ time_range: TimeRange,
870
+ time_bin_config: Optional[TimeBinConfig] = UNSET,
871
+ ) -> TraceCountByStatusTimeSeries:
872
+ if time_range.start is None:
873
+ raise BadRequest("Start time is required")
874
+
875
+ dialect = info.context.db.dialect
876
+ utc_offset_minutes = 0
877
+ field: Literal["minute", "hour", "day", "week", "month", "year"] = "hour"
878
+ if time_bin_config:
879
+ utc_offset_minutes = time_bin_config.utc_offset_minutes
880
+ if time_bin_config.scale is TimeBinScale.MINUTE:
881
+ field = "minute"
882
+ elif time_bin_config.scale is TimeBinScale.HOUR:
883
+ field = "hour"
884
+ elif time_bin_config.scale is TimeBinScale.DAY:
885
+ field = "day"
886
+ elif time_bin_config.scale is TimeBinScale.WEEK:
887
+ field = "week"
888
+ elif time_bin_config.scale is TimeBinScale.MONTH:
889
+ field = "month"
890
+ elif time_bin_config.scale is TimeBinScale.YEAR:
891
+ field = "year"
892
+ bucket = date_trunc(dialect, field, models.Trace.start_time, utc_offset_minutes)
893
+ trace_error_status_counts = (
894
+ select(
895
+ models.Span.trace_rowid,
896
+ )
897
+ .where(models.Span.parent_id.is_(None))
898
+ .group_by(models.Span.trace_rowid)
899
+ .having(func.max(models.Span.cumulative_error_count) > 0)
900
+ ).subquery()
901
+ stmt = (
902
+ select(
903
+ bucket,
904
+ func.count(models.Trace.id).label("total_count"),
905
+ func.coalesce(func.count(trace_error_status_counts.c.trace_rowid), 0).label(
906
+ "error_count"
907
+ ),
908
+ )
909
+ .join_from(
910
+ models.Trace,
911
+ trace_error_status_counts,
912
+ onclause=trace_error_status_counts.c.trace_rowid == models.Trace.id,
913
+ isouter=True,
914
+ )
915
+ .where(models.Trace.project_rowid == self.project_rowid)
916
+ .group_by(bucket)
917
+ .order_by(bucket)
918
+ )
919
+ if time_range:
920
+ if time_range.start:
921
+ stmt = stmt.where(time_range.start <= models.Trace.start_time)
922
+ if time_range.end:
923
+ stmt = stmt.where(models.Trace.start_time < time_range.end)
924
+ data: dict[datetime, TraceCountByStatusTimeSeriesDataPoint] = {}
925
+ async with info.context.db() as session:
926
+ async for t, total_count, error_count in await session.stream(stmt):
927
+ timestamp = _as_datetime(t)
928
+ data[timestamp] = TraceCountByStatusTimeSeriesDataPoint(
929
+ timestamp=timestamp,
930
+ ok_count=total_count - error_count,
931
+ error_count=error_count,
932
+ total_count=total_count,
933
+ )
934
+
935
+ data_timestamps: list[datetime] = [data_point.timestamp for data_point in data.values()]
936
+ min_time = min([*data_timestamps, time_range.start])
937
+ max_time = max(
938
+ [
939
+ *data_timestamps,
940
+ *([time_range.end] if time_range.end else [datetime.now(timezone.utc)]),
941
+ ],
942
+ )
943
+ for timestamp in get_timestamp_range(
944
+ start_time=min_time,
945
+ end_time=max_time,
946
+ stride=field,
947
+ utc_offset_minutes=utc_offset_minutes,
948
+ ):
949
+ if timestamp not in data:
950
+ data[timestamp] = TraceCountByStatusTimeSeriesDataPoint(
951
+ timestamp=timestamp,
952
+ ok_count=0,
953
+ error_count=0,
954
+ total_count=0,
955
+ )
956
+ return TraceCountByStatusTimeSeries(data=sorted(data.values(), key=lambda x: x.timestamp))
957
+
958
+ @strawberry.field
959
+ async def trace_latency_ms_percentile_time_series(
960
+ self,
961
+ info: Info[Context, None],
962
+ time_range: TimeRange,
963
+ time_bin_config: Optional[TimeBinConfig] = UNSET,
964
+ ) -> TraceLatencyPercentileTimeSeries:
965
+ if time_range.start is None:
966
+ raise BadRequest("Start time is required")
967
+
968
+ dialect = info.context.db.dialect
969
+ utc_offset_minutes = 0
970
+ field: Literal["minute", "hour", "day", "week", "month", "year"] = "hour"
971
+ if time_bin_config:
972
+ utc_offset_minutes = time_bin_config.utc_offset_minutes
973
+ if time_bin_config.scale is TimeBinScale.MINUTE:
974
+ field = "minute"
975
+ elif time_bin_config.scale is TimeBinScale.HOUR:
976
+ field = "hour"
977
+ elif time_bin_config.scale is TimeBinScale.DAY:
978
+ field = "day"
979
+ elif time_bin_config.scale is TimeBinScale.WEEK:
980
+ field = "week"
981
+ elif time_bin_config.scale is TimeBinScale.MONTH:
982
+ field = "month"
983
+ elif time_bin_config.scale is TimeBinScale.YEAR:
984
+ field = "year"
985
+ bucket = date_trunc(dialect, field, models.Trace.start_time, utc_offset_minutes)
986
+
987
+ stmt = select(bucket).where(models.Trace.project_rowid == self.project_rowid)
988
+ if time_range.start:
989
+ stmt = stmt.where(time_range.start <= models.Trace.start_time)
990
+ if time_range.end:
991
+ stmt = stmt.where(models.Trace.start_time < time_range.end)
992
+
993
+ if dialect is SupportedSQLDialect.POSTGRESQL:
994
+ stmt = stmt.add_columns(
995
+ percentile_cont(0.50).within_group(models.Trace.latency_ms.asc()).label("p50"),
996
+ percentile_cont(0.75).within_group(models.Trace.latency_ms.asc()).label("p75"),
997
+ percentile_cont(0.90).within_group(models.Trace.latency_ms.asc()).label("p90"),
998
+ percentile_cont(0.95).within_group(models.Trace.latency_ms.asc()).label("p95"),
999
+ percentile_cont(0.99).within_group(models.Trace.latency_ms.asc()).label("p99"),
1000
+ percentile_cont(0.999).within_group(models.Trace.latency_ms.asc()).label("p999"),
1001
+ func.max(models.Trace.latency_ms).label("max"),
1002
+ )
1003
+ elif dialect is SupportedSQLDialect.SQLITE:
1004
+ stmt = stmt.add_columns(
1005
+ func.percentile(models.Trace.latency_ms, 50).label("p50"),
1006
+ func.percentile(models.Trace.latency_ms, 75).label("p75"),
1007
+ func.percentile(models.Trace.latency_ms, 90).label("p90"),
1008
+ func.percentile(models.Trace.latency_ms, 95).label("p95"),
1009
+ func.percentile(models.Trace.latency_ms, 99).label("p99"),
1010
+ func.percentile(models.Trace.latency_ms, 99.9).label("p999"),
1011
+ func.max(models.Trace.latency_ms).label("max"),
1012
+ )
1013
+ else:
1014
+ assert_never(dialect)
1015
+
1016
+ stmt = stmt.group_by(bucket).order_by(bucket)
1017
+
1018
+ data: dict[datetime, TraceLatencyMsPercentileTimeSeriesDataPoint] = {}
1019
+ async with info.context.db() as session:
1020
+ async for (
1021
+ bucket_time,
1022
+ p50,
1023
+ p75,
1024
+ p90,
1025
+ p95,
1026
+ p99,
1027
+ p999,
1028
+ max_latency,
1029
+ ) in await session.stream(stmt):
1030
+ timestamp = _as_datetime(bucket_time)
1031
+ data[timestamp] = TraceLatencyMsPercentileTimeSeriesDataPoint(
1032
+ timestamp=timestamp,
1033
+ p50=p50,
1034
+ p75=p75,
1035
+ p90=p90,
1036
+ p95=p95,
1037
+ p99=p99,
1038
+ p999=p999,
1039
+ max=max_latency,
1040
+ )
1041
+
1042
+ data_timestamps: list[datetime] = [data_point.timestamp for data_point in data.values()]
1043
+ min_time = min([*data_timestamps, time_range.start])
1044
+ max_time = max(
1045
+ [
1046
+ *data_timestamps,
1047
+ *([time_range.end] if time_range.end else [datetime.now(timezone.utc)]),
1048
+ ],
1049
+ )
1050
+ for timestamp in get_timestamp_range(
1051
+ start_time=min_time,
1052
+ end_time=max_time,
1053
+ stride=field,
1054
+ utc_offset_minutes=utc_offset_minutes,
1055
+ ):
1056
+ if timestamp not in data:
1057
+ data[timestamp] = TraceLatencyMsPercentileTimeSeriesDataPoint(timestamp=timestamp)
1058
+ return TraceLatencyPercentileTimeSeries(
1059
+ data=sorted(data.values(), key=lambda x: x.timestamp)
1060
+ )
1061
+
1062
+ @strawberry.field
1063
+ async def trace_token_count_time_series(
1064
+ self,
1065
+ info: Info[Context, None],
1066
+ time_range: TimeRange,
1067
+ time_bin_config: Optional[TimeBinConfig] = UNSET,
1068
+ ) -> TraceTokenCountTimeSeries:
1069
+ if time_range.start is None:
1070
+ raise BadRequest("Start time is required")
1071
+
1072
+ dialect = info.context.db.dialect
1073
+ utc_offset_minutes = 0
1074
+ field: Literal["minute", "hour", "day", "week", "month", "year"] = "hour"
1075
+ if time_bin_config:
1076
+ utc_offset_minutes = time_bin_config.utc_offset_minutes
1077
+ if time_bin_config.scale is TimeBinScale.MINUTE:
1078
+ field = "minute"
1079
+ elif time_bin_config.scale is TimeBinScale.HOUR:
1080
+ field = "hour"
1081
+ elif time_bin_config.scale is TimeBinScale.DAY:
1082
+ field = "day"
1083
+ elif time_bin_config.scale is TimeBinScale.WEEK:
1084
+ field = "week"
1085
+ elif time_bin_config.scale is TimeBinScale.MONTH:
1086
+ field = "month"
1087
+ elif time_bin_config.scale is TimeBinScale.YEAR:
1088
+ field = "year"
1089
+ bucket = date_trunc(dialect, field, models.Trace.start_time, utc_offset_minutes)
1090
+ stmt = (
1091
+ select(
1092
+ bucket,
1093
+ func.sum(models.SpanCost.total_tokens),
1094
+ func.sum(models.SpanCost.prompt_tokens),
1095
+ func.sum(models.SpanCost.completion_tokens),
1096
+ )
1097
+ .join_from(
1098
+ models.Trace,
1099
+ models.SpanCost,
1100
+ onclause=models.SpanCost.trace_rowid == models.Trace.id,
1101
+ )
1102
+ .where(models.Trace.project_rowid == self.project_rowid)
1103
+ .group_by(bucket)
1104
+ .order_by(bucket)
1105
+ )
1106
+ if time_range:
1107
+ if time_range.start:
1108
+ stmt = stmt.where(time_range.start <= models.Trace.start_time)
1109
+ if time_range.end:
1110
+ stmt = stmt.where(models.Trace.start_time < time_range.end)
1111
+ data: dict[datetime, TraceTokenCountTimeSeriesDataPoint] = {}
1112
+ async with info.context.db() as session:
1113
+ async for (
1114
+ t,
1115
+ total_tokens,
1116
+ prompt_tokens,
1117
+ completion_tokens,
1118
+ ) in await session.stream(stmt):
1119
+ timestamp = _as_datetime(t)
1120
+ data[timestamp] = TraceTokenCountTimeSeriesDataPoint(
1121
+ timestamp=timestamp,
1122
+ prompt_token_count=prompt_tokens,
1123
+ completion_token_count=completion_tokens,
1124
+ total_token_count=total_tokens,
1125
+ )
1126
+
1127
+ data_timestamps: list[datetime] = [data_point.timestamp for data_point in data.values()]
1128
+ min_time = min([*data_timestamps, time_range.start])
1129
+ max_time = max(
1130
+ [
1131
+ *data_timestamps,
1132
+ *([time_range.end] if time_range.end else [datetime.now(timezone.utc)]),
1133
+ ],
1134
+ )
1135
+ for timestamp in get_timestamp_range(
1136
+ start_time=min_time,
1137
+ end_time=max_time,
1138
+ stride=field,
1139
+ utc_offset_minutes=utc_offset_minutes,
1140
+ ):
1141
+ if timestamp not in data:
1142
+ data[timestamp] = TraceTokenCountTimeSeriesDataPoint(timestamp=timestamp)
1143
+ return TraceTokenCountTimeSeries(data=sorted(data.values(), key=lambda x: x.timestamp))
1144
+
1145
+ @strawberry.field
1146
+ async def trace_token_cost_time_series(
1147
+ self,
1148
+ info: Info[Context, None],
1149
+ time_range: TimeRange,
1150
+ time_bin_config: Optional[TimeBinConfig] = UNSET,
1151
+ ) -> TraceTokenCostTimeSeries:
1152
+ if time_range.start is None:
1153
+ raise BadRequest("Start time is required")
1154
+
1155
+ dialect = info.context.db.dialect
1156
+ utc_offset_minutes = 0
1157
+ field: Literal["minute", "hour", "day", "week", "month", "year"] = "hour"
1158
+ if time_bin_config:
1159
+ utc_offset_minutes = time_bin_config.utc_offset_minutes
1160
+ if time_bin_config.scale is TimeBinScale.MINUTE:
1161
+ field = "minute"
1162
+ elif time_bin_config.scale is TimeBinScale.HOUR:
1163
+ field = "hour"
1164
+ elif time_bin_config.scale is TimeBinScale.DAY:
1165
+ field = "day"
1166
+ elif time_bin_config.scale is TimeBinScale.WEEK:
1167
+ field = "week"
1168
+ elif time_bin_config.scale is TimeBinScale.MONTH:
1169
+ field = "month"
1170
+ elif time_bin_config.scale is TimeBinScale.YEAR:
1171
+ field = "year"
1172
+ bucket = date_trunc(dialect, field, models.Trace.start_time, utc_offset_minutes)
1173
+ stmt = (
1174
+ select(
1175
+ bucket,
1176
+ func.sum(models.SpanCost.total_cost),
1177
+ func.sum(models.SpanCost.prompt_cost),
1178
+ func.sum(models.SpanCost.completion_cost),
1179
+ )
1180
+ .join_from(
1181
+ models.Trace,
1182
+ models.SpanCost,
1183
+ onclause=models.SpanCost.trace_rowid == models.Trace.id,
1184
+ )
1185
+ .where(models.Trace.project_rowid == self.project_rowid)
1186
+ .group_by(bucket)
1187
+ .order_by(bucket)
1188
+ )
1189
+ if time_range:
1190
+ if time_range.start:
1191
+ stmt = stmt.where(time_range.start <= models.Trace.start_time)
1192
+ if time_range.end:
1193
+ stmt = stmt.where(models.Trace.start_time < time_range.end)
1194
+ data: dict[datetime, TraceTokenCostTimeSeriesDataPoint] = {}
1195
+ async with info.context.db() as session:
1196
+ async for (
1197
+ t,
1198
+ total_cost,
1199
+ prompt_cost,
1200
+ completion_cost,
1201
+ ) in await session.stream(stmt):
1202
+ timestamp = _as_datetime(t)
1203
+ data[timestamp] = TraceTokenCostTimeSeriesDataPoint(
1204
+ timestamp=timestamp,
1205
+ prompt_cost=prompt_cost,
1206
+ completion_cost=completion_cost,
1207
+ total_cost=total_cost,
1208
+ )
1209
+
1210
+ data_timestamps: list[datetime] = [data_point.timestamp for data_point in data.values()]
1211
+ min_time = min([*data_timestamps, time_range.start])
1212
+ max_time = max(
1213
+ [
1214
+ *data_timestamps,
1215
+ *([time_range.end] if time_range.end else [datetime.now(timezone.utc)]),
1216
+ ],
1217
+ )
1218
+ for timestamp in get_timestamp_range(
1219
+ start_time=min_time,
1220
+ end_time=max_time,
1221
+ stride=field,
1222
+ utc_offset_minutes=utc_offset_minutes,
1223
+ ):
1224
+ if timestamp not in data:
1225
+ data[timestamp] = TraceTokenCostTimeSeriesDataPoint(timestamp=timestamp)
1226
+ return TraceTokenCostTimeSeries(data=sorted(data.values(), key=lambda x: x.timestamp))
1227
+
1228
+ @strawberry.field
1229
+ async def span_annotation_score_time_series(
1230
+ self,
1231
+ info: Info[Context, None],
1232
+ time_range: TimeRange,
1233
+ time_bin_config: Optional[TimeBinConfig] = UNSET,
1234
+ ) -> SpanAnnotationScoreTimeSeries:
1235
+ if time_range.start is None:
1236
+ raise BadRequest("Start time is required")
1237
+
1238
+ dialect = info.context.db.dialect
1239
+ utc_offset_minutes = 0
1240
+ field: Literal["minute", "hour", "day", "week", "month", "year"] = "hour"
1241
+ if time_bin_config:
1242
+ utc_offset_minutes = time_bin_config.utc_offset_minutes
1243
+ if time_bin_config.scale is TimeBinScale.MINUTE:
1244
+ field = "minute"
1245
+ elif time_bin_config.scale is TimeBinScale.HOUR:
1246
+ field = "hour"
1247
+ elif time_bin_config.scale is TimeBinScale.DAY:
1248
+ field = "day"
1249
+ elif time_bin_config.scale is TimeBinScale.WEEK:
1250
+ field = "week"
1251
+ elif time_bin_config.scale is TimeBinScale.MONTH:
1252
+ field = "month"
1253
+ elif time_bin_config.scale is TimeBinScale.YEAR:
1254
+ field = "year"
1255
+ bucket = date_trunc(dialect, field, models.Trace.start_time, utc_offset_minutes)
1256
+ stmt = (
1257
+ select(
1258
+ bucket,
1259
+ models.SpanAnnotation.name,
1260
+ func.avg(models.SpanAnnotation.score).label("average_score"),
1261
+ )
1262
+ .join_from(
1263
+ models.SpanAnnotation,
1264
+ models.Span,
1265
+ onclause=models.SpanAnnotation.span_rowid == models.Span.id,
1266
+ )
1267
+ .join_from(
1268
+ models.Span,
1269
+ models.Trace,
1270
+ onclause=models.Span.trace_rowid == models.Trace.id,
1271
+ )
1272
+ .where(models.Trace.project_rowid == self.project_rowid)
1273
+ .group_by(bucket, models.SpanAnnotation.name)
1274
+ .order_by(bucket)
1275
+ )
1276
+ if time_range:
1277
+ if time_range.start:
1278
+ stmt = stmt.where(time_range.start <= models.Trace.start_time)
1279
+ if time_range.end:
1280
+ stmt = stmt.where(models.Trace.start_time < time_range.end)
1281
+ scores: dict[datetime, dict[str, float]] = {}
1282
+ unique_names: set[str] = set()
1283
+ async with info.context.db() as session:
1284
+ async for (
1285
+ t,
1286
+ name,
1287
+ average_score,
1288
+ ) in await session.stream(stmt):
1289
+ timestamp = _as_datetime(t)
1290
+ if timestamp not in scores:
1291
+ scores[timestamp] = {}
1292
+ scores[timestamp][name] = average_score
1293
+ unique_names.add(name)
1294
+
1295
+ score_timestamps: list[datetime] = [timestamp for timestamp in scores]
1296
+ min_time = min([*score_timestamps, time_range.start])
1297
+ max_time = max(
1298
+ [
1299
+ *score_timestamps,
1300
+ *([time_range.end] if time_range.end else [datetime.now(timezone.utc)]),
1301
+ ],
1302
+ )
1303
+ data: dict[datetime, SpanAnnotationScoreTimeSeriesDataPoint] = {
1304
+ timestamp: SpanAnnotationScoreTimeSeriesDataPoint(
1305
+ timestamp=timestamp,
1306
+ scores_with_labels=[
1307
+ SpanAnnotationScoreWithLabel(label=label, score=scores[timestamp][label])
1308
+ for label in scores[timestamp]
1309
+ ],
1310
+ )
1311
+ for timestamp in score_timestamps
1312
+ }
1313
+ for timestamp in get_timestamp_range(
1314
+ start_time=min_time,
1315
+ end_time=max_time,
1316
+ stride=field,
1317
+ utc_offset_minutes=utc_offset_minutes,
1318
+ ):
1319
+ if timestamp not in data:
1320
+ data[timestamp] = SpanAnnotationScoreTimeSeriesDataPoint(
1321
+ timestamp=timestamp,
1322
+ scores_with_labels=[],
1323
+ )
1324
+ return SpanAnnotationScoreTimeSeries(
1325
+ data=sorted(data.values(), key=lambda x: x.timestamp),
1326
+ names=sorted(list(unique_names)),
1327
+ )
1328
+
1329
+
1330
+ @strawberry.type
1331
+ class SpanCountTimeSeriesDataPoint:
1332
+ timestamp: datetime
1333
+ ok_count: Optional[int] = None
1334
+ error_count: Optional[int] = None
1335
+ unset_count: Optional[int] = None
1336
+ total_count: Optional[int] = None
1337
+
844
1338
 
845
1339
  @strawberry.type
846
- class SpanCountTimeSeries(TimeSeries):
847
- """A time series of span count"""
1340
+ class SpanCountTimeSeries:
1341
+ data: list[SpanCountTimeSeriesDataPoint]
848
1342
 
849
1343
 
850
1344
  @strawberry.type
@@ -852,6 +1346,80 @@ class TraceCountTimeSeries(TimeSeries):
852
1346
  """A time series of trace count"""
853
1347
 
854
1348
 
1349
+ @strawberry.type
1350
+ class TraceCountByStatusTimeSeriesDataPoint:
1351
+ timestamp: datetime
1352
+ ok_count: int
1353
+ error_count: int
1354
+ total_count: int
1355
+
1356
+
1357
+ @strawberry.type
1358
+ class TraceCountByStatusTimeSeries:
1359
+ data: list[TraceCountByStatusTimeSeriesDataPoint]
1360
+
1361
+
1362
+ @strawberry.type
1363
+ class TraceLatencyMsPercentileTimeSeriesDataPoint:
1364
+ timestamp: datetime
1365
+ p50: Optional[float] = None
1366
+ p75: Optional[float] = None
1367
+ p90: Optional[float] = None
1368
+ p95: Optional[float] = None
1369
+ p99: Optional[float] = None
1370
+ p999: Optional[float] = None
1371
+ max: Optional[float] = None
1372
+
1373
+
1374
+ @strawberry.type
1375
+ class TraceLatencyPercentileTimeSeries:
1376
+ data: list[TraceLatencyMsPercentileTimeSeriesDataPoint]
1377
+
1378
+
1379
+ @strawberry.type
1380
+ class TraceTokenCountTimeSeriesDataPoint:
1381
+ timestamp: datetime
1382
+ prompt_token_count: Optional[float] = None
1383
+ completion_token_count: Optional[float] = None
1384
+ total_token_count: Optional[float] = None
1385
+
1386
+
1387
+ @strawberry.type
1388
+ class TraceTokenCountTimeSeries:
1389
+ data: list[TraceTokenCountTimeSeriesDataPoint]
1390
+
1391
+
1392
+ @strawberry.type
1393
+ class TraceTokenCostTimeSeriesDataPoint:
1394
+ timestamp: datetime
1395
+ prompt_cost: Optional[float] = None
1396
+ completion_cost: Optional[float] = None
1397
+ total_cost: Optional[float] = None
1398
+
1399
+
1400
+ @strawberry.type
1401
+ class TraceTokenCostTimeSeries:
1402
+ data: list[TraceTokenCostTimeSeriesDataPoint]
1403
+
1404
+
1405
+ @strawberry.type
1406
+ class SpanAnnotationScoreWithLabel:
1407
+ label: str
1408
+ score: float
1409
+
1410
+
1411
+ @strawberry.type
1412
+ class SpanAnnotationScoreTimeSeriesDataPoint:
1413
+ timestamp: datetime
1414
+ scores_with_labels: list[SpanAnnotationScoreWithLabel]
1415
+
1416
+
1417
+ @strawberry.type
1418
+ class SpanAnnotationScoreTimeSeries:
1419
+ data: list[SpanAnnotationScoreTimeSeriesDataPoint]
1420
+ names: list[str]
1421
+
1422
+
855
1423
  INPUT_VALUE = SpanAttributes.INPUT_VALUE.split(".")
856
1424
  OUTPUT_VALUE = SpanAttributes.OUTPUT_VALUE.split(".")
857
1425
 
@@ -1060,3 +1628,13 @@ async def _paginate_span_by_trace_start_time(
1060
1628
  has_next_page=has_next_page,
1061
1629
  ),
1062
1630
  )
1631
+
1632
+
1633
+ def to_gql_project(project: models.Project) -> Project:
1634
+ """
1635
+ Converts an ORM project to a GraphQL project.
1636
+ """
1637
+ return Project(
1638
+ project_rowid=project.id,
1639
+ db_project=project,
1640
+ )