diff --git a/openapi/ga/individual/platform.openapi.yaml b/openapi/ga/individual/platform.openapi.yaml
index 194ccf56cc..652870abf7 100644
--- a/openapi/ga/individual/platform.openapi.yaml
+++ b/openapi/ga/individual/platform.openapi.yaml
@@ -4089,6 +4089,88 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
+ /apis/intake/v2/workspaces/{workspace}/spans/groups:
+ get:
+ tags:
+ - Spans
+ summary: List Span Groups
+ operationId: list_span_groups_apis_intake_v2_workspaces__workspace__spans_groups_get
+ parameters:
+ - name: workspace
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Workspace
+ - name: by
+ in: query
+ required: true
+ schema:
+ type: string
+ description: Comma-separated span fields to group by, e.g. trace_id or session_id,trace_id.
+ title: By
+ description: Comma-separated span fields to group by, e.g. trace_id or session_id,trace_id.
+ - name: page
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 1
+ description: Page number.
+ default: 1
+ title: Page
+ description: Page number.
+ - name: page_size
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ description: Page size.
+ default: 10
+ title: Page Size
+ description: Page size.
+ - name: sort
+ in: query
+ required: false
+ schema:
+ allOf:
+ - $ref: '#/components/schemas/SpanGroupSortField'
+ default: -span_count
+ - in: query
+ name: filter
+ style: deepObject
+ required: false
+ explode: true
+ schema:
+ $ref: '#/components/schemas/SpanFilter'
+ description: Filter spans by the same fields as the span list endpoint, then
+ group matching spans by the comma-separated fields in the by query parameter.
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SpanGroupsPage'
+ '400':
+ description: Invalid group-by parameter
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ required:
+ - detail
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
/apis/intake/v2/workspaces/{workspace}/spans/{span_id}:
get:
tags:
@@ -15847,6 +15929,67 @@ components:
description: Filter by span start timestamp.
title: SpanFilter
type: object
+ SpanGroup:
+ properties:
+ group:
+ additionalProperties:
+ type: string
+ type: object
+ title: Group
+ description: Group key values, keyed by the requested group-by fields.
+ span_count:
+ type: integer
+ minimum: 0.0
+ title: Span Count
+ description: Number of matching spans in this group.
+ type: object
+ required:
+ - group
+ - span_count
+ title: SpanGroup
+ SpanGroupBy:
+ type: string
+ enum:
+ - trace_id
+ - session_id
+ title: SpanGroupBy
+ SpanGroupSortField:
+ type: string
+ enum:
+ - span_count
+ - -span_count
+ title: SpanGroupSortField
+ SpanGroupsPage:
+ properties:
+ data:
+ items:
+ $ref: '#/components/schemas/SpanGroup'
+ type: array
+ title: Data
+ pagination:
+ allOf:
+ - $ref: '#/components/schemas/PaginationData'
+ description: Pagination information.
+ sort:
+ title: Sort
+ description: The field on which the results are sorted.
+ type: string
+ filter:
+ title: Filter
+ description: Filtering information.
+ additionalProperties: true
+ type: object
+ grouped_by:
+ items:
+ $ref: '#/components/schemas/SpanGroupBy'
+ type: array
+ title: Grouped By
+ description: Span fields used to group the matching spans.
+ type: object
+ required:
+ - data
+ - grouped_by
+ title: SpanGroupsPage
SpanKind:
type: string
enum:
diff --git a/openapi/ga/openapi.yaml b/openapi/ga/openapi.yaml
index 194ccf56cc..652870abf7 100644
--- a/openapi/ga/openapi.yaml
+++ b/openapi/ga/openapi.yaml
@@ -4089,6 +4089,88 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
+ /apis/intake/v2/workspaces/{workspace}/spans/groups:
+ get:
+ tags:
+ - Spans
+ summary: List Span Groups
+ operationId: list_span_groups_apis_intake_v2_workspaces__workspace__spans_groups_get
+ parameters:
+ - name: workspace
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Workspace
+ - name: by
+ in: query
+ required: true
+ schema:
+ type: string
+ description: Comma-separated span fields to group by, e.g. trace_id or session_id,trace_id.
+ title: By
+ description: Comma-separated span fields to group by, e.g. trace_id or session_id,trace_id.
+ - name: page
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 1
+ description: Page number.
+ default: 1
+ title: Page
+ description: Page number.
+ - name: page_size
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ description: Page size.
+ default: 10
+ title: Page Size
+ description: Page size.
+ - name: sort
+ in: query
+ required: false
+ schema:
+ allOf:
+ - $ref: '#/components/schemas/SpanGroupSortField'
+ default: -span_count
+ - in: query
+ name: filter
+ style: deepObject
+ required: false
+ explode: true
+ schema:
+ $ref: '#/components/schemas/SpanFilter'
+ description: Filter spans by the same fields as the span list endpoint, then
+ group matching spans by the comma-separated fields in the by query parameter.
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SpanGroupsPage'
+ '400':
+ description: Invalid group-by parameter
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ required:
+ - detail
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
/apis/intake/v2/workspaces/{workspace}/spans/{span_id}:
get:
tags:
@@ -15847,6 +15929,67 @@ components:
description: Filter by span start timestamp.
title: SpanFilter
type: object
+ SpanGroup:
+ properties:
+ group:
+ additionalProperties:
+ type: string
+ type: object
+ title: Group
+ description: Group key values, keyed by the requested group-by fields.
+ span_count:
+ type: integer
+ minimum: 0.0
+ title: Span Count
+ description: Number of matching spans in this group.
+ type: object
+ required:
+ - group
+ - span_count
+ title: SpanGroup
+ SpanGroupBy:
+ type: string
+ enum:
+ - trace_id
+ - session_id
+ title: SpanGroupBy
+ SpanGroupSortField:
+ type: string
+ enum:
+ - span_count
+ - -span_count
+ title: SpanGroupSortField
+ SpanGroupsPage:
+ properties:
+ data:
+ items:
+ $ref: '#/components/schemas/SpanGroup'
+ type: array
+ title: Data
+ pagination:
+ allOf:
+ - $ref: '#/components/schemas/PaginationData'
+ description: Pagination information.
+ sort:
+ title: Sort
+ description: The field on which the results are sorted.
+ type: string
+ filter:
+ title: Filter
+ description: Filtering information.
+ additionalProperties: true
+ type: object
+ grouped_by:
+ items:
+ $ref: '#/components/schemas/SpanGroupBy'
+ type: array
+ title: Grouped By
+ description: Span fields used to group the matching spans.
+ type: object
+ required:
+ - data
+ - grouped_by
+ title: SpanGroupsPage
SpanKind:
type: string
enum:
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index 194ccf56cc..652870abf7 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -4089,6 +4089,88 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
+ /apis/intake/v2/workspaces/{workspace}/spans/groups:
+ get:
+ tags:
+ - Spans
+ summary: List Span Groups
+ operationId: list_span_groups_apis_intake_v2_workspaces__workspace__spans_groups_get
+ parameters:
+ - name: workspace
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Workspace
+ - name: by
+ in: query
+ required: true
+ schema:
+ type: string
+ description: Comma-separated span fields to group by, e.g. trace_id or session_id,trace_id.
+ title: By
+ description: Comma-separated span fields to group by, e.g. trace_id or session_id,trace_id.
+ - name: page
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 1
+ description: Page number.
+ default: 1
+ title: Page
+ description: Page number.
+ - name: page_size
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ description: Page size.
+ default: 10
+ title: Page Size
+ description: Page size.
+ - name: sort
+ in: query
+ required: false
+ schema:
+ allOf:
+ - $ref: '#/components/schemas/SpanGroupSortField'
+ default: -span_count
+ - in: query
+ name: filter
+ style: deepObject
+ required: false
+ explode: true
+ schema:
+ $ref: '#/components/schemas/SpanFilter'
+ description: Filter spans by the same fields as the span list endpoint, then
+ group matching spans by the comma-separated fields in the by query parameter.
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SpanGroupsPage'
+ '400':
+ description: Invalid group-by parameter
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ required:
+ - detail
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
/apis/intake/v2/workspaces/{workspace}/spans/{span_id}:
get:
tags:
@@ -15847,6 +15929,67 @@ components:
description: Filter by span start timestamp.
title: SpanFilter
type: object
+ SpanGroup:
+ properties:
+ group:
+ additionalProperties:
+ type: string
+ type: object
+ title: Group
+ description: Group key values, keyed by the requested group-by fields.
+ span_count:
+ type: integer
+ minimum: 0.0
+ title: Span Count
+ description: Number of matching spans in this group.
+ type: object
+ required:
+ - group
+ - span_count
+ title: SpanGroup
+ SpanGroupBy:
+ type: string
+ enum:
+ - trace_id
+ - session_id
+ title: SpanGroupBy
+ SpanGroupSortField:
+ type: string
+ enum:
+ - span_count
+ - -span_count
+ title: SpanGroupSortField
+ SpanGroupsPage:
+ properties:
+ data:
+ items:
+ $ref: '#/components/schemas/SpanGroup'
+ type: array
+ title: Data
+ pagination:
+ allOf:
+ - $ref: '#/components/schemas/PaginationData'
+ description: Pagination information.
+ sort:
+ title: Sort
+ description: The field on which the results are sorted.
+ type: string
+ filter:
+ title: Filter
+ description: Filtering information.
+ additionalProperties: true
+ type: object
+ grouped_by:
+ items:
+ $ref: '#/components/schemas/SpanGroupBy'
+ type: array
+ title: Grouped By
+ description: Span fields used to group the matching spans.
+ type: object
+ required:
+ - data
+ - grouped_by
+ title: SpanGroupsPage
SpanKind:
type: string
enum:
diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/intake/spans/__init__.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/intake/spans/__init__.py
index aad1cd6e14..ec7bf3b4b2 100644
--- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/intake/spans/__init__.py
+++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/intake/spans/__init__.py
@@ -8,7 +8,7 @@
import typer
-from nemo_platform_ext.cli.commands.api.intake.spans import evaluator_results
+from nemo_platform_ext.cli.commands.api.intake.spans import evaluator_results, groups
from nemo_platform_ext.cli.core.api import build_kwargs, merge_filter_dict
from nemo_platform_ext.cli.core.code_generator import handle_code_generation
from nemo_platform_ext.cli.core.context import CLIContext
@@ -26,6 +26,7 @@
app = create_typer_app(name="spans", help="Manage spans")
app.add_typer(evaluator_results.app, name="evaluator-results")
+app.add_typer(groups.app, name="groups")
@app.command("list")
diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/intake/spans/groups.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/intake/spans/groups.py
new file mode 100644
index 0000000000..d829ee5b9c
--- /dev/null
+++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/intake/spans/groups.py
@@ -0,0 +1,165 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+# NOTE: This file is auto-generated
+from __future__ import annotations
+
+from typing import Annotated, Literal
+
+import typer
+
+from nemo_platform_ext.cli.core.api import build_kwargs, merge_filter_dict
+from nemo_platform_ext.cli.core.code_generator import handle_code_generation
+from nemo_platform_ext.cli.core.context import CLIContext
+from nemo_platform_ext.cli.core.errors import handle_errors
+from nemo_platform_ext.cli.core.formatters import Column, check_output_columns_with_format, format_output
+from nemo_platform_ext.cli.core.help_formatter import collect_warnings, create_typer_app
+from nemo_platform_ext.cli.core.pagination import PaginationType, fetch_all_pages, warn_if_more_pages
+from nemo_platform_ext.cli.core.types import ListOutputFormatOption, NoTruncateOption, OutputColumnsOption
+
+app = create_typer_app(name="groups", help="Manage groups")
+
+
+@app.command("list")
+@collect_warnings
+@handle_errors
+def list_groups(
+ ctx: typer.Context,
+ workspace: Annotated[str | None, typer.Option("--workspace")] = None,
+ by: Annotated[str, typer.Option("--by", help="Comma-separated span fields to group by, e.g.")] = ...,
+ filter: Annotated[
+ str | None,
+ typer.Option(
+ "--filter",
+ metavar="FILTER_JSON",
+ help="Use --filter with JSON for complex/nested queries, or --filter.FIELD options for simple fields. Both can be combined, with field options taking precedence.\nJSON-only fields:\n started_at: {gte: str, lte: str}\n\nFilter spans by the same fields as the span list endpoint, then group matching spans by the comma-separated fields in the by query parameter.",
+ rich_help_panel="Filter Options",
+ ),
+ ] = None,
+ filter_agent_id: Annotated[str | None, typer.Option("--filter.agent-id", rich_help_panel="Filter Options")] = None,
+ filter_agent_name: Annotated[
+ str | None, typer.Option("--filter.agent-name", rich_help_panel="Filter Options")
+ ] = None,
+ filter_dataset_id: Annotated[
+ str | None, typer.Option("--filter.dataset-id", rich_help_panel="Filter Options")
+ ] = None,
+ filter_dataset_name: Annotated[
+ str | None, typer.Option("--filter.dataset-name", rich_help_panel="Filter Options")
+ ] = None,
+ filter_dataset_version: Annotated[
+ str | None, typer.Option("--filter.dataset-version", rich_help_panel="Filter Options")
+ ] = None,
+ filter_evaluation_id: Annotated[
+ str | None, typer.Option("--filter.evaluation-id", rich_help_panel="Filter Options")
+ ] = None,
+ filter_evaluation_run_id: Annotated[
+ str | None, typer.Option("--filter.evaluation-run-id", rich_help_panel="Filter Options")
+ ] = None,
+ filter_evaluation_sha: Annotated[
+ str | None, typer.Option("--filter.evaluation-sha", rich_help_panel="Filter Options")
+ ] = None,
+ filter_kind: Annotated[str | None, typer.Option("--filter.kind", rich_help_panel="Filter Options")] = None,
+ filter_model: Annotated[str | None, typer.Option("--filter.model", rich_help_panel="Filter Options")] = None,
+ filter_parent_span_id: Annotated[
+ str | None, typer.Option("--filter.parent-span-id", rich_help_panel="Filter Options")
+ ] = None,
+ filter_project: Annotated[str | None, typer.Option("--filter.project", rich_help_panel="Filter Options")] = None,
+ filter_prompt_name: Annotated[
+ str | None, typer.Option("--filter.prompt-name", rich_help_panel="Filter Options")
+ ] = None,
+ filter_prompt_version: Annotated[
+ str | None, typer.Option("--filter.prompt-version", rich_help_panel="Filter Options")
+ ] = None,
+ filter_provider: Annotated[str | None, typer.Option("--filter.provider", rich_help_panel="Filter Options")] = None,
+ filter_session_id: Annotated[
+ str | None, typer.Option("--filter.session-id", rich_help_panel="Filter Options")
+ ] = None,
+ filter_source: Annotated[str | None, typer.Option("--filter.source", rich_help_panel="Filter Options")] = None,
+ filter_status: Annotated[str | None, typer.Option("--filter.status", rich_help_panel="Filter Options")] = None,
+ filter_test_case_id: Annotated[
+ str | None, typer.Option("--filter.test-case-id", rich_help_panel="Filter Options")
+ ] = None,
+ filter_tool_name: Annotated[
+ str | None, typer.Option("--filter.tool-name", rich_help_panel="Filter Options")
+ ] = None,
+ filter_trace_id: Annotated[str | None, typer.Option("--filter.trace-id", rich_help_panel="Filter Options")] = None,
+ page: Annotated[int | None, typer.Option("--page", help="Page number.")] = None,
+ page_size: Annotated[int | None, typer.Option("--page-size", help="Page size.")] = None,
+ sort: Annotated[Literal["span_count", "-span_count"] | None, typer.Option("--sort")] = None,
+ output_format: ListOutputFormatOption = None,
+ no_truncate: NoTruncateOption = None,
+ columns: OutputColumnsOption = None,
+ all_pages: Annotated[bool, typer.Option("--all-pages", help="Fetch all pages")] = False,
+) -> None:
+ """List Span Groups"""
+ state: CLIContext = ctx.obj
+ output_format = state.get_output_format(output_format)
+
+ check_output_columns_with_format(columns, output_format)
+
+ default_columns = [
+ Column("name", None),
+ Column("workspace", None),
+ Column("created_at", None),
+ ]
+ if columns is None or str(columns).strip() == "default":
+ columns = default_columns
+
+ kwargs = build_kwargs(
+ workspace=workspace,
+ by=by,
+ filter=merge_filter_dict(
+ filter,
+ agent_id=filter_agent_id,
+ agent_name=filter_agent_name,
+ dataset_id=filter_dataset_id,
+ dataset_name=filter_dataset_name,
+ dataset_version=filter_dataset_version,
+ evaluation_id=filter_evaluation_id,
+ evaluation_run_id=filter_evaluation_run_id,
+ evaluation_sha=filter_evaluation_sha,
+ kind=filter_kind,
+ model=filter_model,
+ parent_span_id=filter_parent_span_id,
+ project=filter_project,
+ prompt_name=filter_prompt_name,
+ prompt_version=filter_prompt_version,
+ provider=filter_provider,
+ session_id=filter_session_id,
+ source=filter_source,
+ status=filter_status,
+ test_case_id=filter_test_case_id,
+ tool_name=filter_tool_name,
+ trace_id=filter_trace_id,
+ ),
+ page=page,
+ page_size=page_size,
+ sort=sort,
+ )
+
+ if handle_code_generation(["intake", "spans", "groups"], "list", kwargs, output_format, state):
+ return
+
+ client = state.get_client()
+ path_args = ()
+ pagination_type = PaginationType.PAGE_NUMBER
+ if all_pages:
+ items = fetch_all_pages(
+ client.intake.spans.groups.list,
+ path_args=path_args,
+ body_args=kwargs,
+ pagination_type=pagination_type,
+ )
+ else:
+ items = client.intake.spans.groups.list(*path_args, **kwargs)
+
+ format_output(
+ items,
+ is_list=True,
+ output_format=output_format,
+ output_columns=columns,
+ no_truncate=state.get_no_truncate(no_truncate),
+ timestamp_format=state.get_timestamp_format(),
+ )
+ if not all_pages:
+ warn_if_more_pages(items, pagination_type)
diff --git a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml
index 194ccf56cc..652870abf7 100644
--- a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml
+++ b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml
@@ -4089,6 +4089,88 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
+ /apis/intake/v2/workspaces/{workspace}/spans/groups:
+ get:
+ tags:
+ - Spans
+ summary: List Span Groups
+ operationId: list_span_groups_apis_intake_v2_workspaces__workspace__spans_groups_get
+ parameters:
+ - name: workspace
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Workspace
+ - name: by
+ in: query
+ required: true
+ schema:
+ type: string
+ description: Comma-separated span fields to group by, e.g. trace_id or session_id,trace_id.
+ title: By
+ description: Comma-separated span fields to group by, e.g. trace_id or session_id,trace_id.
+ - name: page
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 1
+ description: Page number.
+ default: 1
+ title: Page
+ description: Page number.
+ - name: page_size
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ description: Page size.
+ default: 10
+ title: Page Size
+ description: Page size.
+ - name: sort
+ in: query
+ required: false
+ schema:
+ allOf:
+ - $ref: '#/components/schemas/SpanGroupSortField'
+ default: -span_count
+ - in: query
+ name: filter
+ style: deepObject
+ required: false
+ explode: true
+ schema:
+ $ref: '#/components/schemas/SpanFilter'
+ description: Filter spans by the same fields as the span list endpoint, then
+ group matching spans by the comma-separated fields in the by query parameter.
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SpanGroupsPage'
+ '400':
+ description: Invalid group-by parameter
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ required:
+ - detail
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
/apis/intake/v2/workspaces/{workspace}/spans/{span_id}:
get:
tags:
@@ -15847,6 +15929,67 @@ components:
description: Filter by span start timestamp.
title: SpanFilter
type: object
+ SpanGroup:
+ properties:
+ group:
+ additionalProperties:
+ type: string
+ type: object
+ title: Group
+ description: Group key values, keyed by the requested group-by fields.
+ span_count:
+ type: integer
+ minimum: 0.0
+ title: Span Count
+ description: Number of matching spans in this group.
+ type: object
+ required:
+ - group
+ - span_count
+ title: SpanGroup
+ SpanGroupBy:
+ type: string
+ enum:
+ - trace_id
+ - session_id
+ title: SpanGroupBy
+ SpanGroupSortField:
+ type: string
+ enum:
+ - span_count
+ - -span_count
+ title: SpanGroupSortField
+ SpanGroupsPage:
+ properties:
+ data:
+ items:
+ $ref: '#/components/schemas/SpanGroup'
+ type: array
+ title: Data
+ pagination:
+ allOf:
+ - $ref: '#/components/schemas/PaginationData'
+ description: Pagination information.
+ sort:
+ title: Sort
+ description: The field on which the results are sorted.
+ type: string
+ filter:
+ title: Filter
+ description: Filtering information.
+ additionalProperties: true
+ type: object
+ grouped_by:
+ items:
+ $ref: '#/components/schemas/SpanGroupBy'
+ type: array
+ title: Grouped By
+ description: Span fields used to group the matching spans.
+ type: object
+ required:
+ - data
+ - grouped_by
+ title: SpanGroupsPage
SpanKind:
type: string
enum:
diff --git a/sdk/python/nemo-platform/.nmpcontext/stainless.yaml b/sdk/python/nemo-platform/.nmpcontext/stainless.yaml
index 14dda10ca6..6b143b5183 100644
--- a/sdk/python/nemo-platform/.nmpcontext/stainless.yaml
+++ b/sdk/python/nemo-platform/.nmpcontext/stainless.yaml
@@ -845,6 +845,14 @@ resources:
list: get /apis/intake/v2/workspaces/{workspace}/spans
retrieve: get /apis/intake/v2/workspaces/{workspace}/spans/{span_id}
subresources:
+ groups:
+ models:
+ span_group: SpanGroup
+ span_group_by: SpanGroupBy
+ span_group_sort_field: SpanGroupSortField
+ span_groups_page: SpanGroupsPage
+ methods:
+ list: get /apis/intake/v2/workspaces/{workspace}/spans/groups
evaluator_results:
methods:
list: get /apis/intake/v2/workspaces/{workspace}/spans/{span_id}/evaluator-results
diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/api/intake/spans/__init__.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/api/intake/spans/__init__.py
index d08dd6eeb4..78ef861ad2 100644
--- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/api/intake/spans/__init__.py
+++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/api/intake/spans/__init__.py
@@ -8,7 +8,7 @@
import typer
-from nemo_platform.cli.commands.api.intake.spans import evaluator_results
+from nemo_platform.cli.commands.api.intake.spans import evaluator_results, groups
from nemo_platform.cli.core.api import build_kwargs, merge_filter_dict
from nemo_platform.cli.core.code_generator import handle_code_generation
from nemo_platform.cli.core.context import CLIContext
@@ -26,6 +26,7 @@
app = create_typer_app(name="spans", help="Manage spans")
app.add_typer(evaluator_results.app, name="evaluator-results")
+app.add_typer(groups.app, name="groups")
@app.command("list")
diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/api/intake/spans/groups.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/api/intake/spans/groups.py
new file mode 100644
index 0000000000..55bbd84fe3
--- /dev/null
+++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/api/intake/spans/groups.py
@@ -0,0 +1,165 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+# NOTE: This file is auto-generated
+from __future__ import annotations
+
+from typing import Annotated, Literal
+
+import typer
+
+from nemo_platform.cli.core.api import build_kwargs, merge_filter_dict
+from nemo_platform.cli.core.code_generator import handle_code_generation
+from nemo_platform.cli.core.context import CLIContext
+from nemo_platform.cli.core.errors import handle_errors
+from nemo_platform.cli.core.formatters import Column, check_output_columns_with_format, format_output
+from nemo_platform.cli.core.help_formatter import collect_warnings, create_typer_app
+from nemo_platform.cli.core.pagination import PaginationType, fetch_all_pages, warn_if_more_pages
+from nemo_platform.cli.core.types import ListOutputFormatOption, NoTruncateOption, OutputColumnsOption
+
+app = create_typer_app(name="groups", help="Manage groups")
+
+
+@app.command("list")
+@collect_warnings
+@handle_errors
+def list_groups(
+ ctx: typer.Context,
+ workspace: Annotated[str | None, typer.Option("--workspace")] = None,
+ by: Annotated[str, typer.Option("--by", help="Comma-separated span fields to group by, e.g.")] = ...,
+ filter: Annotated[
+ str | None,
+ typer.Option(
+ "--filter",
+ metavar="FILTER_JSON",
+ help="Use --filter with JSON for complex/nested queries, or --filter.FIELD options for simple fields. Both can be combined, with field options taking precedence.\nJSON-only fields:\n started_at: {gte: str, lte: str}\n\nFilter spans by the same fields as the span list endpoint, then group matching spans by the comma-separated fields in the by query parameter.",
+ rich_help_panel="Filter Options",
+ ),
+ ] = None,
+ filter_agent_id: Annotated[str | None, typer.Option("--filter.agent-id", rich_help_panel="Filter Options")] = None,
+ filter_agent_name: Annotated[
+ str | None, typer.Option("--filter.agent-name", rich_help_panel="Filter Options")
+ ] = None,
+ filter_dataset_id: Annotated[
+ str | None, typer.Option("--filter.dataset-id", rich_help_panel="Filter Options")
+ ] = None,
+ filter_dataset_name: Annotated[
+ str | None, typer.Option("--filter.dataset-name", rich_help_panel="Filter Options")
+ ] = None,
+ filter_dataset_version: Annotated[
+ str | None, typer.Option("--filter.dataset-version", rich_help_panel="Filter Options")
+ ] = None,
+ filter_evaluation_id: Annotated[
+ str | None, typer.Option("--filter.evaluation-id", rich_help_panel="Filter Options")
+ ] = None,
+ filter_evaluation_run_id: Annotated[
+ str | None, typer.Option("--filter.evaluation-run-id", rich_help_panel="Filter Options")
+ ] = None,
+ filter_evaluation_sha: Annotated[
+ str | None, typer.Option("--filter.evaluation-sha", rich_help_panel="Filter Options")
+ ] = None,
+ filter_kind: Annotated[str | None, typer.Option("--filter.kind", rich_help_panel="Filter Options")] = None,
+ filter_model: Annotated[str | None, typer.Option("--filter.model", rich_help_panel="Filter Options")] = None,
+ filter_parent_span_id: Annotated[
+ str | None, typer.Option("--filter.parent-span-id", rich_help_panel="Filter Options")
+ ] = None,
+ filter_project: Annotated[str | None, typer.Option("--filter.project", rich_help_panel="Filter Options")] = None,
+ filter_prompt_name: Annotated[
+ str | None, typer.Option("--filter.prompt-name", rich_help_panel="Filter Options")
+ ] = None,
+ filter_prompt_version: Annotated[
+ str | None, typer.Option("--filter.prompt-version", rich_help_panel="Filter Options")
+ ] = None,
+ filter_provider: Annotated[str | None, typer.Option("--filter.provider", rich_help_panel="Filter Options")] = None,
+ filter_session_id: Annotated[
+ str | None, typer.Option("--filter.session-id", rich_help_panel="Filter Options")
+ ] = None,
+ filter_source: Annotated[str | None, typer.Option("--filter.source", rich_help_panel="Filter Options")] = None,
+ filter_status: Annotated[str | None, typer.Option("--filter.status", rich_help_panel="Filter Options")] = None,
+ filter_test_case_id: Annotated[
+ str | None, typer.Option("--filter.test-case-id", rich_help_panel="Filter Options")
+ ] = None,
+ filter_tool_name: Annotated[
+ str | None, typer.Option("--filter.tool-name", rich_help_panel="Filter Options")
+ ] = None,
+ filter_trace_id: Annotated[str | None, typer.Option("--filter.trace-id", rich_help_panel="Filter Options")] = None,
+ page: Annotated[int | None, typer.Option("--page", help="Page number.")] = None,
+ page_size: Annotated[int | None, typer.Option("--page-size", help="Page size.")] = None,
+ sort: Annotated[Literal["span_count", "-span_count"] | None, typer.Option("--sort")] = None,
+ output_format: ListOutputFormatOption = None,
+ no_truncate: NoTruncateOption = None,
+ columns: OutputColumnsOption = None,
+ all_pages: Annotated[bool, typer.Option("--all-pages", help="Fetch all pages")] = False,
+) -> None:
+ """List Span Groups"""
+ state: CLIContext = ctx.obj
+ output_format = state.get_output_format(output_format)
+
+ check_output_columns_with_format(columns, output_format)
+
+ default_columns = [
+ Column("name", None),
+ Column("workspace", None),
+ Column("created_at", None),
+ ]
+ if columns is None or str(columns).strip() == "default":
+ columns = default_columns
+
+ kwargs = build_kwargs(
+ workspace=workspace,
+ by=by,
+ filter=merge_filter_dict(
+ filter,
+ agent_id=filter_agent_id,
+ agent_name=filter_agent_name,
+ dataset_id=filter_dataset_id,
+ dataset_name=filter_dataset_name,
+ dataset_version=filter_dataset_version,
+ evaluation_id=filter_evaluation_id,
+ evaluation_run_id=filter_evaluation_run_id,
+ evaluation_sha=filter_evaluation_sha,
+ kind=filter_kind,
+ model=filter_model,
+ parent_span_id=filter_parent_span_id,
+ project=filter_project,
+ prompt_name=filter_prompt_name,
+ prompt_version=filter_prompt_version,
+ provider=filter_provider,
+ session_id=filter_session_id,
+ source=filter_source,
+ status=filter_status,
+ test_case_id=filter_test_case_id,
+ tool_name=filter_tool_name,
+ trace_id=filter_trace_id,
+ ),
+ page=page,
+ page_size=page_size,
+ sort=sort,
+ )
+
+ if handle_code_generation(["intake", "spans", "groups"], "list", kwargs, output_format, state):
+ return
+
+ client = state.get_client()
+ path_args = ()
+ pagination_type = PaginationType.PAGE_NUMBER
+ if all_pages:
+ items = fetch_all_pages(
+ client.intake.spans.groups.list,
+ path_args=path_args,
+ body_args=kwargs,
+ pagination_type=pagination_type,
+ )
+ else:
+ items = client.intake.spans.groups.list(*path_args, **kwargs)
+
+ format_output(
+ items,
+ is_list=True,
+ output_format=output_format,
+ output_columns=columns,
+ no_truncate=state.get_no_truncate(no_truncate),
+ timestamp_format=state.get_timestamp_format(),
+ )
+ if not all_pages:
+ warn_if_more_pages(items, pagination_type)
diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/intake/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/intake/api.md
index 8384b9222c..e403f23d72 100644
--- a/sdk/python/nemo-platform/src/nemo_platform/resources/intake/api.md
+++ b/sdk/python/nemo-platform/src/nemo_platform/resources/intake/api.md
@@ -146,6 +146,23 @@ Methods:
- client.intake.spans.retrieve(span_id, \*, workspace) -> Span
- client.intake.spans.list(\*, workspace, \*\*params) -> SyncDefaultPagination[Span]
+### Groups
+
+Types:
+
+```python
+from nemo_platform.types.intake.spans import (
+ SpanGroup,
+ SpanGroupBy,
+ SpanGroupSortField,
+ SpanGroupsPage,
+)
+```
+
+Methods:
+
+- client.intake.spans.groups.list(\*, workspace, \*\*params) -> SyncDefaultPagination[SpanGroup]
+
### EvaluatorResults
Types:
diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/intake/spans/__init__.py b/sdk/python/nemo-platform/src/nemo_platform/resources/intake/spans/__init__.py
index 7c8d6b276c..f27ad1e2d8 100644
--- a/sdk/python/nemo-platform/src/nemo_platform/resources/intake/spans/__init__.py
+++ b/sdk/python/nemo-platform/src/nemo_platform/resources/intake/spans/__init__.py
@@ -23,6 +23,14 @@
SpansResourceWithStreamingResponse,
AsyncSpansResourceWithStreamingResponse,
)
+from .groups import (
+ GroupsResource,
+ AsyncGroupsResource,
+ GroupsResourceWithRawResponse,
+ AsyncGroupsResourceWithRawResponse,
+ GroupsResourceWithStreamingResponse,
+ AsyncGroupsResourceWithStreamingResponse,
+)
from .evaluator_results import (
EvaluatorResultsResource,
AsyncEvaluatorResultsResource,
@@ -33,6 +41,12 @@
)
__all__ = [
+ "GroupsResource",
+ "AsyncGroupsResource",
+ "GroupsResourceWithRawResponse",
+ "AsyncGroupsResourceWithRawResponse",
+ "GroupsResourceWithStreamingResponse",
+ "AsyncGroupsResourceWithStreamingResponse",
"EvaluatorResultsResource",
"AsyncEvaluatorResultsResource",
"EvaluatorResultsResourceWithRawResponse",
diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/intake/spans/groups.py b/sdk/python/nemo-platform/src/nemo_platform/resources/intake/spans/groups.py
new file mode 100644
index 0000000000..1f5618a0c3
--- /dev/null
+++ b/sdk/python/nemo-platform/src/nemo_platform/resources/intake/spans/groups.py
@@ -0,0 +1,245 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from __future__ import annotations
+
+import httpx
+
+from ...._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
+from ...._utils import path_template, maybe_transform
+from ...._compat import cached_property
+from ...._resource import SyncAPIResource, AsyncAPIResource
+from ...._response import (
+ to_raw_response_wrapper,
+ to_streamed_response_wrapper,
+ async_to_raw_response_wrapper,
+ async_to_streamed_response_wrapper,
+)
+from ....pagination import SyncDefaultPagination, AsyncDefaultPagination
+from ...._base_client import AsyncPaginator, make_request_options
+from ....types.intake.spans import SpanGroupSortField, group_list_params
+from ....types.intake.spans.span_group import SpanGroup
+from ....types.intake.span_filter_param import SpanFilterParam
+from ....types.intake.spans.span_group_sort_field import SpanGroupSortField
+
+__all__ = ["GroupsResource", "AsyncGroupsResource"]
+
+
+class GroupsResource(SyncAPIResource):
+ @cached_property
+ def with_raw_response(self) -> GroupsResourceWithRawResponse:
+ """
+ This property can be used as a prefix for any HTTP method call to return
+ the raw response object instead of the parsed content.
+
+ For more information, see https://docs.nvidia.com/nemo/microservices/latest/pysdk/index.html#accessing-raw-response-data-e-g-headers
+ """
+ return GroupsResourceWithRawResponse(self)
+
+ @cached_property
+ def with_streaming_response(self) -> GroupsResourceWithStreamingResponse:
+ """
+ An alternative to `.with_raw_response` that doesn't eagerly read the response body.
+
+ For more information, see https://docs.nvidia.com/nemo/microservices/latest/pysdk/index.html#with_streaming_response
+ """
+ return GroupsResourceWithStreamingResponse(self)
+
+ def list(
+ self,
+ *,
+ workspace: str | None = None,
+ by: str,
+ filter: SpanFilterParam | Omit = omit,
+ page: int | Omit = omit,
+ page_size: int | Omit = omit,
+ sort: SpanGroupSortField | Omit = omit,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> SyncDefaultPagination[SpanGroup]:
+ """List Span Groups
+
+ Args:
+ by: Comma-separated span fields to group by, e.g.
+
+ trace_id or session_id,trace_id.
+
+ filter: Filter spans by the same fields as the span list endpoint, then group matching
+ spans by the comma-separated fields in the by query parameter.
+
+ page: Page number.
+
+ page_size: Page size.
+
+ extra_headers: Send extra headers
+
+ extra_query: Add additional query parameters to the request
+
+ extra_body: Add additional JSON properties to the request
+
+ timeout: Override the client-level default timeout for this request, in seconds
+ """
+ if workspace is None:
+ workspace = self._client._get_workspace_path_param()
+ if not workspace:
+ raise ValueError(f"Expected a non-empty value for `workspace` but received {workspace!r}")
+ return self._get_api_list(
+ path_template("/apis/intake/v2/workspaces/{workspace}/spans/groups", workspace=workspace),
+ page=SyncDefaultPagination[SpanGroup],
+ options=make_request_options(
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ query=maybe_transform(
+ {
+ "by": by,
+ "filter": filter,
+ "page": page,
+ "page_size": page_size,
+ "sort": sort,
+ },
+ group_list_params.GroupListParams,
+ ),
+ ),
+ model=SpanGroup,
+ )
+
+
+class AsyncGroupsResource(AsyncAPIResource):
+ @cached_property
+ def with_raw_response(self) -> AsyncGroupsResourceWithRawResponse:
+ """
+ This property can be used as a prefix for any HTTP method call to return
+ the raw response object instead of the parsed content.
+
+ For more information, see https://docs.nvidia.com/nemo/microservices/latest/pysdk/index.html#accessing-raw-response-data-e-g-headers
+ """
+ return AsyncGroupsResourceWithRawResponse(self)
+
+ @cached_property
+ def with_streaming_response(self) -> AsyncGroupsResourceWithStreamingResponse:
+ """
+ An alternative to `.with_raw_response` that doesn't eagerly read the response body.
+
+ For more information, see https://docs.nvidia.com/nemo/microservices/latest/pysdk/index.html#with_streaming_response
+ """
+ return AsyncGroupsResourceWithStreamingResponse(self)
+
+ def list(
+ self,
+ *,
+ workspace: str | None = None,
+ by: str,
+ filter: SpanFilterParam | Omit = omit,
+ page: int | Omit = omit,
+ page_size: int | Omit = omit,
+ sort: SpanGroupSortField | Omit = omit,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> AsyncPaginator[SpanGroup, AsyncDefaultPagination[SpanGroup]]:
+ """List Span Groups
+
+ Args:
+ by: Comma-separated span fields to group by, e.g.
+
+ trace_id or session_id,trace_id.
+
+ filter: Filter spans by the same fields as the span list endpoint, then group matching
+ spans by the comma-separated fields in the by query parameter.
+
+ page: Page number.
+
+ page_size: Page size.
+
+ extra_headers: Send extra headers
+
+ extra_query: Add additional query parameters to the request
+
+ extra_body: Add additional JSON properties to the request
+
+ timeout: Override the client-level default timeout for this request, in seconds
+ """
+ if workspace is None:
+ workspace = self._client._get_workspace_path_param()
+ if not workspace:
+ raise ValueError(f"Expected a non-empty value for `workspace` but received {workspace!r}")
+ return self._get_api_list(
+ path_template("/apis/intake/v2/workspaces/{workspace}/spans/groups", workspace=workspace),
+ page=AsyncDefaultPagination[SpanGroup],
+ options=make_request_options(
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ query=maybe_transform(
+ {
+ "by": by,
+ "filter": filter,
+ "page": page,
+ "page_size": page_size,
+ "sort": sort,
+ },
+ group_list_params.GroupListParams,
+ ),
+ ),
+ model=SpanGroup,
+ )
+
+
+class GroupsResourceWithRawResponse:
+ def __init__(self, groups: GroupsResource) -> None:
+ self._groups = groups
+
+ self.list = to_raw_response_wrapper(
+ groups.list,
+ )
+
+
+class AsyncGroupsResourceWithRawResponse:
+ def __init__(self, groups: AsyncGroupsResource) -> None:
+ self._groups = groups
+
+ self.list = async_to_raw_response_wrapper(
+ groups.list,
+ )
+
+
+class GroupsResourceWithStreamingResponse:
+ def __init__(self, groups: GroupsResource) -> None:
+ self._groups = groups
+
+ self.list = to_streamed_response_wrapper(
+ groups.list,
+ )
+
+
+class AsyncGroupsResourceWithStreamingResponse:
+ def __init__(self, groups: AsyncGroupsResource) -> None:
+ self._groups = groups
+
+ self.list = async_to_streamed_response_wrapper(
+ groups.list,
+ )
diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/intake/spans/spans.py b/sdk/python/nemo-platform/src/nemo_platform/resources/intake/spans/spans.py
index f1cd07cf0a..dd52e8cf5b 100644
--- a/sdk/python/nemo-platform/src/nemo_platform/resources/intake/spans/spans.py
+++ b/sdk/python/nemo-platform/src/nemo_platform/resources/intake/spans/spans.py
@@ -21,6 +21,14 @@
import httpx
+from .groups import (
+ GroupsResource,
+ AsyncGroupsResource,
+ GroupsResourceWithRawResponse,
+ AsyncGroupsResourceWithRawResponse,
+ GroupsResourceWithStreamingResponse,
+ AsyncGroupsResourceWithStreamingResponse,
+)
from ...._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
from ...._utils import path_template, maybe_transform
from ...._compat import cached_property
@@ -50,6 +58,10 @@
class SpansResource(SyncAPIResource):
+ @cached_property
+ def groups(self) -> GroupsResource:
+ return GroupsResource(self._client)
+
@cached_property
def evaluator_results(self) -> EvaluatorResultsResource:
return EvaluatorResultsResource(self._client)
@@ -177,6 +189,10 @@ def list(
class AsyncSpansResource(AsyncAPIResource):
+ @cached_property
+ def groups(self) -> AsyncGroupsResource:
+ return AsyncGroupsResource(self._client)
+
@cached_property
def evaluator_results(self) -> AsyncEvaluatorResultsResource:
return AsyncEvaluatorResultsResource(self._client)
@@ -314,6 +330,10 @@ def __init__(self, spans: SpansResource) -> None:
spans.list,
)
+ @cached_property
+ def groups(self) -> GroupsResourceWithRawResponse:
+ return GroupsResourceWithRawResponse(self._spans.groups)
+
@cached_property
def evaluator_results(self) -> EvaluatorResultsResourceWithRawResponse:
return EvaluatorResultsResourceWithRawResponse(self._spans.evaluator_results)
@@ -330,6 +350,10 @@ def __init__(self, spans: AsyncSpansResource) -> None:
spans.list,
)
+ @cached_property
+ def groups(self) -> AsyncGroupsResourceWithRawResponse:
+ return AsyncGroupsResourceWithRawResponse(self._spans.groups)
+
@cached_property
def evaluator_results(self) -> AsyncEvaluatorResultsResourceWithRawResponse:
return AsyncEvaluatorResultsResourceWithRawResponse(self._spans.evaluator_results)
@@ -346,6 +370,10 @@ def __init__(self, spans: SpansResource) -> None:
spans.list,
)
+ @cached_property
+ def groups(self) -> GroupsResourceWithStreamingResponse:
+ return GroupsResourceWithStreamingResponse(self._spans.groups)
+
@cached_property
def evaluator_results(self) -> EvaluatorResultsResourceWithStreamingResponse:
return EvaluatorResultsResourceWithStreamingResponse(self._spans.evaluator_results)
@@ -362,6 +390,10 @@ def __init__(self, spans: AsyncSpansResource) -> None:
spans.list,
)
+ @cached_property
+ def groups(self) -> AsyncGroupsResourceWithStreamingResponse:
+ return AsyncGroupsResourceWithStreamingResponse(self._spans.groups)
+
@cached_property
def evaluator_results(self) -> AsyncEvaluatorResultsResourceWithStreamingResponse:
return AsyncEvaluatorResultsResourceWithStreamingResponse(self._spans.evaluator_results)
diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/intake/spans/__init__.py b/sdk/python/nemo-platform/src/nemo_platform/types/intake/spans/__init__.py
index 26a4fc75ff..51f9d92805 100644
--- a/sdk/python/nemo-platform/src/nemo_platform/types/intake/spans/__init__.py
+++ b/sdk/python/nemo-platform/src/nemo_platform/types/intake/spans/__init__.py
@@ -17,4 +17,9 @@
from __future__ import annotations
+from .span_group import SpanGroup as SpanGroup
+from .span_group_by import SpanGroupBy as SpanGroupBy
+from .span_groups_page import SpanGroupsPage as SpanGroupsPage
+from .group_list_params import GroupListParams as GroupListParams
+from .span_group_sort_field import SpanGroupSortField as SpanGroupSortField
from .evaluator_result_list_response import EvaluatorResultListResponse as EvaluatorResultListResponse
diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/intake/spans/group_list_params.py b/sdk/python/nemo-platform/src/nemo_platform/types/intake/spans/group_list_params.py
new file mode 100644
index 0000000000..f636534b69
--- /dev/null
+++ b/sdk/python/nemo-platform/src/nemo_platform/types/intake/spans/group_list_params.py
@@ -0,0 +1,46 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from __future__ import annotations
+
+from typing_extensions import Required, TypedDict
+
+from ..span_filter_param import SpanFilterParam
+from .span_group_sort_field import SpanGroupSortField
+
+__all__ = ["GroupListParams"]
+
+
+class GroupListParams(TypedDict, total=False):
+ workspace: str
+
+ by: Required[str]
+ """Comma-separated span fields to group by, e.g. trace_id or session_id,trace_id."""
+
+ filter: SpanFilterParam
+ """
+ Filter spans by the same fields as the span list endpoint, then group matching
+ spans by the comma-separated fields in the by query parameter.
+ """
+
+ page: int
+ """Page number."""
+
+ page_size: int
+ """Page size."""
+
+ sort: SpanGroupSortField
diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/intake/spans/span_group.py b/sdk/python/nemo-platform/src/nemo_platform/types/intake/spans/span_group.py
new file mode 100644
index 0000000000..e67a79ac43
--- /dev/null
+++ b/sdk/python/nemo-platform/src/nemo_platform/types/intake/spans/span_group.py
@@ -0,0 +1,30 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from typing import Dict
+
+from ...._models import BaseModel
+
+__all__ = ["SpanGroup"]
+
+
+class SpanGroup(BaseModel):
+ group: Dict[str, str]
+ """Group key values, keyed by the requested group-by fields."""
+
+ span_count: int
+ """Number of matching spans in this group."""
diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/intake/spans/span_group_by.py b/sdk/python/nemo-platform/src/nemo_platform/types/intake/spans/span_group_by.py
new file mode 100644
index 0000000000..4f7af5414d
--- /dev/null
+++ b/sdk/python/nemo-platform/src/nemo_platform/types/intake/spans/span_group_by.py
@@ -0,0 +1,22 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from typing_extensions import Literal, TypeAlias
+
+__all__ = ["SpanGroupBy"]
+
+SpanGroupBy: TypeAlias = Literal["trace_id", "session_id"]
diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/intake/spans/span_group_sort_field.py b/sdk/python/nemo-platform/src/nemo_platform/types/intake/spans/span_group_sort_field.py
new file mode 100644
index 0000000000..2796d547cf
--- /dev/null
+++ b/sdk/python/nemo-platform/src/nemo_platform/types/intake/spans/span_group_sort_field.py
@@ -0,0 +1,22 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from typing_extensions import Literal, TypeAlias
+
+__all__ = ["SpanGroupSortField"]
+
+SpanGroupSortField: TypeAlias = Literal["span_count", "-span_count"]
diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/intake/spans/span_groups_page.py b/sdk/python/nemo-platform/src/nemo_platform/types/intake/spans/span_groups_page.py
new file mode 100644
index 0000000000..28afcbc59b
--- /dev/null
+++ b/sdk/python/nemo-platform/src/nemo_platform/types/intake/spans/span_groups_page.py
@@ -0,0 +1,41 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from typing import Dict, List, Optional
+
+from ...._models import BaseModel
+from .span_group import SpanGroup
+from .span_group_by import SpanGroupBy
+from ...shared.pagination_data import PaginationData
+
+__all__ = ["SpanGroupsPage"]
+
+
+class SpanGroupsPage(BaseModel):
+ data: List[SpanGroup]
+
+ grouped_by: List[SpanGroupBy]
+ """Span fields used to group the matching spans."""
+
+ filter: Optional[Dict[str, object]] = None
+ """Filtering information."""
+
+ pagination: Optional[PaginationData] = None
+ """Pagination information."""
+
+ sort: Optional[str] = None
+ """The field on which the results are sorted."""
diff --git a/sdk/python/nemo-platform/tests/api_resources/intake/spans/test_groups.py b/sdk/python/nemo-platform/tests/api_resources/intake/spans/test_groups.py
new file mode 100644
index 0000000000..6b026a3c33
--- /dev/null
+++ b/sdk/python/nemo-platform/tests/api_resources/intake/spans/test_groups.py
@@ -0,0 +1,211 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from __future__ import annotations
+
+import os
+from typing import Any, cast
+
+import pytest
+
+from tests.utils import assert_matches_type
+from nemo_platform import NeMoPlatform, AsyncNeMoPlatform
+from nemo_platform._utils import parse_datetime
+from nemo_platform.pagination import SyncDefaultPagination, AsyncDefaultPagination
+from nemo_platform.types.intake.spans import SpanGroup
+
+base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
+
+
+class TestGroups:
+ parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_method_list(self, client: NeMoPlatform) -> None:
+ group = client.intake.spans.groups.list(
+ workspace="workspace",
+ by="by",
+ )
+ assert_matches_type(SyncDefaultPagination[SpanGroup], group, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_method_list_with_all_params(self, client: NeMoPlatform) -> None:
+ group = client.intake.spans.groups.list(
+ workspace="workspace",
+ by="by",
+ filter={
+ "agent_id": "agent_id",
+ "agent_name": "agent_name",
+ "dataset_id": "dataset_id",
+ "dataset_name": "dataset_name",
+ "dataset_version": "dataset_version",
+ "evaluation_id": "evaluation_id",
+ "evaluation_run_id": "evaluation_run_id",
+ "evaluation_sha": "evaluation_sha",
+ "kind": "LLM",
+ "model": "model",
+ "parent_span_id": "parent_span_id",
+ "project": "project",
+ "prompt_name": "prompt_name",
+ "prompt_version": "prompt_version",
+ "provider": "provider",
+ "session_id": "session_id",
+ "source": "source",
+ "started_at": {
+ "gte": parse_datetime("2019-12-27T18:11:19.117Z"),
+ "lte": parse_datetime("2019-12-27T18:11:19.117Z"),
+ },
+ "status": "success",
+ "test_case_id": "test_case_id",
+ "tool_name": "tool_name",
+ "trace_id": "trace_id",
+ },
+ page=1,
+ page_size=1,
+ sort="span_count",
+ )
+ assert_matches_type(SyncDefaultPagination[SpanGroup], group, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_raw_response_list(self, client: NeMoPlatform) -> None:
+ response = client.intake.spans.groups.with_raw_response.list(
+ workspace="workspace",
+ by="by",
+ )
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ group = response.parse()
+ assert_matches_type(SyncDefaultPagination[SpanGroup], group, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_streaming_response_list(self, client: NeMoPlatform) -> None:
+ with client.intake.spans.groups.with_streaming_response.list(
+ workspace="workspace",
+ by="by",
+ ) as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ group = response.parse()
+ assert_matches_type(SyncDefaultPagination[SpanGroup], group, path=["response"])
+
+ assert cast(Any, response.is_closed) is True
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_path_params_list(self, client: NeMoPlatform) -> None:
+ with pytest.raises(ValueError, match=r"Expected a non-empty value for `workspace` but received ''"):
+ client.intake.spans.groups.with_raw_response.list(
+ workspace="",
+ by="by",
+ )
+
+
+class TestAsyncGroups:
+ parametrize = pytest.mark.parametrize(
+ "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"]
+ )
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_method_list(self, async_client: AsyncNeMoPlatform) -> None:
+ group = await async_client.intake.spans.groups.list(
+ workspace="workspace",
+ by="by",
+ )
+ assert_matches_type(AsyncDefaultPagination[SpanGroup], group, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_method_list_with_all_params(self, async_client: AsyncNeMoPlatform) -> None:
+ group = await async_client.intake.spans.groups.list(
+ workspace="workspace",
+ by="by",
+ filter={
+ "agent_id": "agent_id",
+ "agent_name": "agent_name",
+ "dataset_id": "dataset_id",
+ "dataset_name": "dataset_name",
+ "dataset_version": "dataset_version",
+ "evaluation_id": "evaluation_id",
+ "evaluation_run_id": "evaluation_run_id",
+ "evaluation_sha": "evaluation_sha",
+ "kind": "LLM",
+ "model": "model",
+ "parent_span_id": "parent_span_id",
+ "project": "project",
+ "prompt_name": "prompt_name",
+ "prompt_version": "prompt_version",
+ "provider": "provider",
+ "session_id": "session_id",
+ "source": "source",
+ "started_at": {
+ "gte": parse_datetime("2019-12-27T18:11:19.117Z"),
+ "lte": parse_datetime("2019-12-27T18:11:19.117Z"),
+ },
+ "status": "success",
+ "test_case_id": "test_case_id",
+ "tool_name": "tool_name",
+ "trace_id": "trace_id",
+ },
+ page=1,
+ page_size=1,
+ sort="span_count",
+ )
+ assert_matches_type(AsyncDefaultPagination[SpanGroup], group, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_raw_response_list(self, async_client: AsyncNeMoPlatform) -> None:
+ response = await async_client.intake.spans.groups.with_raw_response.list(
+ workspace="workspace",
+ by="by",
+ )
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ group = await response.parse()
+ assert_matches_type(AsyncDefaultPagination[SpanGroup], group, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_streaming_response_list(self, async_client: AsyncNeMoPlatform) -> None:
+ async with async_client.intake.spans.groups.with_streaming_response.list(
+ workspace="workspace",
+ by="by",
+ ) as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ group = await response.parse()
+ assert_matches_type(AsyncDefaultPagination[SpanGroup], group, path=["response"])
+
+ assert cast(Any, response.is_closed) is True
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_path_params_list(self, async_client: AsyncNeMoPlatform) -> None:
+ with pytest.raises(ValueError, match=r"Expected a non-empty value for `workspace` but received ''"):
+ await async_client.intake.spans.groups.with_raw_response.list(
+ workspace="",
+ by="by",
+ )
diff --git a/sdk/stainless.yaml b/sdk/stainless.yaml
index 14dda10ca6..6b143b5183 100644
--- a/sdk/stainless.yaml
+++ b/sdk/stainless.yaml
@@ -845,6 +845,14 @@ resources:
list: get /apis/intake/v2/workspaces/{workspace}/spans
retrieve: get /apis/intake/v2/workspaces/{workspace}/spans/{span_id}
subresources:
+ groups:
+ models:
+ span_group: SpanGroup
+ span_group_by: SpanGroupBy
+ span_group_sort_field: SpanGroupSortField
+ span_groups_page: SpanGroupsPage
+ methods:
+ list: get /apis/intake/v2/workspaces/{workspace}/spans/groups
evaluator_results:
methods:
list: get /apis/intake/v2/workspaces/{workspace}/spans/{span_id}/evaluator-results
diff --git a/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml b/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml
index be49cf053d..81aa550ef7 100644
--- a/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml
+++ b/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml
@@ -965,6 +965,13 @@ authz:
scopes:
- intake:read
- platform:read
+ /apis/intake/v2/workspaces/{workspace}/spans/groups:
+ get:
+ permissions:
+ - intake.spans.list
+ scopes:
+ - intake:read
+ - platform:read
/apis/intake/v2/workspaces/{workspace}/spans/{span_id}:
get:
permissions:
diff --git a/services/intake/README.md b/services/intake/README.md
index cf8352b223..70fbfae8c3 100644
--- a/services/intake/README.md
+++ b/services/intake/README.md
@@ -9,6 +9,7 @@ post-hoc annotations and evaluator result lookup.
Active v2 workspace endpoints:
- `GET /apis/intake/v2/workspaces/{workspace}/spans`
+- `GET /apis/intake/v2/workspaces/{workspace}/spans/groups`
- `GET /apis/intake/v2/workspaces/{workspace}/spans/{span_id}`
- `GET /apis/intake/v2/workspaces/{workspace}/traces`
- `GET /apis/intake/v2/workspaces/{workspace}/traces/{id}`
diff --git a/services/intake/src/nmp/intake/spans/api/spans.py b/services/intake/src/nmp/intake/spans/api/spans.py
index 7e44978c0d..878bab79e8 100644
--- a/services/intake/src/nmp/intake/spans/api/spans.py
+++ b/services/intake/src/nmp/intake/spans/api/spans.py
@@ -19,7 +19,18 @@
require_enum_value,
require_string_value,
)
-from nmp.intake.spans.api.spans_schemas import Span, SpanFilter, SpanKind, SpanMode, SpanSortField, SpanStatus
+from nmp.intake.spans.api.spans_schemas import (
+ Span,
+ SpanFilter,
+ SpanGroup,
+ SpanGroupBy,
+ SpanGroupSortField,
+ SpanGroupsPage,
+ SpanKind,
+ SpanMode,
+ SpanSortField,
+ SpanStatus,
+)
from nmp.intake.spans.domain import SpanAttributeFilter, SpanListFilter
from nmp.intake.spans.service import SpanNotFoundError
from nmp.intake.spans.storage import utc_now
@@ -90,6 +101,64 @@ async def list_spans(
)
+@router.get(
+ "/v2/workspaces/{workspace}/spans/groups",
+ response_model=SpanGroupsPage,
+ response_model_exclude_none=True,
+ tags=[API_TAG],
+ responses={
+ 400: {
+ "description": "Invalid group-by parameter",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {"detail": {"type": "string"}},
+ "required": ["detail"],
+ }
+ }
+ },
+ }
+ },
+ openapi_extra=generate_openapi_extra_params(
+ filter_schema=SpanFilter,
+ filter_description=(
+ "Filter spans by the same fields as the span list endpoint, then group matching spans by the "
+ "comma-separated fields in the by query parameter."
+ ),
+ ),
+)
+async def list_span_groups(
+ workspace: str,
+ request: Request,
+ service: SpansServiceDep,
+ by: str = Query(description="Comma-separated span fields to group by, e.g. trace_id or session_id,trace_id."),
+ page: int = Query(default=1, ge=1, description="Page number."),
+ page_size: int = Query(default=10, ge=1, le=1000, description="Page size."),
+ sort: SpanGroupSortField = Query(default=SpanGroupSortField.SPAN_COUNT_DESC),
+ parsed: ParsedFilter = Depends(make_filter_dep(SpanFilter)),
+) -> SpanGroupsPage:
+ validate_list_query_params(request, additional_params={"by"})
+ grouped_by = _parse_group_by(by)
+ filters = _span_filter(workspace, parsed)
+ _apply_default_time_bound(filters)
+ result = await service.list_span_groups(
+ filters=filters,
+ group_by=[field.value for field in grouped_by],
+ page=page,
+ page_size=page_size,
+ sort=sort.value,
+ )
+ groups = [SpanGroup.from_domain(group) for group in result.data]
+ return SpanGroupsPage(
+ grouped_by=grouped_by,
+ data=groups,
+ pagination=result.pagination,
+ sort=sort,
+ filter=parsed.to_response(),
+ )
+
+
@router.get(
"/v2/workspaces/{workspace}/spans/{span_id}",
response_model=Span,
@@ -108,6 +177,32 @@ async def get_span(
return Span.from_domain(span)
+def _parse_group_by(value: str) -> list[SpanGroupBy]:
+ raw_fields = [field.strip() for field in value.split(",") if field.strip()]
+ if not raw_fields:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="At least one group-by field is required.")
+
+ grouped_by: list[SpanGroupBy] = []
+ seen: set[SpanGroupBy] = set()
+ for raw_field in raw_fields:
+ try:
+ field = SpanGroupBy(raw_field)
+ except ValueError:
+ allowed = ", ".join(item.value for item in SpanGroupBy)
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Unsupported span group-by field: {raw_field}. Allowed fields: {allowed}",
+ ) from None
+ if field in seen:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Duplicate span group-by field: {field.value}",
+ )
+ grouped_by.append(field)
+ seen.add(field)
+ return grouped_by
+
+
def _span_filter(workspace: str, parsed: ParsedFilter) -> SpanListFilter:
filters = SpanListFilter(workspace=workspace)
for comparison in filter_comparisons(parsed):
diff --git a/services/intake/src/nmp/intake/spans/api/spans_schemas.py b/services/intake/src/nmp/intake/spans/api/spans_schemas.py
index d7d8c56286..82f7dda962 100644
--- a/services/intake/src/nmp/intake/spans/api/spans_schemas.py
+++ b/services/intake/src/nmp/intake/spans/api/spans_schemas.py
@@ -10,8 +10,10 @@
from enum import StrEnum
from typing import Any, Literal, Self
+from nmp.common.api.common import Page
from nmp.common.entities.values import DatetimeFilter
from nmp.intake.spans.domain import IntakeSpan, SpanKind, SpanStatus
+from nmp.intake.spans.domain import SpanGroup as IntakeSpanGroup
from nmp.intake.spans.span_attribute_bags import SpanAttributeBags
from nmp.intake.spans.span_semantic_attributes import SpanSemanticAttributes
from pydantic import BaseModel, ConfigDict, Field
@@ -22,6 +24,16 @@ class SpanSortField(StrEnum):
STARTED_AT_DESC = "-started_at"
+class SpanGroupSortField(StrEnum):
+ SPAN_COUNT_ASC = "span_count"
+ SPAN_COUNT_DESC = "-span_count"
+
+
+class SpanGroupBy(StrEnum):
+ TRACE_ID = "trace_id"
+ SESSION_ID = "session_id"
+
+
SpanMode = Literal["summary", "detailed"]
@@ -200,6 +212,19 @@ def from_domain(cls, span: IntakeSpan, *, mode: SpanMode = "detailed") -> Self:
)
+class SpanGroup(BaseModel):
+ group: dict[str, str] = Field(description="Group key values, keyed by the requested group-by fields.")
+ span_count: int = Field(ge=0, description="Number of matching spans in this group.")
+
+ @classmethod
+ def from_domain(cls, group: IntakeSpanGroup) -> Self:
+ return cls(group=group.group, span_count=group.span_count)
+
+
+class SpanGroupsPage(Page[SpanGroup]):
+ grouped_by: list[SpanGroupBy] = Field(description="Span fields used to group the matching spans.")
+
+
def _evaluation_context(
attributes: SpanSemanticAttributes,
attribute_bags: SpanAttributeBags,
diff --git a/services/intake/src/nmp/intake/spans/domain.py b/services/intake/src/nmp/intake/spans/domain.py
index aa197cfc9d..fd10d80e06 100644
--- a/services/intake/src/nmp/intake/spans/domain.py
+++ b/services/intake/src/nmp/intake/spans/domain.py
@@ -82,6 +82,11 @@ class SpanListFilter(BaseModel):
attribute_filters: list[SpanAttributeFilter] = Field(default_factory=list)
+class SpanGroup(BaseModel):
+ group: dict[str, str]
+ span_count: int = Field(ge=0)
+
+
class TraceListFilter(BaseModel):
workspace: str
trace_id: str | None = None
diff --git a/services/intake/src/nmp/intake/spans/service.py b/services/intake/src/nmp/intake/spans/service.py
index fbd64e6c6b..6cf5407b8c 100644
--- a/services/intake/src/nmp/intake/spans/service.py
+++ b/services/intake/src/nmp/intake/spans/service.py
@@ -14,6 +14,7 @@
EvaluatorResultListFilter,
IntakeSpan,
IntakeTrace,
+ SpanGroup,
SpanListFilter,
TraceBatch,
TraceListFilter,
@@ -80,6 +81,23 @@ async def list_spans(
) -> PaginatedResult[IntakeSpan]:
return await self._spans.list_spans(filters=filters, page=page, page_size=page_size, sort=sort)
+ async def list_span_groups(
+ self,
+ *,
+ filters: SpanListFilter,
+ group_by: list[str],
+ page: int,
+ page_size: int,
+ sort: str,
+ ) -> PaginatedResult[SpanGroup]:
+ return await self._spans.list_span_groups(
+ filters=filters,
+ group_by=group_by,
+ page=page,
+ page_size=page_size,
+ sort=sort,
+ )
+
async def get_span(self, *, workspace: str, span_id: str) -> IntakeSpan:
span = await self._spans.get_span(workspace=workspace, span_id=span_id)
if span is None:
diff --git a/services/intake/src/nmp/intake/spans/span_repository.py b/services/intake/src/nmp/intake/spans/span_repository.py
index 370aab0bbc..073c72356d 100644
--- a/services/intake/src/nmp/intake/spans/span_repository.py
+++ b/services/intake/src/nmp/intake/spans/span_repository.py
@@ -5,12 +5,13 @@
from __future__ import annotations
+from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from nmp.common.api.common import PaginatedResult
from nmp.intake.spans.clickhouse_client import ClickHouseSpanClient
-from nmp.intake.spans.domain import IntakeSpan, SpanListFilter
+from nmp.intake.spans.domain import IntakeSpan, SpanGroup, SpanListFilter
from nmp.intake.spans.span_attribute_catalog import where_clause
from nmp.intake.spans.storage import (
dict_to_row,
@@ -47,9 +48,21 @@
"started_at": "start_time",
}
+SPAN_GROUP_COLUMN_FIELDS = {
+ "trace_id": "trace_id",
+ "session_id": "session_id",
+}
+
_ZERO_DATETIME = datetime.fromtimestamp(0, tz=timezone.utc)
+@dataclass(frozen=True)
+class _GroupExpression:
+ select_sql: str
+ group_sql: str
+ required_sql: str
+
+
class SpanRepository:
def __init__(self, client: ClickHouseSpanClient) -> None:
self._client = client
@@ -93,6 +106,56 @@ async def list_spans(
),
)
+ async def list_span_groups(
+ self,
+ *,
+ filters: SpanListFilter,
+ group_by: list[str],
+ page: int,
+ page_size: int,
+ sort: str,
+ ) -> PaginatedResult[SpanGroup]:
+ group_expressions = _group_expressions(group_by)
+ where_sql, parameters = _span_where(filters)
+
+ required_sql = " AND ".join(f"({expression.required_sql})" for expression in group_expressions)
+ if required_sql:
+ where_sql = f"{where_sql} AND {required_sql}"
+
+ table = self._client.table("spans")
+ select_sql = ", ".join(expression.select_sql for expression in group_expressions)
+ group_sql = ", ".join(expression.group_sql for expression in group_expressions)
+ grouped_sql = f"""
+ SELECT {select_sql}, count() AS span_count
+ FROM {table} FINAL
+ WHERE {where_sql}
+ GROUP BY {group_sql}
+ """
+
+ total_result = await self._client.query(
+ f"SELECT count() FROM ({grouped_sql}) AS span_groups",
+ parameters=parameters,
+ )
+ total_results = int(total_result.result_rows[0][0])
+ offset = (page - 1) * page_size
+ rows_result = await self._client.query(
+ f"""
+ SELECT *
+ FROM ({grouped_sql}) AS span_groups
+ ORDER BY {_group_order_by(sort, group_by)}
+ LIMIT %(limit)s OFFSET %(offset)s
+ """,
+ parameters={**parameters, "limit": page_size, "offset": offset},
+ )
+ rows = result_rows(rows_result)
+ groups = [_row_to_group(row, group_by=group_by) for row in rows]
+ return PaginatedResult(
+ data=groups,
+ pagination=make_pagination(
+ page=page, page_size=page_size, current_page_size=len(groups), total_results=total_results
+ ),
+ )
+
async def get_span(self, *, workspace: str, span_id: str) -> IntakeSpan | None:
columns_sql = ", ".join(SPAN_COLUMNS)
result = await self._client.query(
@@ -149,6 +212,23 @@ def _span_where(filters: SpanListFilter) -> tuple[str, dict[str, Any]]:
return " AND ".join(clauses), parameters
+def _group_expressions(group_by: list[str]) -> list[_GroupExpression]:
+ if not group_by:
+ raise ValueError("At least one span group-by field is required.")
+ return [_group_expression(field) for field in group_by]
+
+
+def _group_expression(field: str) -> _GroupExpression:
+ if field in SPAN_GROUP_COLUMN_FIELDS:
+ column = SPAN_GROUP_COLUMN_FIELDS[field]
+ return _GroupExpression(
+ select_sql=f"{column} AS {field}",
+ group_sql=column,
+ required_sql=f"{column} != ''",
+ )
+ raise ValueError(f"Unsupported span group-by field: {field}")
+
+
def _order_by(sort: str) -> str:
direction = "DESC" if sort.startswith("-") else "ASC"
field = sort.removeprefix("-")
@@ -158,6 +238,17 @@ def _order_by(sort: str) -> str:
return f"{column} {direction}, id ASC"
+def _group_order_by(sort: str, group_by: list[str]) -> str:
+ direction = "DESC" if sort.startswith("-") else "ASC"
+ field = sort.removeprefix("-")
+ if field != "span_count":
+ raise ValueError(f"Unsupported span group sort field: {field}")
+ group_sort = ", ".join(f"{field} ASC" for field in group_by)
+ if group_sort:
+ return f"span_count {direction}, {group_sort}"
+ return f"span_count {direction}"
+
+
def _span_to_row(span: IntakeSpan) -> dict[str, Any]:
return {
"workspace": span.workspace,
@@ -191,6 +282,13 @@ def _rows_to_spans(rows: list[dict[str, Any]]) -> list[IntakeSpan]:
return [_row_to_span(row, id_by_external=id_by_external) for row in rows]
+def _row_to_group(row: dict[str, Any], *, group_by: list[str]) -> SpanGroup:
+ return SpanGroup(
+ group={field: str(row[field]) for field in group_by},
+ span_count=int(row["span_count"]),
+ )
+
+
def _row_to_span(
row: dict[str, Any],
*,
diff --git a/services/intake/tests/integration/spans/test_spans_read_filters.py b/services/intake/tests/integration/spans/test_spans_read_filters.py
index d5217621d1..c88d7148e7 100644
--- a/services/intake/tests/integration/spans/test_spans_read_filters.py
+++ b/services/intake/tests/integration/spans/test_spans_read_filters.py
@@ -158,5 +158,22 @@ def test_spans_read_filters(client: TestClient, make_otlp_request):
assert parent_response.json()["pagination"]["total_results"] == 1
assert parent_response.json()["data"][0]["name"] == "span-6"
+ group_response = client.get(
+ "/apis/intake/v2/workspaces/default/spans/groups",
+ params={
+ "by": "session_id,trace_id",
+ "filter[kind]": "LLM",
+ "page_size": 20,
+ },
+ )
+ assert group_response.status_code == 200, group_response.text
+ group_payload = group_response.json()
+ assert group_payload["grouped_by"] == ["session_id", "trace_id"]
+ assert group_payload["pagination"]["total_results"] == 2
+ assert group_payload["data"] == [
+ {"group": {"session_id": "conv-a", "trace_id": "0" * 31 + "1"}, "span_count": 2},
+ {"group": {"session_id": "conv-b", "trace_id": "0" * 31 + "1"}, "span_count": 2},
+ ]
+
unsupported_response = client.get("/apis/intake/v2/workspaces/default/spans", params={"session_id": "conv-a"})
assert unsupported_response.status_code == 400
diff --git a/services/intake/tests/integration/test_intake.py b/services/intake/tests/integration/test_intake.py
index 31b0b58153..f5742e6fef 100644
--- a/services/intake/tests/integration/test_intake.py
+++ b/services/intake/tests/integration/test_intake.py
@@ -36,6 +36,8 @@ def test_intake_openapi_keeps_span_era_routes(sdk: NeMoPlatform) -> None:
assert "/apis/intake/v2/workspaces/{workspace}/spans" in paths
assert "get" in paths["/apis/intake/v2/workspaces/{workspace}/spans"]
+ span_groups_operation = paths["/apis/intake/v2/workspaces/{workspace}/spans/groups"]["get"]
+ assert "400" in span_groups_operation["responses"]
assert "/apis/intake/v2/workspaces/{workspace}/spans/{span_id}" in paths
assert "get" in paths["/apis/intake/v2/workspaces/{workspace}/spans/{span_id}"]
assert "/apis/intake/v2/workspaces/{workspace}/traces" in paths
diff --git a/services/intake/tests/test_spans_clickhouse_repository.py b/services/intake/tests/test_spans_clickhouse_repository.py
index 6e69321988..a6ab958537 100644
--- a/services/intake/tests/test_spans_clickhouse_repository.py
+++ b/services/intake/tests/test_spans_clickhouse_repository.py
@@ -7,9 +7,10 @@
from typing import cast
import pytest
+from nmp.intake.spans.api.spans_schemas import SpanGroupBy
from nmp.intake.spans.clickhouse_client import ClickHouseSpanClient
from nmp.intake.spans.domain import SpanListFilter
-from nmp.intake.spans.span_repository import SPAN_COLUMNS, SpanRepository, _order_by
+from nmp.intake.spans.span_repository import SPAN_COLUMNS, SPAN_GROUP_COLUMN_FIELDS, SpanRepository, _order_by
from nmp.intake.spans.storage import make_pagination
@@ -31,10 +32,10 @@ def table(self, name: str) -> str:
async def query(self, query: str, *, parameters: dict[str, object]) -> _QueryResult:
self.queries.append(query)
self.parameters.append(parameters)
- if query.lstrip().startswith("SELECT count()"):
- return _QueryResult([(0,)])
if self.query_results:
return self.query_results.pop(0)
+ if query.lstrip().startswith("SELECT count()"):
+ return _QueryResult([(0,)])
return _QueryResult([])
@@ -112,6 +113,75 @@ async def test_list_spans_reads_final_rows():
assert "FROM spans FINAL" in client.queries[1]
+def test_span_group_by_enum_matches_repository_columns():
+ assert {field.value for field in SpanGroupBy} == set(SPAN_GROUP_COLUMN_FIELDS)
+
+
+@pytest.mark.asyncio
+async def test_list_span_groups_groups_by_columns():
+ client = _Client(
+ query_results=[
+ _QueryResult([(2,)]),
+ _QueryResult(
+ [
+ ("session-a", "trace-a", 3),
+ ("session-b", "trace-b", 1),
+ ],
+ ["session_id", "trace_id", "span_count"],
+ ),
+ ]
+ )
+ repository = _repository(client)
+
+ result = await repository.list_span_groups(
+ filters=SpanListFilter(workspace="workspace-a"),
+ group_by=["session_id", "trace_id"],
+ page=1,
+ page_size=10,
+ sort="-span_count",
+ )
+
+ assert result.pagination.total_results == 2
+ assert result.data[0].group == {"session_id": "session-a", "trace_id": "trace-a"}
+ assert result.data[0].span_count == 3
+ assert "FROM spans FINAL" in client.queries[0]
+ assert "GROUP BY session_id, trace_id" in client.queries[0]
+ assert "ORDER BY span_count DESC, session_id ASC, trace_id ASC" in client.queries[1]
+
+
+@pytest.mark.asyncio
+async def test_list_span_groups_reuses_span_filters():
+ client = _Client()
+ repository = _repository(client)
+
+ await repository.list_span_groups(
+ filters=SpanListFilter(workspace="workspace-a", status="error"),
+ group_by=["trace_id"],
+ page=1,
+ page_size=10,
+ sort="-span_count",
+ )
+
+ assert "status = %(status)s" in client.queries[0]
+ assert "GROUP BY trace_id" in client.queries[0]
+ assert client.parameters[0]["status"] == "error"
+
+
+@pytest.mark.asyncio
+async def test_list_span_groups_rejects_unsupported_group_field():
+ client = _Client()
+ repository = _repository(client)
+
+ with pytest.raises(ValueError, match="Unsupported span group-by field"):
+ await repository.list_span_groups(
+ filters=SpanListFilter(workspace="workspace-a"),
+ group_by=["trace_id; DROP TABLE spans"],
+ page=1,
+ page_size=10,
+ sort="-span_count",
+ )
+
+
@pytest.mark.asyncio
async def test_get_span_prefers_external_span_id_over_numeric_internal_id():
row = _span_row(internal_id=7, external_span_id="123")
diff --git a/services/intake/tests/test_spans_schemas.py b/services/intake/tests/test_spans_schemas.py
index 3c73afbe32..7f846801a0 100644
--- a/services/intake/tests/test_spans_schemas.py
+++ b/services/intake/tests/test_spans_schemas.py
@@ -7,9 +7,10 @@
from datetime import datetime, timezone
import pytest
-from nmp.intake.spans.api.spans_schemas import Span
+from nmp.intake.spans.api.spans_schemas import Span, SpanGroup
from nmp.intake.spans.api.traces_schemas import Trace
from nmp.intake.spans.domain import IntakeSpan, IntakeTrace, SpanKind, SpanStatus, TraceEvaluationContext
+from nmp.intake.spans.domain import SpanGroup as IntakeSpanGroup
from nmp.intake.spans.storage import json_dumps_preserve
from pydantic import ValidationError
@@ -62,6 +63,15 @@ def test_span_response_raw_attributes_merges_atif_raw_with_unknown_attributes():
}
+def test_span_group_response_maps_group_values():
+ response = SpanGroup.from_domain(
+ IntakeSpanGroup(group={"session_id": "session-a", "trace_id": "trace-a"}, span_count=3)
+ )
+
+ assert response.group == {"session_id": "session-a", "trace_id": "trace-a"}
+ assert response.span_count == 3
+
+
def test_trace_response_maps_core_trace_fields():
started_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
ended_at = datetime(2026, 1, 1, 0, 0, 2, 500000, tzinfo=timezone.utc)