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)