Compare commits

..

39 Commits

Author SHA1 Message Date
Abhi Kumar
2249367b2e feat(dashboards): rename scatter plot dimensions to colorBy and sizeBy 2026-10-07 18:39:46 +05:30
Abhi Kumar
79d7fb0ef7 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-07 13:56:36 +05:30
Abhi Kumar
2be3488bda feat(dashboards): add point size and opacity to scatter plot spec
The fixed size and the min/max range are both stored so binding or unbinding
the size dimension restores the other's last setting.

Assisted-by: Claude Opus 5.5
2026-10-07 13:56:36 +05:30
Abhi Kumar
bf3a36099f chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-07 13:56:36 +05:30
Abhi Kumar
14833699d5 feat(dashboards): add axis labels and multi-key colour to scatter plot spec
An empty colour list colours by every group-by label.

Assisted-by: Claude Opus 5.5
2026-10-07 13:56:36 +05:30
Abhi Kumar
ddf847d343 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-07 13:56:36 +05:30
Abhi Kumar
ed65aa29cc feat(dashboards): add plugin schema for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-07 13:55:23 +05:30
Abhi Kumar
954988d401 chore(storybook): cover heatmap config and changed settings 2026-10-07 13:29:32 +05:30
Abhi Kumar
dff09e6950 fix(dashboard): tighten config pane helper text 2026-10-07 13:29:32 +05:30
Abhi Kumar
4132972bb3 refactor(dashboard): remove ConfigSegmented and segment icons
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
3348ee3bd2 feat(dashboard): redesign heatmap config controls
Opacity base colour reuses the threshold swatches and count range the
axis range input, in place of bespoke cards and inputs.

Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
b01ea8eb06 fix(dashboard): restore "Global Time" time range label
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
08731f448d feat(dashboard): compare config changes against the saved panel
Section dots and field resets now reflect unsaved edits instead of
deviation from kind defaults; Reset reverts to the saved value.
Collapsed section summaries are dropped.

Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
330fb7c985 feat(dashboard): confirm before leaving the panel editor with unsaved changes
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
3b3462a2ed feat(dashboard): redesign thresholds and context links sections
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
375879ea2b style(dashboard): draw config tile sketches in the tile's text color
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
7cfd4f6d9e feat(dashboard): add text position grid and restyle text panel options
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
40bdb1e9e5 feat(dashboard): redesign buckets section
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
c9974d4a68 feat(dashboard): redesign chart appearance section
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
bb491a932b feat(dashboard): redesign legend section and match series colors to legend font
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
faf665b6af feat(dashboard): redesign axes section
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
5997038816 feat(dashboard): redesign formatting section with decimal tiles and preview
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
aa530894d9 feat(dashboard): redesign visualization section
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
687b20a5c2 feat(dashboard): summarise config sections and mark changed settings
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
3b02ee9815 feat(dashboard): add change tracking and shared config pane controls
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
ea7282973b chore(storybook): cover the new-panel drawer and panel type switch 2026-10-07 13:29:30 +05:30
Abhi Kumar
86f9ca51ab chore: pr review changes 2026-10-07 13:29:30 +05:30
Abhi Kumar
a94786c589 chore: pr review changes 2026-10-07 13:29:30 +05:30
Abhi Kumar
c370d2cc84 test(dashboard): wait for picker store effects after starting a new section 2026-10-07 13:29:30 +05:30
Abhi Kumar
0c5f5cbee1 test(dashboard): use userEvent in panel type switcher tests
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:30 +05:30
Abhi Kumar
64b624aaac feat(dashboard): add-to-section split button in the new panel drawer
Replaces the antd section select; the CTA names its destination.

Assisted-by: Claude Opus 5.5
2026-10-07 13:29:30 +05:30
Abhi Kumar
d53eaf436d feat(dashboard): switch panel type in the editor via the type browser
A revert button returns to the type the panel was opened with.
2026-10-07 13:29:30 +05:30
Abhi Kumar
1c612342e1 fix(dashboard): scroll to a placeholder that grows the dashboard
OverlayScrollbars marks its viewport scrollable only after noticing the
overflow, and the grid animates its height, so the reveal either scrolled
html or stopped short.
2026-10-07 13:29:30 +05:30
Abhi Kumar
5d389e10f9 feat(dashboard): preview the new panel where the picker will add it 2026-10-07 13:29:30 +05:30
Abhi Kumar
c2691226f2 feat(dashboard): preview a new section on the dashboard while naming it 2026-10-07 13:29:30 +05:30
Abhi Kumar
3833623a0a feat(dashboard): pick or create the section from the new-panel drawer footer
The main New Panel button defaults to the dashboard root.
2026-10-07 13:29:30 +05:30
Abhi Kumar
c83e1dac83 feat(dashboard): let a new panel's save create its section or the root
Placement travels in the editor URL as a NewPanelTarget, so nothing is
written until the panel is saved.
2026-10-07 13:29:30 +05:30
Abhi Kumar
b5dddfef17 feat(dashboard): highlight the picker's target section behind the drawer 2026-10-07 13:29:30 +05:30
Abhi Kumar
eae11aeee7 feat(dashboard): redesign the new-panel picker as a searchable drawer 2026-10-07 13:29:30 +05:30
35 changed files with 836 additions and 2588 deletions

View File

@@ -40,7 +40,6 @@ jobs:
matrix:
project:
- chromium
shard: [1, 2, 3, 4]
if: |
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
@@ -49,33 +48,12 @@ jobs:
steps:
- name: checkout
uses: actions/checkout@v4
# Images bring-up-stack would otherwise pull, at the tests/conftest.py
# defaults. A stale tag only loses the head start.
- name: pull-images
id: pull-images
background: true
run: |
printf '%s\n' \
node:22-bookworm \
golang:1.25-bookworm \
python:3.13-slim \
postgres:15 \
clickhouse/clickhouse-server:25.12.5 \
clickhouse/clickhouse-keeper:25.12.5 \
signoz/signoz-schema-migrator:v0.144.9 \
wiremock/wiremock:2.35.1-1 \
| xargs -P 0 -n 1 docker pull -q
- name: python
uses: actions/setup-python@v5
with:
python-version: 3.13
- name: uv
uses: astral-sh/setup-uv@v4
- name: python-install
id: python-install
background: true
run: |
cd tests && uv sync
- name: node
uses: actions/setup-node@v4
with:
@@ -84,9 +62,19 @@ jobs:
uses: pnpm/action-setup@v6
with:
version: 10
- name: python-install
run: |
cd tests && uv sync
- name: pnpm-install
run: |
cd tests/e2e && pnpm install --frozen-lockfile
- name: playwright-browsers
run: |
docker create --name pw mcr.microsoft.com/playwright:v1.57.0-noble
docker cp pw:/ms-playwright "$RUNNER_TEMP/ms-playwright"
docker rm pw
echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ms-playwright" >> "$GITHUB_ENV"
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
# Restore-only: the cacheci workflow owns cache saves. Seeds the
# BuildKit cache mounts so the in-test image build is incremental.
- name: restore
@@ -111,19 +99,6 @@ jobs:
tar -xf /restored/pnpm-store.tar -C /pnpm/store
EOF
docker build -f "$RUNNER_TEMP/inject.Dockerfile" "$RUNNER_TEMP/cacheci"
- name: wait-stack-deps
wait: [pull-images, python-install]
# Started after the pulls so its download overlaps the CPU-bound
# image build instead of competing for the network.
- name: playwright-browsers
id: playwright-browsers
background: true
run: |
docker create --name pw mcr.microsoft.com/playwright:v1.57.0-noble
docker cp pw:/ms-playwright "$RUNNER_TEMP/ms-playwright"
docker rm pw
echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ms-playwright" >> "$GITHUB_ENV"
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
- name: bring-up-stack
run: |
cd tests && \
@@ -131,12 +106,10 @@ jobs:
--basetemp=./tmp/ \
-vv --reuse --with-web \
e2e/bootstrap/setup.py::test_setup
- name: wait-playwright-browsers
wait: playwright-browsers
- name: playwright-test
run: |
cd tests/e2e && \
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/4
pnpm playwright test --project=${{ matrix.project }}
- name: teardown-stack
if: always()
run: |
@@ -149,6 +122,6 @@ jobs:
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-artifacts-${{ matrix.project }}-${{ matrix.shard }}
name: playwright-artifacts-${{ matrix.project }}
path: tests/e2e/artifacts/
retention-days: 5

View File

@@ -68,7 +68,6 @@ jobs:
- semconvfamilies
- serviceaccount
- spanmapper
- tracedetail
- querier_json_body
- querier_skip_resource_fingerprint
- ttl

View File

@@ -3425,6 +3425,13 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -4167,6 +4174,7 @@ components:
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -4182,6 +4190,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4195,6 +4204,7 @@ components:
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4280,6 +4290,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
properties:
kind:
enum:
- signoz/ScatterPlotPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
properties:
kind:
@@ -4359,6 +4381,16 @@ components:
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesPointDiameter:
maximum: 40
minimum: 2
nullable: true
type: integer
DashboardtypesPointOpacity:
maximum: 1
minimum: 0.1
nullable: true
type: number
DashboardtypesPostableDashboardV2:
properties:
generateName:
@@ -4532,6 +4564,87 @@ components:
required:
- queryValue
type: object
DashboardtypesScatterPlotAxes:
properties:
x:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
"y":
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
type: object
DashboardtypesScatterPlotAxis:
properties:
label:
description: Axis title. Empty draws none.
type: string
scale:
$ref: '#/components/schemas/DashboardtypesAxisScale'
softMax:
nullable: true
type: number
softMin:
nullable: true
type: number
type: object
DashboardtypesScatterPlotChartAppearance:
properties:
points:
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
type: object
DashboardtypesScatterPlotDimensions:
properties:
colorBy:
description: Group-by label names (e.g. k8s.namespace.name) whose combined
values colour dots and drive the legend. Empty colours by every group-by
label.
items:
type: string
nullable: true
type: array
sizeBy:
description: Value column key that scales dot size. Empty draws every dot
at the default size.
type: string
x:
description: Value column key (queryName, or queryName.expression for a
multi-aggregation query) plotted on the x axis. Empty uses the first value
column.
type: string
"y":
description: Value column key plotted on the y axis. Empty uses the second
value column.
type: string
type: object
DashboardtypesScatterPlotPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
dimensions:
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
formatting:
$ref: '#/components/schemas/DashboardtypesTableFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesScatterPlotPoints:
properties:
maxSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
minSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
opacity:
$ref: '#/components/schemas/DashboardtypesPointOpacity'
size:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
type: object
DashboardtypesSource:
enum:
- user
@@ -9844,53 +9957,6 @@ components:
required:
- aggregations
type: object
SpantypesGettableTraceSummary:
properties:
ai:
$ref: '#/components/schemas/SpantypesTraceAISummary'
endTimestampMillis:
minimum: 0
type: integer
hasMissingSpans:
type: boolean
rootServiceEntryPoint:
type: string
rootServiceName:
type: string
rootSpanStatusCode:
type: string
startTimestampMillis:
minimum: 0
type: integer
totalErrorSpansCount:
minimum: 0
type: integer
totalSpansCount:
minimum: 0
type: integer
required:
- startTimestampMillis
- endTimestampMillis
- rootServiceName
- rootServiceEntryPoint
- rootSpanStatusCode
- totalSpansCount
- totalErrorSpansCount
- hasMissingSpans
type: object
SpantypesGettableTraceThread:
properties:
nextCursor:
type: string
prevCursor:
type: string
spans:
items:
$ref: '#/components/schemas/SpantypesThreadSpan'
type: array
required:
- spans
type: object
SpantypesGettableWaterfallTrace:
properties:
endTimestampMillis:
@@ -10205,94 +10271,6 @@ components:
nullable: true
type: object
type: object
SpantypesThreadSpan:
properties:
attributes:
additionalProperties: {}
type: object
duration_nano:
minimum: 0
type: integer
events:
items:
$ref: '#/components/schemas/SpantypesEvent'
type: array
has_error:
type: boolean
kind_string:
type: string
name:
type: string
parent_span_id:
type: string
references:
items:
$ref: '#/components/schemas/SpantypesOtelSpanRef'
type: array
resource:
additionalProperties:
type: string
type: object
span_id:
type: string
status_code_string:
type: string
status_message:
type: string
time_unix:
minimum: 0
type: integer
trace_id:
type: string
required:
- span_id
- trace_id
- parent_span_id
- name
- kind_string
- time_unix
- duration_nano
- has_error
- status_code_string
- status_message
- resource
- attributes
- events
- references
type: object
SpantypesTraceAISummary:
properties:
tokens:
$ref: '#/components/schemas/SpantypesTraceAITokens'
totalCost:
type: number
required:
- tokens
type: object
SpantypesTraceAITokens:
properties:
cacheRead:
minimum: 0
type: integer
cacheWrite:
minimum: 0
type: integer
input:
minimum: 0
type: integer
output:
minimum: 0
type: integer
reasoning:
minimum: 0
type: integer
required:
- input
- output
- cacheRead
- cacheWrite
- reasoning
type: object
SpantypesUpdatableSpanMapper:
properties:
config:
@@ -16017,163 +15995,6 @@ paths:
tags:
- tracedetail
x-signoz-stability: alpha
/api/v1/traces/{traceID}/summary:
get:
deprecated: false
description: Returns the trace's time range, root span, span and error counts,
and whether any spans are missing. AI traces also include token and cost totals.
operationId: GetTraceSummary
parameters:
- in: path
name: traceID
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableTraceSummary'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get summary for a trace
tags:
- tracedetail
x-signoz-stability: alpha
/api/v1/traces/{traceID}/thread:
get:
deprecated: false
description: Returns the spans carrying gen_ai input or output messages in timestamp
order. Pass nextCursor as after or prevCursor as before to page, or spanId
to open the page around a span.
operationId: GetTraceThread
parameters:
- description: Page size, at most 100. 0 means 20.
in: query
name: limit
schema:
description: Page size, at most 100. 0 means 20.
type: integer
- description: The nextCursor of a page; returns the spans after it. Set only
one of after, before and spanId.
in: query
name: after
schema:
description: The nextCursor of a page; returns the spans after it. Set only
one of after, before and spanId.
type: string
- description: The prevCursor of a page; returns the spans before it. Set only
one of after, before and spanId.
in: query
name: before
schema:
description: The prevCursor of a page; returns the spans before it. Set
only one of after, before and spanId.
type: string
- description: Returns the page around this span. Set only one of after, before
and spanId.
in: query
name: spanId
schema:
description: Returns the page around this span. Set only one of after, before
and spanId.
type: string
- in: path
name: traceID
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableTraceThread'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get thread view for a trace
tags:
- tracedetail
x-signoz-stability: alpha
/api/v1/user/me:
get:
deprecated: true

View File

@@ -11335,82 +11335,6 @@ export interface SpantypesGettableTraceAggregationsDTO {
aggregations: SpantypesSpanAggregationResultDTO[];
}
export interface SpantypesTraceAITokensDTO {
/**
* @type integer
* @minimum 0
*/
cacheRead: number;
/**
* @type integer
* @minimum 0
*/
cacheWrite: number;
/**
* @type integer
* @minimum 0
*/
input: number;
/**
* @type integer
* @minimum 0
*/
output: number;
/**
* @type integer
* @minimum 0
*/
reasoning: number;
}
export interface SpantypesTraceAISummaryDTO {
tokens: SpantypesTraceAITokensDTO;
/**
* @type number
*/
totalCost?: number;
}
export interface SpantypesGettableTraceSummaryDTO {
ai?: SpantypesTraceAISummaryDTO;
/**
* @type integer
* @minimum 0
*/
endTimestampMillis: number;
/**
* @type boolean
*/
hasMissingSpans: boolean;
/**
* @type string
*/
rootServiceEntryPoint: string;
/**
* @type string
*/
rootServiceName: string;
/**
* @type string
*/
rootSpanStatusCode: string;
/**
* @type integer
* @minimum 0
*/
startTimestampMillis: number;
/**
* @type integer
* @minimum 0
*/
totalErrorSpansCount: number;
/**
* @type integer
* @minimum 0
*/
totalSpansCount: number;
}
export interface SpantypesOtelSpanRefDTO {
/**
* @type string
@@ -11426,86 +11350,6 @@ export interface SpantypesOtelSpanRefDTO {
traceId?: string;
}
export type SpantypesThreadSpanDTOAttributes = { [key: string]: unknown };
export type SpantypesThreadSpanDTOResource = { [key: string]: string };
export interface SpantypesThreadSpanDTO {
/**
* @type object
*/
attributes: SpantypesThreadSpanDTOAttributes;
/**
* @type integer
* @minimum 0
*/
duration_nano: number;
/**
* @type array
*/
events: SpantypesEventDTO[];
/**
* @type boolean
*/
has_error: boolean;
/**
* @type string
*/
kind_string: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
parent_span_id: string;
/**
* @type array
*/
references: SpantypesOtelSpanRefDTO[];
/**
* @type object
*/
resource: SpantypesThreadSpanDTOResource;
/**
* @type string
*/
span_id: string;
/**
* @type string
*/
status_code_string: string;
/**
* @type string
*/
status_message: string;
/**
* @type integer
* @minimum 0
*/
time_unix: number;
/**
* @type string
*/
trace_id: string;
}
export interface SpantypesGettableTraceThreadDTO {
/**
* @type string
*/
nextCursor?: string;
/**
* @type string
*/
prevCursor?: string;
/**
* @type array
*/
spans: SpantypesThreadSpanDTO[];
}
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
[key: string]: unknown;
};
@@ -13179,51 +13023,6 @@ export type GetTraceAggregations200 = {
status: string;
};
export type GetTraceSummaryPathParameters = {
traceID: string;
};
export type GetTraceSummary200 = {
data: SpantypesGettableTraceSummaryDTO;
/**
* @type string
*/
status: string;
};
export type GetTraceThreadPathParameters = {
traceID: string;
};
export type GetTraceThreadParams = {
/**
* @type integer
* @description Page size, at most 100. 0 means 20.
*/
limit?: number;
/**
* @type string
* @description The nextCursor of a page; returns the spans after it. Set only one of after, before and spanId.
*/
after?: string;
/**
* @type string
* @description The prevCursor of a page; returns the spans before it. Set only one of after, before and spanId.
*/
before?: string;
/**
* @type string
* @description Returns the page around this span. Set only one of after, before and spanId.
*/
spanId?: string;
};
export type GetTraceThread200 = {
data: SpantypesGettableTraceThreadDTO;
/**
* @type string
*/
status: string;
};
export type ListUserPreferences200 = {
/**
* @type array

View File

@@ -4,17 +4,11 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import { useMutation } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
@@ -22,11 +16,6 @@ import type {
GetFlamegraphPathParameters,
GetTraceAggregations200,
GetTraceAggregationsPathParameters,
GetTraceSummary200,
GetTraceSummaryPathParameters,
GetTraceThread200,
GetTraceThreadParams,
GetTraceThreadPathParameters,
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
@@ -38,26 +27,6 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* Computes span aggregations grouped by requested field.
* @summary Get aggregations for a trace
@@ -158,223 +127,6 @@ export const useGetTraceAggregations = <
> => {
return useMutation(getGetTraceAggregationsMutationOptions(options));
};
/**
* Returns the trace's time range, root span, span and error counts, and whether any spans are missing. AI traces also include token and cost totals.
* @summary Get summary for a trace
*/
export const getTraceSummary = (
{ traceID }: GetTraceSummaryPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetTraceSummary200>({
url: `/api/v1/traces/${traceID}/summary`,
method: 'GET',
signal,
});
};
export const getGetTraceSummaryQueryKey = ({
traceID,
}: GetTraceSummaryPathParameters) => {
return [`/api/v1/traces/${traceID}/summary`] as const;
};
export const getGetTraceSummaryQueryOptions = <
TData = Awaited<ReturnType<typeof getTraceSummary>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceSummaryPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceSummary>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetTraceSummaryQueryKey({ traceID });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceSummary>>> = ({
signal,
}) => getTraceSummary({ traceID }, signal);
return {
queryKey,
queryFn,
enabled: traceID !== null && traceID !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getTraceSummary>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetTraceSummaryQueryResult = NonNullable<
Awaited<ReturnType<typeof getTraceSummary>>
>;
export type GetTraceSummaryQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get summary for a trace
*/
export function useGetTraceSummary<
TData = Awaited<ReturnType<typeof getTraceSummary>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceSummaryPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceSummary>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetTraceSummaryQueryOptions({ traceID }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Get summary for a trace
*/
export const invalidateGetTraceSummary = async (
queryClient: QueryClient,
{ traceID }: GetTraceSummaryPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetTraceSummaryQueryKey({ traceID }) },
options,
);
return queryClient;
};
/**
* Returns the spans carrying gen_ai input or output messages in timestamp order. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
* @summary Get thread view for a trace
*/
export const getTraceThread = (
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetTraceThread200>({
url: `/api/v1/traces/${traceID}/thread`,
method: 'GET',
params,
signal,
});
};
export const getGetTraceThreadQueryKey = (
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
) => {
return [
`/api/v1/traces/${traceID}/thread`,
...(params ? [params] : []),
] as const;
};
export const getGetTraceThreadQueryOptions = <
TData = Awaited<ReturnType<typeof getTraceThread>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceThread>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetTraceThreadQueryKey({ traceID }, params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceThread>>> = ({
signal,
}) => getTraceThread({ traceID }, params, signal);
return {
queryKey,
queryFn,
enabled: traceID !== null && traceID !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getTraceThread>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetTraceThreadQueryResult = NonNullable<
Awaited<ReturnType<typeof getTraceThread>>
>;
export type GetTraceThreadQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get thread view for a trace
*/
export function useGetTraceThread<
TData = Awaited<ReturnType<typeof getTraceThread>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceThread>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetTraceThreadQueryOptions(
{ traceID },
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Get thread view for a trace
*/
export const invalidateGetTraceThread = async (
queryClient: QueryClient,
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetTraceThreadQueryKey({ traceID }, params) },
options,
);
return queryClient;
};
/**
* Returns the flamegraph view of spans for a given trace ID.
* @summary Get flamegraph view for a trace

View File

@@ -42,9 +42,6 @@
display: flex;
flex-direction: column;
padding: 0 16px;
// OverlayScrollbar owns the scroll. A native scrollbar here changes the
// body width when it toggles and shifts the header grid.
overflow: hidden;
}
.title {
@@ -72,7 +69,7 @@
}
.log-detail-drawer__content {
min-height: 100%;
height: 100%;
}
.log-detail-drawer__log {

View File

@@ -5,7 +5,6 @@ import { Drawer, Tooltip } from 'antd';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { Typography } from '@signozhq/ui/typography';
import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
@@ -289,150 +288,148 @@ function LogDetailInner({
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
<OverlayScrollbar style={{ height: '100%' }}>
<div
className="log-detail-drawer__content"
data-log-detail-ignore="true"
data-testid="log-detail-drawer"
>
<div className="log-detail-drawer__log">
<LogStateIndicator
severityText={log.severity_text}
severityNumber={log.severity_number}
fontSize={options?.fontSize ?? FontSize.MEDIUM}
/>
<Tooltip
title={removeEscapeCharacters(logBody)}
placement="left"
mouseLeaveDelay={0}
>
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
</Tooltip>
<div
className="log-detail-drawer__content"
data-log-detail-ignore="true"
data-testid="log-detail-drawer"
>
<div className="log-detail-drawer__log">
<LogStateIndicator
severityText={log.severity_text}
severityNumber={log.severity_number}
fontSize={options?.fontSize ?? FontSize.MEDIUM}
/>
<Tooltip
title={removeEscapeCharacters(logBody)}
placement="left"
mouseLeaveDelay={0}
>
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
</Tooltip>
<div className="log-overflow-shadow">&nbsp;</div>
</div>
<LogHighlights log={log} />
<div className="log-detail-drawer__section-divider" />
<div className="tabs-and-search">
<ToggleGroupSimple
type="single"
className="views-tabs"
onChange={handleModeChange}
value={selectedView}
items={[
{
value: VIEW_TYPES.OVERVIEW,
label: (
<div className="view-title">
<Table size={14} />
Overview
</div>
),
},
{
value: VIEW_TYPES.CONTEXT,
label: (
<div className="view-title">
<TextSelect size={14} />
Context
</div>
),
},
{
value: VIEW_TYPES.INFRAMETRICS,
label: (
<div className="view-title">
<Histogram size="md" />
Metrics
</div>
),
},
]}
/>
<div className="log-detail-drawer__actions">
{selectedView === VIEW_TYPES.CONTEXT && (
<Tooltip
title="Show Filters"
placement="topLeft"
aria-label="Show Filters"
mouseLeaveDelay={0}
>
<Button
variant="link"
color="secondary"
size="sm"
prefix={<Filter size="lg" />}
onClick={handleFilterVisible}
/>
</Tooltip>
)}
</div>
</div>
{isFilterVisible && contextQuery?.builder.queryData[0] && (
<div className="log-detail-drawer-query-container">
<QuerySearch
onChange={(value): void => handleQueryExpressionChange(value, 0)}
dataSource={DataSource.LOGS}
queryData={contextQuery?.builder.queryData[0]}
onRun={handleRunQuery}
/>
</div>
)}
{selectedView === VIEW_TYPES.OVERVIEW && (
<Overview
logData={log}
isListViewPanel={isListViewPanel}
handleChangeSelectedView={handleChangeSelectedView}
onApplyLogFilter={onApplyLogFilter}
/>
)}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView
log={log}
filters={filters}
contextQuery={contextQuery}
isEdit={isEdit}
/>
)}
{selectedView === VIEW_TYPES.INFRAMETRICS && (
<InfraMetrics
clusterName={log.resources_string?.[RESOURCE_KEYS.CLUSTER_NAME] || ''}
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
timestamp={log.timestamp.toString()}
dataSource={DataSource.LOGS}
/>
)}
{selectedView === VIEW_TYPES.OVERVIEW && (
<div className="log-detail-drawer__footer-hint">
<div className="log-detail-drawer__footer-hint-content">
<Typography.Text
color="muted"
className="log-detail-drawer__footer-hint-text"
>
Use
</Typography.Text>
<ArrowUp size={14} className="log-detail-drawer__footer-hint-icon" />
<span>/</span>
<ArrowDown size={14} className="log-detail-drawer__footer-hint-icon" />
<Typography.Text
color="muted"
className="log-detail-drawer__footer-hint-text"
>
to view previous/next log
</Typography.Text>
</div>
</div>
)}
<div className="log-overflow-shadow">&nbsp;</div>
</div>
</OverlayScrollbar>
<LogHighlights log={log} />
<div className="log-detail-drawer__section-divider" />
<div className="tabs-and-search">
<ToggleGroupSimple
type="single"
className="views-tabs"
onChange={handleModeChange}
value={selectedView}
items={[
{
value: VIEW_TYPES.OVERVIEW,
label: (
<div className="view-title">
<Table size={14} />
Overview
</div>
),
},
{
value: VIEW_TYPES.CONTEXT,
label: (
<div className="view-title">
<TextSelect size={14} />
Context
</div>
),
},
{
value: VIEW_TYPES.INFRAMETRICS,
label: (
<div className="view-title">
<Histogram size="md" />
Metrics
</div>
),
},
]}
/>
<div className="log-detail-drawer__actions">
{selectedView === VIEW_TYPES.CONTEXT && (
<Tooltip
title="Show Filters"
placement="topLeft"
aria-label="Show Filters"
mouseLeaveDelay={0}
>
<Button
variant="link"
color="secondary"
size="sm"
prefix={<Filter size="lg" />}
onClick={handleFilterVisible}
/>
</Tooltip>
)}
</div>
</div>
{isFilterVisible && contextQuery?.builder.queryData[0] && (
<div className="log-detail-drawer-query-container">
<QuerySearch
onChange={(value): void => handleQueryExpressionChange(value, 0)}
dataSource={DataSource.LOGS}
queryData={contextQuery?.builder.queryData[0]}
onRun={handleRunQuery}
/>
</div>
)}
{selectedView === VIEW_TYPES.OVERVIEW && (
<Overview
logData={log}
isListViewPanel={isListViewPanel}
handleChangeSelectedView={handleChangeSelectedView}
onApplyLogFilter={onApplyLogFilter}
/>
)}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView
log={log}
filters={filters}
contextQuery={contextQuery}
isEdit={isEdit}
/>
)}
{selectedView === VIEW_TYPES.INFRAMETRICS && (
<InfraMetrics
clusterName={log.resources_string?.[RESOURCE_KEYS.CLUSTER_NAME] || ''}
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
timestamp={log.timestamp.toString()}
dataSource={DataSource.LOGS}
/>
)}
{selectedView === VIEW_TYPES.OVERVIEW && (
<div className="log-detail-drawer__footer-hint">
<div className="log-detail-drawer__footer-hint-content">
<Typography.Text
color="muted"
className="log-detail-drawer__footer-hint-text"
>
Use
</Typography.Text>
<ArrowUp size={14} className="log-detail-drawer__footer-hint-icon" />
<span>/</span>
<ArrowDown size={14} className="log-detail-drawer__footer-hint-icon" />
<Typography.Text
color="muted"
className="log-detail-drawer__footer-hint-text"
>
to view previous/next log
</Typography.Text>
</div>
</div>
)}
</div>
</Drawer>
);
}

View File

@@ -10,23 +10,6 @@ import (
)
func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/traces/{traceID}/summary", handler.New(
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetTraceSummary),
handler.OpenAPIDef{
ID: "GetTraceSummary",
Tags: []string{"tracedetail"},
Summary: "Get summary for a trace",
Description: "Returns the trace's time range, root span, span and error counts, and whether any spans are missing. AI traces also include token and cost totals.",
Response: new(spantypes.GettableTraceSummary),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v4/traces/{traceID}/waterfall", handler.New(
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetWaterfallV4),
handler.OpenAPIDef{
@@ -84,23 +67,5 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/traces/{traceID}/thread", handler.New(
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetThread),
handler.OpenAPIDef{
ID: "GetTraceThread",
Tags: []string{"tracedetail"},
Summary: "Get thread view for a trace",
Description: "Returns the spans carrying gen_ai input or output messages in timestamp order. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.",
RequestQuery: new(spantypes.GetTraceThreadParams),
Response: new(spantypes.GettableTraceThread),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -6,9 +6,7 @@ import (
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -20,22 +18,6 @@ func NewHandler(module tracedetail.Module) tracedetail.Handler {
return &handler{module: module}
}
func (h *handler) GetTraceSummary(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
}
stats, err := h.module.GetTraceSummary(r.Context(), valuer.MustNewUUID(claims.OrgID), mux.Vars(r)["traceID"])
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, spantypes.NewGettableTraceSummary(stats))
}
func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
req := new(spantypes.PostableWaterfall)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
@@ -93,31 +75,3 @@ func (h *handler) GetFlamegraph(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, result)
}
func (h *handler) GetThread(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
}
req := new(spantypes.GetTraceThreadParams)
if err := binding.Query.BindQuery(r.URL.Query(), req); err != nil {
render.Error(rw, err)
return
}
query, err := spantypes.NewThreadQuery(req)
if err != nil {
render.Error(rw, err)
return
}
result, err := h.module.GetThread(r.Context(), valuer.MustNewUUID(claims.OrgID), mux.Vars(r)["traceID"], query)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

View File

@@ -8,7 +8,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"go.opentelemetry.io/otel/metric"
)
@@ -40,41 +39,26 @@ func NewModule(traceStore spantypes.TraceStore, providerSettings factory.Provide
return m
}
func (m *module) GetTraceSummary(ctx context.Context, orgID valuer.UUID, traceID string) (*spantypes.TraceStats, error) {
bounds, err := m.store.GetTraceBounds(ctx, traceID)
if err != nil {
return nil, err
}
stats, err := m.store.GetTraceStats(ctx, orgID, traceID, bounds)
if err != nil {
return nil, err
}
if stats.TotalSpans == 0 {
return nil, spantypes.ErrTraceNotFound
}
return stats, nil
}
// GetWaterfallV4 is the OOM-safe V4 waterfall.
// For large traces (NumSpans > effectiveLimit) it uses a two-step fetch:
// minimal fields for all spans to build the tree, then full fields for the
// visible window only. Aggregations are not returned.
func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error) {
bounds, err := m.store.GetTraceBounds(ctx, traceID)
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
return nil, err
}
if bounds.NumSpans > uint64(m.config.Waterfall.MaxLimitToSelectAllSpans) {
if summary.NumSpans > uint64(m.config.Waterfall.MaxLimitToSelectAllSpans) {
attrs := metric.WithAttributes(attrResponseType.String(attrResponseTypeWindowed))
m.metrics.waterfallRequestCount.Add(ctx, 1, attrs)
m.metrics.waterfallSpanCount.Add(ctx, int64(bounds.NumSpans), attrs)
return m.getWindowedWaterfall(ctx, traceID, selectedSpanID, uncollapsedSpans, bounds.Start, bounds.End)
m.metrics.waterfallSpanCount.Add(ctx, int64(summary.NumSpans), attrs)
return m.getWindowedWaterfall(ctx, traceID, selectedSpanID, uncollapsedSpans, summary.Start, summary.End)
}
return m.getFullWaterfall(ctx, traceID, bounds)
return m.getFullWaterfall(ctx, traceID, summary)
}
func (m *module) getFullWaterfall(ctx context.Context, traceID string, bounds *spantypes.TraceBounds) (*spantypes.GettableWaterfallTrace, error) {
spanItems, err := m.store.GetTraceSpans(ctx, traceID, bounds)
func (m *module) getFullWaterfall(ctx context.Context, traceID string, summary *spantypes.TraceSummary) (*spantypes.GettableWaterfallTrace, error) {
spanItems, err := m.store.GetTraceSpans(ctx, traceID, summary)
if err != nil {
return nil, err
}
@@ -94,24 +78,24 @@ func (m *module) getFullWaterfall(ctx context.Context, traceID string, bounds *s
}
func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error) {
bounds, err := m.store.GetTraceBounds(ctx, traceID)
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
return nil, err
}
traceDurationNs := uint64(bounds.End.UnixNano()) - uint64(bounds.Start.UnixNano())
traceDurationNs := uint64(summary.End.UnixNano()) - uint64(summary.Start.UnixNano())
results := make([]spantypes.SpanAggregationResult, 0, len(req.Aggregations))
for _, agg := range req.Aggregations {
result := spantypes.SpanAggregationResult{Field: agg.Field, Aggregation: agg.Aggregation}
switch agg.Aggregation {
case spantypes.SpanAggregationSpanCount:
result.Value, err = m.store.GetSpanCountByField(ctx, traceID, bounds, agg.Field)
result.Value, err = m.store.GetSpanCountByField(ctx, traceID, summary, agg.Field)
if err != nil {
return nil, err
}
case spantypes.SpanAggregationDuration:
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, bounds, agg.Field)
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, summary, agg.Field)
if err2 != nil {
return nil, err2
}
@@ -120,7 +104,7 @@ func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *
result.Value[k] = ns / 1_000_000
}
case spantypes.SpanAggregationExecutionTimePercentage:
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, bounds, agg.Field)
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, summary, agg.Field)
if err2 != nil {
return nil, err2
}
@@ -137,15 +121,15 @@ func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *
}
func (m *module) GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
bounds, err := m.store.GetTraceBounds(ctx, traceID)
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
return nil, err
}
if bounds.NumSpans <= uint64(m.config.Flamegraph.SelectAllSpansLimit) {
return m.getFullFlamegraph(ctx, traceID, bounds, selectFields)
if summary.NumSpans <= uint64(m.config.Flamegraph.SelectAllSpansLimit) {
return m.getFullFlamegraph(ctx, traceID, summary, selectFields)
}
m.metrics.flamegraphRequestCount.Add(ctx, 1, metric.WithAttributes(attrResponseType.String(attrResponseTypeSampled)))
return m.getWindowedFlamegraph(ctx, traceID, selectedSpanID, bounds, selectFields)
return m.getWindowedFlamegraph(ctx, traceID, selectedSpanID, summary, selectFields)
}
// getWindowedWaterfall builds the waterfall tree with minimal data and then returns only a window of full spans.
@@ -189,51 +173,8 @@ func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpan
), nil
}
func (m *module) GetThread(ctx context.Context, orgID valuer.UUID, traceID string, query *spantypes.ThreadQuery) (*spantypes.GettableTraceThread, error) {
bounds, err := m.store.GetTraceBounds(ctx, traceID)
if err != nil {
return nil, err
}
// One extra row per side signals a next/prev page; NewGettableTraceThread trims the response back to query.Limit.
page := spantypes.ThreadPage{Limit: query.Limit + 1}
switch {
case query.SpanID != "":
// Window centred on the span: fetch both directions, NewGettableTraceThread splits the limit.
anchor, err := m.store.GetThreadCursor(ctx, traceID, bounds, query.SpanID)
if err != nil {
return nil, err
}
before, err := m.store.GetThreadSpans(ctx, orgID, traceID, bounds, spantypes.ThreadPage{Cursor: anchor, From: spantypes.ThreadBefore, Limit: page.Limit})
if err != nil {
return nil, err
}
// ThreadAt keeps the anchor when it carries messages; otherwise the filter drops it.
after, err := m.store.GetThreadSpans(ctx, orgID, traceID, bounds, spantypes.ThreadPage{Cursor: anchor, From: spantypes.ThreadAt, Limit: page.Limit})
if err != nil {
return nil, err
}
return spantypes.NewGettableTraceThread(traceID, query, before, after), nil
case query.Before != nil:
page.Cursor, page.From = query.Before, spantypes.ThreadBefore
before, err := m.store.GetThreadSpans(ctx, orgID, traceID, bounds, page)
if err != nil {
return nil, err
}
return spantypes.NewGettableTraceThread(traceID, query, before, nil), nil
default:
// nil for the first page.
page.Cursor = query.After
after, err := m.store.GetThreadSpans(ctx, orgID, traceID, bounds, page)
if err != nil {
return nil, err
}
return spantypes.NewGettableTraceThread(traceID, query, nil, after), nil
}
}
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, bounds *spantypes.TraceBounds, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, bounds.Start, bounds.End, nil)
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, nil)
if err != nil {
return nil, err
}
@@ -246,8 +187,8 @@ func (m *module) getFullFlamegraph(ctx context.Context, traceID string, bounds *
}
// getWindowedFlamegraph returns a window of a max levels and max sampled spans per level around the selected span.
func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpanID string, bounds *spantypes.TraceBounds, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, bounds.Start, bounds.End)
func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpanID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, summary.Start, summary.End)
if err != nil {
return nil, err
}
@@ -266,7 +207,7 @@ func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpa
return nil, spantypes.ErrTraceNotFound
}
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, bounds.Start, bounds.End, spantypes.FlamegraphWindowSpanIDs(selectedSpans))
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, spantypes.FlamegraphWindowSpanIDs(selectedSpans))
if err != nil {
return nil, err
}

View File

@@ -4,22 +4,15 @@ import (
"context"
"database/sql"
"fmt"
"slices"
"time"
sqlbuilder "github.com/huandu/go-sqlbuilder"
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
const colServiceName = `resource_string_service$$$$name` // $ gets escaped so $$$$ converts to $$.
@@ -45,21 +38,13 @@ type spanDurationRow struct {
type traceStore struct {
telemetryStore telemetrystore.TelemetryStore
metadataStore telemetrytypes.MetadataStore
storage qbtypes.Storage
flagger flagger.Flagger
}
func NewTraceStore(ts telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, fl flagger.Flagger) *traceStore {
return &traceStore{
telemetryStore: ts,
metadataStore: metadataStore,
storage: tracestelemetryschema.NewStorage(),
flagger: fl,
}
func NewTraceStore(ts telemetrystore.TelemetryStore) *traceStore {
return &traceStore{telemetryStore: ts}
}
func (s *traceStore) GetTraceBounds(ctx context.Context, traceID string) (*spantypes.TraceBounds, error) {
func (s *traceStore) GetTraceSummary(ctx context.Context, traceID string) (*spantypes.TraceSummary, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("trace_id", "min(start) AS start", "max(end) AS end", "sum(num_spans) AS num_spans")
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceSummaryTable))
@@ -67,148 +52,20 @@ func (s *traceStore) GetTraceBounds(ctx context.Context, traceID string) (*spant
sb.GroupBy("trace_id")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var bounds spantypes.TraceBounds
var summary spantypes.TraceSummary
err := s.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(
&bounds.TraceID, &bounds.Start, &bounds.End, &bounds.NumSpans,
&summary.TraceID, &summary.Start, &summary.End, &summary.NumSpans,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, spantypes.ErrTraceNotFound
}
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace bounds")
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace summary")
}
return &bounds, nil
return &summary, nil
}
func (s *traceStore) GetTraceStats(ctx context.Context, orgID valuer.UUID, traceID string, bounds *spantypes.TraceBounds) (*spantypes.TraceStats, error) {
table := fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable)
spans := sqlbuilder.NewSelectBuilder()
genAIColumns, err := s.genAISpanColumns(ctx, orgID, bounds, spans)
if err != nil {
return nil, err
}
// Spans whose parent is absent from the trace; these also count as roots and surface as "Missing Span".
ids := sqlbuilder.NewSelectBuilder()
ids.Select("span_id")
ids.From(table)
ids.Where(
ids.E("trace_id", traceID),
ids.GE("ts_bucket_start", bounds.Start.Unix()-1800),
ids.LE("ts_bucket_start", bounds.End.Unix()),
)
missingParent := fmt.Sprintf("parent_span_id <> '' AND parent_span_id GLOBAL NOT IN (%s)", spans.Var(ids))
spans.Select(
"toUnixTimestamp64Nano(timestamp) AS span_start_ns",
"span_start_ns + duration_nano AS span_end_ns",
"span_id",
"has_error",
"("+missingParent+") AS has_missing_parent",
"(parent_span_id = '' OR has_missing_parent) AS is_root",
"if(parent_span_id = '', name, 'Missing Span') AS root_name",
"if(parent_span_id = '', "+colServiceName+", '') AS root_service",
"if(parent_span_id = '', response_status_code, '') AS root_status_code",
)
spans.SelectMore(genAIColumns...)
spans.From(table)
spans.Where(
spans.E("trace_id", traceID),
spans.GE("ts_bucket_start", bounds.Start.Unix()-1800),
spans.LE("ts_bucket_start", bounds.End.Unix()),
)
// A span can be ingested more than once; keep one row per span_id.
spans.SQL("LIMIT 1 BY span_id")
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"toUInt64(min(span_start_ns)) AS start_ns",
"toUInt64(max(span_end_ns)) AS end_ns",
"count() AS total_spans",
"countIf(has_error) AS total_error_spans",
"countIf(has_missing_parent) > 0 AS has_missing_spans",
"argMinIf(root_service, (span_start_ns, root_name), is_root) AS root_service_name",
"argMinIf(root_name, (span_start_ns, root_name), is_root) AS root_entry_point",
"argMinIf(root_status_code, (span_start_ns, root_name), is_root) AS root_span_status_code",
"countIf(is_gen_ai) AS gen_ai_span_count",
"toUInt64(coalesce(sum(input_tokens_value), 0)) AS input_tokens",
"toUInt64(coalesce(sum(output_tokens_value), 0)) AS output_tokens",
"toUInt64(coalesce(sum(cache_read_tokens_value), 0)) AS cache_read_tokens",
"toUInt64(coalesce(sum(cache_write_tokens_value), 0)) AS cache_write_tokens",
"toUInt64(coalesce(sum(reasoning_tokens_value), 0)) AS reasoning_tokens",
"sum(total_cost_value) AS total_cost",
)
sb.From(sb.BuilderAs(spans, "spans"))
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var stats spantypes.TraceStats
err = s.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(
&stats.StartNs, &stats.EndNs, &stats.TotalSpans, &stats.TotalErrorSpans, &stats.HasMissingSpans,
&stats.RootServiceName, &stats.RootEntryPoint, &stats.RootSpanStatusCode, &stats.GenAISpanCount,
&stats.Tokens.Input, &stats.Tokens.Output, &stats.Tokens.CacheRead, &stats.Tokens.CacheWrite, &stats.Tokens.Reasoning,
&stats.TotalCost,
)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace stats")
}
return &stats, nil
}
// genAISpanColumns returns the gen_ai columns aggregated per span, resolved across attribute evolutions.
func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bounds *spantypes.TraceBounds, sb *sqlbuilder.SelectBuilder) ([]string, error) {
attributeKey := func(name string, dataType telemetrytypes.FieldDataType) *telemetrytypes.TelemetryFieldKey {
return &telemetrytypes.TelemetryFieldKey{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: dataType}
}
values := []struct{ key, alias string }{
{aiobservabilitytypes.GenAIUsageInputTokens, "input_tokens_value"},
{aiobservabilitytypes.GenAIUsageOutputTokens, "output_tokens_value"},
{aiobservabilitytypes.GenAIUsageCacheReadInputTokens, "cache_read_tokens_value"},
{aiobservabilitytypes.GenAIUsageCacheCreationInputTokens, "cache_write_tokens_value"},
{aiobservabilitytypes.GenAIUsageReasoningOutputTokens, "reasoning_tokens_value"},
{aiobservabilitytypes.SignozGenAITotalCost, "total_cost_value"},
}
names := slices.Clone(aiobservabilitytypes.GenAISpanGateKeys)
for _, value := range values {
names = append(names, value.key)
}
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(names))
for _, name := range names {
selectors = append(selectors, &telemetrytypes.FieldKeySelector{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact})
}
keys, _, err := s.metadataStore.GetKeysMulti(ctx, orgID, querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, s.flagger, selectors))
if err != nil {
return nil, err
}
q := querybuilder.NewQueryInfo(ctx, orgID, s.flagger, telemetrytypes.SignalTraces, nil, uint64(bounds.Start.UnixNano()), uint64(bounds.End.UnixNano()))
gate := make([]string, 0, len(aiobservabilitytypes.GenAISpanGateKeys))
for _, name := range aiobservabilitytypes.GenAISpanGateKeys {
conds, _, err := querybuilder.Conditions(ctx, q, s.storage, attributeKey(name, telemetrytypes.FieldDataTypeString), qbtypes.FilterOperatorExists, nil, keys, false, sb)
if err != nil {
return nil, err
}
gate = append(gate, conds...)
}
columns := []string{sb.Or(gate...) + " AS is_gen_ai"}
for _, value := range values {
// lookup by number, the type metadata stores numeric attributes under; float64 is only the output cast
expr, err := querybuilder.ResolveColumn(ctx, q, s.storage, attributeKey(value.key, telemetrytypes.FieldDataTypeNumber), telemetrytypes.FieldDataTypeFloat64, keys)
if err != nil {
return nil, err
}
// a materialized column name carries `$$`, which Build would otherwise unescape
columns = append(columns, sqlbuilder.Escape(expr)+" AS "+value.alias)
}
return columns, nil
}
func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, bounds *spantypes.TraceBounds) ([]spantypes.StorableSpan, error) {
func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary *spantypes.TraceSummary) ([]spantypes.StorableSpan, error) {
// DISTINCT ON (span_id) is ClickHouse-specific syntax not supported by sqlbuilder
query := fmt.Sprintf(`
SELECT DISTINCT ON (span_id)
@@ -228,8 +85,8 @@ func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, bounds *
err := s.telemetryStore.ClickhouseDB().Select(
ctx, &spanItems, query,
traceID,
bounds.Start.Unix()-1800,
bounds.End.Unix(),
summary.Start.Unix()-1800,
summary.End.Unix(),
)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace spans")
@@ -267,7 +124,8 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"DISTINCT ON (span_id) timestamp", "duration_nano", "span_id", "has_error", "kind",
"DISTINCT ON (span_id) timestamp",
"duration_nano", "span_id", "has_error", "kind",
colServiceName, "name",
"attributes_string", "attributes_number", "attributes_bool", "resources_string",
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
@@ -297,119 +155,6 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
return spans, nil
}
func (s *traceStore) GetThreadSpans(ctx context.Context, orgID valuer.UUID, traceID string, bounds *spantypes.TraceBounds, page spantypes.ThreadPage) ([]spantypes.StorableSpan, error) {
q := querybuilder.NewQueryInfo(ctx, orgID, s.flagger, telemetrytypes.SignalTraces, nil, uint64(bounds.Start.UnixNano()), uint64(bounds.End.UnixNano()))
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"DISTINCT ON (span_id) timestamp", "duration_nano", "span_id", "parent_span_id", "has_error", "name", "kind_string",
"status_code_string", "status_message", "resources_string",
"attributes_string", "attributes_number", "attributes_bool",
"events", "links as references",
)
if q.TraceAttrsJSONOn {
sb.SelectMore("attributes")
}
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
hasMessages, err := s.messagesExistCondition(ctx, q, orgID, bounds, sb)
if err != nil {
return nil, err
}
sb.Where(
sb.E("trace_id", traceID),
sb.GE("ts_bucket_start", bounds.Start.Unix()-1800),
sb.LE("ts_bucket_start", bounds.End.Unix()),
hasMessages,
)
if cursor := page.Cursor; cursor != nil {
// ClickHouse can't use an index for a tuple comparison, so the separate timestamp and
// ts_bucket_start bounds are what skip the data on the far side of the cursor.
key := "(toUnixTimestamp64Nano(timestamp), span_id)"
bucket := int64(cursor.TimeUnixNano / uint64(time.Second))
timestamp := fmt.Sprintf("fromUnixTimestamp64Nano(toInt64(%s))", sb.Var(cursor.TimeUnixNano))
tuple := sqlbuilder.Tuple(cursor.TimeUnixNano, cursor.SpanID)
switch page.From {
case spantypes.ThreadBefore:
sb.Where(sb.LE("ts_bucket_start", bucket), "timestamp <= "+timestamp, sb.LT(key, tuple))
case spantypes.ThreadAt:
sb.Where(sb.GE("ts_bucket_start", bucket-1800), "timestamp >= "+timestamp, sb.GE(key, tuple))
default:
sb.Where(sb.GE("ts_bucket_start", bucket-1800), "timestamp >= "+timestamp, sb.GT(key, tuple))
}
}
// span_id breaks timestamp ties so the order matches the cursor key; otherwise tied spans
// can be skipped or repeated across pages.
if page.From == spantypes.ThreadBefore {
sb.OrderByDesc("timestamp")
sb.OrderByDesc("span_id")
} else {
sb.OrderByAsc("timestamp")
sb.OrderByAsc("span_id")
}
sb.Limit(page.Limit)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var spans []spantypes.StorableSpan
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying thread spans")
}
return spans, nil
}
// messagesExistCondition resolves the gen_ai message keys through the attribute evolution metadata
// and the use_trace_attributes_json flag, so the filter reads the same columns the query builder does.
func (s *traceStore) messagesExistCondition(ctx context.Context, q qbtypes.QueryInfo, orgID valuer.UUID, bounds *spantypes.TraceBounds, sb *sqlbuilder.SelectBuilder) (string, error) {
names := []string{aiobservabilitytypes.GenAIInputMessages, aiobservabilitytypes.GenAIOutputMessages}
selectors := make([]*telemetrytypes.FieldKeySelector, len(names))
for i, name := range names {
selectors[i] = &telemetrytypes.FieldKeySelector{
StartUnixMilli: bounds.Start.UnixMilli(),
EndUnixMilli: bounds.End.UnixMilli(),
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextAttribute,
Name: name,
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
}
}
fieldKeys, _, err := s.metadataStore.GetKeysMulti(ctx, orgID, selectors)
if err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "error fetching thread field keys")
}
conds := make([]string, 0, len(names))
for _, name := range names {
key := &telemetrytypes.TelemetryFieldKey{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute}
keyConds, _, err := querybuilder.Conditions(ctx, q, s.storage, key, qbtypes.FilterOperatorExists, nil, fieldKeys, false, sb)
if err != nil {
return "", err
}
conds = append(conds, keyConds...)
}
return sb.Or(conds...), nil
}
func (s *traceStore) GetThreadCursor(ctx context.Context, traceID string, bounds *spantypes.TraceBounds, spanID string) (*spantypes.ThreadCursor, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("toUnixTimestamp64Nano(timestamp)")
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
sb.Where(
sb.E("trace_id", traceID),
sb.GE("ts_bucket_start", bounds.Start.Unix()-1800),
sb.LE("ts_bucket_start", bounds.End.Unix()),
sb.E("span_id", spanID),
)
sb.Limit(1)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var timeUnixNano int64
if err := s.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&timeUnixNano); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errors.NewNotFoundf(spantypes.ErrCodeThreadSpanNotFound, "span %s not found in trace %s", spanID, traceID)
}
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying thread span")
}
return &spantypes.ThreadCursor{TimeUnixNano: uint64(timeUnixNano), SpanID: spanID}, nil
}
func (s *traceStore) GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
@@ -451,7 +196,7 @@ func (s *traceStore) GetFlamegraphSpans(ctx context.Context, traceID string, sta
return spans, nil
}
func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, bounds *spantypes.TraceBounds, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, summary *spantypes.TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
fieldExpr, err := buildFieldExpr(fieldKey)
if err != nil {
return nil, err
@@ -461,8 +206,8 @@ func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, bo
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
sb.Where(
sb.E("trace_id", traceID),
sb.GE("ts_bucket_start", bounds.Start.Unix()-1800),
sb.LE("ts_bucket_start", bounds.End.Unix()),
sb.GE("ts_bucket_start", summary.Start.Unix()-1800),
sb.LE("ts_bucket_start", summary.End.Unix()),
"notEmpty("+fieldExpr+")",
)
sb.GroupBy("field_value")
@@ -479,7 +224,7 @@ func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, bo
return result, nil
}
func (s *traceStore) GetSpanDurationByField(ctx context.Context, traceID string, bounds *spantypes.TraceBounds, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
func (s *traceStore) GetSpanDurationByField(ctx context.Context, traceID string, summary *spantypes.TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
fieldExpr, err := buildFieldExpr(fieldKey)
if err != nil {
return nil, err
@@ -495,8 +240,8 @@ func (s *traceStore) GetSpanDurationByField(ctx context.Context, traceID string,
allSpansSB.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
allSpansSB.Where(
allSpansSB.E("trace_id", traceID),
allSpansSB.GE("ts_bucket_start", bounds.Start.Unix()-1800),
allSpansSB.LE("ts_bucket_start", bounds.End.Unix()),
allSpansSB.GE("ts_bucket_start", summary.Start.Unix()-1800),
allSpansSB.LE("ts_bucket_start", summary.End.Unix()),
"notEmpty(field_value)",
)
allSpansSB.OrderByAsc("timestamp")

File diff suppressed because one or more lines are too long

View File

@@ -6,23 +6,18 @@ import (
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// Handler exposes HTTP handlers for trace detail APIs.
type Handler interface {
GetTraceSummary(http.ResponseWriter, *http.Request)
GetWaterfallV4(http.ResponseWriter, *http.Request)
GetTraceAggregations(http.ResponseWriter, *http.Request)
GetFlamegraph(http.ResponseWriter, *http.Request)
GetThread(http.ResponseWriter, *http.Request)
}
// Module defines the business logic for trace detail operations.
type Module interface {
GetTraceSummary(ctx context.Context, orgID valuer.UUID, traceID string) (*spantypes.TraceStats, error)
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error)
GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error)
GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error)
GetThread(ctx context.Context, orgID valuer.UUID, traceID string, query *spantypes.ThreadQuery) (*spantypes.GettableTraceThread, error)
}

View File

@@ -566,6 +566,24 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
}, nil
}
// flattenJSONPaths flattens a decoded JSON document into dotted keys, overwriting existing keys in out.
func flattenJSONPaths(prefix string, m map[string]any, out map[string]any) {
for k, v := range m {
key := k
if prefix != "" {
key = prefix + "." + k
}
switch child := v.(type) {
case map[string]any:
flattenJSONPaths(key, child, out)
case telemetrystoretypes.JSONValue:
flattenJSONPaths(key, child, out)
default:
out[key] = v
}
}
}
// mergeSpanAttributeColumns merges (attributes_string, attributes_number, attributes_bool, resources_string) into
// unified "attributes" and "resource" keys, and parses the stringified `events`
// and `links` columns into structured slices. Raw DB columns are removed.
@@ -580,7 +598,7 @@ func mergeSpanAttributeColumns(data map[string]any) {
resStr, hasRes := data["resources_string"]
if hasStr || hasNum || hasBool || attrJSON != nil || hasRes {
attributes := make(map[string]any)
attrJSON.FlattenInto("", attributes)
flattenJSONPaths("", attrJSON, attributes)
if m, ok := attrStr.(map[string]string); ok {
for k, v := range m {
attributes[k] = v

View File

@@ -161,7 +161,7 @@ func NewModules(
LogsPipeline: impllogspipeline.NewModule(sqlstore),
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore, telemetryMetadataStore, fl), providerSettings, config.TraceDetail),
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
SpanMapper: spanMapper,
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier),
Tag: tagModule,

View File

@@ -15,12 +15,11 @@ var (
aiobservabilitytypes.GenAIAgentName: genAIAttribute(aiobservabilitytypes.GenAIAgentName, telemetrytypes.FieldDataTypeString),
aiobservabilitytypes.GenAIProviderName: genAIAttribute(aiobservabilitytypes.GenAIProviderName, telemetrytypes.FieldDataTypeString),
aiobservabilitytypes.GenAIUsageInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageInputTokens, telemetrytypes.FieldDataTypeNumber),
aiobservabilitytypes.GenAIUsageOutputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageOutputTokens, telemetrytypes.FieldDataTypeNumber),
aiobservabilitytypes.GenAIUsageCacheReadInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheReadInputTokens, telemetrytypes.FieldDataTypeNumber),
aiobservabilitytypes.GenAIUsageCacheCreationInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheCreationInputTokens, telemetrytypes.FieldDataTypeNumber),
aiobservabilitytypes.GenAIUsageReasoningOutputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageReasoningOutputTokens, telemetrytypes.FieldDataTypeNumber),
aiobservabilitytypes.SignozGenAITotalCost: genAIAttribute(aiobservabilitytypes.SignozGenAITotalCost, telemetrytypes.FieldDataTypeNumber),
aiobservabilitytypes.GenAIUsageInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageInputTokens, telemetrytypes.FieldDataTypeFloat64),
aiobservabilitytypes.GenAIUsageOutputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageOutputTokens, telemetrytypes.FieldDataTypeFloat64),
aiobservabilitytypes.GenAIUsageCacheReadInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheReadInputTokens, telemetrytypes.FieldDataTypeFloat64),
aiobservabilitytypes.GenAIUsageCacheCreationInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheCreationInputTokens, telemetrytypes.FieldDataTypeFloat64),
aiobservabilitytypes.SignozGenAITotalCost: genAIAttribute(aiobservabilitytypes.SignozGenAITotalCost, telemetrytypes.FieldDataTypeFloat64),
aiobservabilitytypes.GenAIInputMessages: genAIAttribute(aiobservabilitytypes.GenAIInputMessages, telemetrytypes.FieldDataTypeString),
aiobservabilitytypes.GenAIOutputMessages: genAIAttribute(aiobservabilitytypes.GenAIOutputMessages, telemetrytypes.FieldDataTypeString),

View File

@@ -15,7 +15,6 @@ const (
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
GenAIUsageReasoningOutputTokens = "gen_ai.usage.reasoning.output_tokens"
GenAIInputMessages = "gen_ai.input.messages"
GenAIOutputMessages = "gen_ai.output.messages"

View File

@@ -1869,6 +1869,247 @@ func TestInvalidateAreaChartPanelSpecValues(t *testing.T) {
}
}
func TestScatterPlotPanelDefaults(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/ScatterPlotPanel",
"spec": {}
},
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(data)
require.NoError(t, err, "unmarshal and validate failed")
require.IsType(t, &ScatterPlotPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
spec := d.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
assert.Empty(t, spec.Dimensions.X, "an omitted x stays empty so the renderer picks the first value column")
assert.Empty(t, spec.Dimensions.Y, "an omitted y stays empty so the renderer picks the second value column")
assert.Empty(t, spec.Dimensions.ColorBy, "an omitted colorBy stays empty so the renderer colours by every group-by label")
assert.Equal(t, "auto", spec.Axes.X.Scale.ValueOrDefault(), "expected x scale default auto")
assert.Equal(t, "auto", spec.Axes.Y.Scale.ValueOrDefault(), "expected y scale default auto")
assert.Nil(t, spec.Axes.X.SoftMin, "expected x softMin unset")
assert.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
assert.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
assert.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
output, err := json.Marshal(d)
require.NoError(t, err, "marshal dashboard failed")
assert.Contains(t, string(output), `"axes":{"x":{"softMin":null,"softMax":null,"scale":"auto","label":""},"y":{"softMin":null,"softMax":null,"scale":"auto","label":""}}`, "expected default axes in stored/response JSON")
assert.Contains(t, string(output), `"dimensions":{"x":"","y":"","sizeBy":"","colorBy":null}`, "expected empty dimensions in stored/response JSON")
assert.Contains(t, string(output), `"chartAppearance":{"points":{"size":null,"minSize":null,"maxSize":null,"opacity":null}}`, "omitted point settings stay null so the renderer applies its defaults")
}
func TestScatterPlotPanelRoundTrip(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/ScatterPlotPanel",
"spec": {
"visualization": {"timePreference": "global_time"},
"dimensions": {"x": "A.count()", "y": "A.p99(duration_nano)", "sizeBy": "A.countIf(has_error = true)", "colorBy": ["k8s.namespace.name", "k8s.pod.name"]},
"formatting": {"columnUnits": {"A.p99(duration_nano)": "ns"}, "decimalPrecision": "2"},
"axes": {"x": {"softMin": 0, "softMax": null, "scale": "log", "label": "Throughput"}, "y": {"softMin": null, "softMax": 1000, "scale": "symlog", "label": "p99 latency"}},
"chartAppearance": {"points": {"size": 8, "minSize": 4, "maxSize": 4, "opacity": 0.1}},
"legend": {"position": "bottom", "mode": "list", "customColors": {}},
"thresholds": [{"value": 300, "unit": "ms", "color": "#f00", "label": "p99 SLO"}]
}
},
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A", "signal": "traces",
"aggregations": [{"expression": "count()"}, {"expression": "p99(duration_nano)"}, {"expression": "countIf(has_error = true)"}],
"groupBy": [{"name": "k8s.namespace.name"}]
}}}}]
}
}
},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(data)
require.NoError(t, err, "unmarshal and validate failed")
require.IsType(t, &ScatterPlotPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
spec := d.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
assert.Equal(t, ScatterPlotDimensions{X: "A.count()", Y: "A.p99(duration_nano)", SizeBy: "A.countIf(has_error = true)", ColorBy: []string{"k8s.namespace.name", "k8s.pod.name"}}, spec.Dimensions)
assert.Equal(t, "Throughput", spec.Axes.X.Label)
points := spec.ChartAppearance.Points
require.NotNil(t, points.Size)
require.NotNil(t, points.MinSize)
require.NotNil(t, points.MaxSize)
require.NotNil(t, points.Opacity)
assert.Equal(t, PointDiameter(8), *points.Size)
assert.Equal(t, PointDiameter(4), *points.MinSize, "minSize may equal maxSize")
assert.Equal(t, PointOpacity(0.1), *points.Opacity, "the opacity floor is inclusive")
assert.Equal(t, "p99 latency", spec.Axes.Y.Label)
assert.Equal(t, "log", spec.Axes.X.Scale.ValueOrDefault(), "expected x scale log")
assert.Equal(t, "symlog", spec.Axes.Y.Scale.ValueOrDefault(), "expected y scale symlog")
require.NotNil(t, spec.Axes.X.SoftMin, "a zero softMin is a set value")
assert.Equal(t, 0.0, *spec.Axes.X.SoftMin)
require.NotNil(t, spec.Axes.Y.SoftMax)
assert.Equal(t, 1000.0, *spec.Axes.Y.SoftMax)
assert.Equal(t, "ns", spec.Formatting.ColumnUnits["A.p99(duration_nano)"])
require.Len(t, spec.Thresholds, 1)
assert.Equal(t, 300.0, spec.Thresholds[0].Value)
output, err := json.Marshal(d)
require.NoError(t, err, "marshal dashboard failed")
for _, want := range []string{
`"dimensions":{"x":"A.count()","y":"A.p99(duration_nano)","sizeBy":"A.countIf(has_error = true)","colorBy":["k8s.namespace.name","k8s.pod.name"]}`,
`"x":{"softMin":0,"softMax":null,"scale":"log","label":"Throughput"}`,
`"chartAppearance":{"points":{"size":8,"minSize":4,"maxSize":4,"opacity":0.1}}`,
`"y":{"softMin":null,"softMax":1000,"scale":"symlog","label":"p99 latency"}`,
} {
assert.Contains(t, string(output), want, "expected stored/response JSON to contain %s", want)
}
}
func TestInvalidateScatterPlotPanelSpecValues(t *testing.T) {
tests := []struct {
scenario string
panelKind string
panelSpec string
expectedErrorSubstring string
}{
{
scenario: "unknown axis scale",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"x": {"scale": "sqrt"}}}`,
expectedErrorSubstring: "invalid axis scale \"sqrt\": must be `auto`, `linear`, `log`, or `symlog`",
},
{
scenario: "non-string axis scale",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"y": {"scale": true}}}`,
expectedErrorSubstring: "cannot unmarshal bool",
},
{
scenario: "isLogScale on a scatter plot axis",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"x": {"isLogScale": true}}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "single-axis axes shape on a scatter plot panel",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"softMin": 0}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "unknown dimension",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"dimensions": {"z": "A"}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "point size below the minimum",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"size": 1}}}`,
expectedErrorSubstring: "invalid point size 1: must be a whole number between 2 and 40",
},
{
scenario: "max point size above the maximum",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"maxSize": 41}}}`,
expectedErrorSubstring: "invalid point size 41",
},
{
scenario: "fractional point size",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"minSize": 4.5}}}`,
expectedErrorSubstring: "invalid point size 4.5",
},
{
scenario: "min point size above max",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"minSize": 20, "maxSize": 10}}}`,
expectedErrorSubstring: "minSize 20 must not exceed maxSize 10",
},
{
scenario: "point opacity below the floor",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"opacity": 0.05}}}`,
expectedErrorSubstring: "invalid point opacity 0.05: must be between 0.1 and 1",
},
{
scenario: "point opacity as a percentage",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"opacity": 70}}}`,
expectedErrorSubstring: "invalid point opacity 70",
},
{
scenario: "area fill fields on a scatter plot",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"fillOpacity": 0.5}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "a single colour key as a string",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"dimensions": {"colorBy": "service.name"}}`,
expectedErrorSubstring: "cannot unmarshal string",
},
{
scenario: "panel-wide unit on a scatter plot panel",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"formatting": {"unit": "ms"}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "threshold without a color",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"thresholds": [{"value": 300}]}`,
expectedErrorSubstring: "Thresholds[0].Color",
},
{
scenario: "scale on a time series panel",
panelKind: "signoz/TimeSeriesPanel",
panelSpec: `{"axes": {"scale": "log"}}`,
expectedErrorSubstring: "unknown field",
},
}
for _, test := range tests {
t.Run(test.scenario, func(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "` + test.panelKind + `", "spec": ` + test.panelSpec + `},
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected the spec to be rejected")
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
})
}
}
func TestNumberPanelDefaults(t *testing.T) {
data := []byte(`{
"variables": [],

View File

@@ -28,16 +28,17 @@ type PanelPlugin struct {
// (and strips the duplicate parent properties) after reflection.
func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return markDiscriminator(s, "kind", map[string]string{
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
})
}
@@ -71,6 +72,7 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
PanelPluginVariant[ScatterPlotPanelSpec]{Kind: string(PanelKindScatterPlot)},
}
}
@@ -227,16 +229,17 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
var (
panelPluginSpecs = map[PanelPluginKind]func() any{
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
PanelKindNumber: func() any { return new(NumberPanelSpec) },
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
PanelKindTable: func() any { return new(TablePanelSpec) },
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
PanelKindList: func() any { return new(ListPanelSpec) },
PanelKindText: func() any { return new(TextPanelSpec) },
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
PanelKindNumber: func() any { return new(NumberPanelSpec) },
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
PanelKindTable: func() any { return new(TablePanelSpec) },
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
PanelKindList: func() any { return new(ListPanelSpec) },
PanelKindText: func() any { return new(TextPanelSpec) },
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
}
queryPluginSpecs = map[QueryPluginKind]func() any{
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
@@ -252,16 +255,17 @@ var (
VariableKindCustom: func() any { return new(CustomVariableSpec) },
}
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindAreaChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder},
PanelKindText: {},
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindAreaChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder},
PanelKindText: {},
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
}
)

View File

@@ -214,7 +214,7 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
CompositeQuery: composite,
FormatOptions: &qb.FormatOptions{
FillGaps: fillGaps,
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable,
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable || panel.Spec.Plugin.Kind == PanelKindScatterPlot,
},
}, nil
}

View File

@@ -209,34 +209,38 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
}
})
t.Run("sets FormatTableResultForUI only for table panels", func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{
Plugin: PanelPlugin{Kind: PanelKindTable},
Queries: []Query{
{
Kind: qb.RequestTypeScalar,
Spec: QuerySpec{
Plugin: QueryPlugin{
Kind: QueryKindBuilder,
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
t.Run("sets FormatTableResultForUI for panels that join scalar rows", func(t *testing.T) {
for _, kind := range []PanelPluginKind{PanelKindTable, PanelKindScatterPlot} {
t.Run(string(kind), func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{
Plugin: PanelPlugin{Kind: kind},
Queries: []Query{
{
Kind: qb.RequestTypeScalar,
Spec: QuerySpec{
Plugin: QueryPlugin{
Kind: QueryKindBuilder,
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
},
},
},
},
},
},
},
},
},
},
}
}
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
require.NoError(t, err)
require.NotNil(t, req.FormatOptions)
assert.True(t, req.FormatOptions.FormatTableResultForUI)
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
require.NoError(t, err)
require.NotNil(t, req.FormatOptions)
assert.True(t, req.FormatOptions.FormatTableResultForUI)
})
}
})
t.Run("sets FillGaps from the panel visualization", func(t *testing.T) {

View File

@@ -2,6 +2,7 @@ package dashboardtypes
import (
"encoding/json"
"math"
"strconv"
"github.com/SigNoz/signoz/pkg/errors"
@@ -166,20 +167,21 @@ func (BuilderQuerySpec) JSONSchemaOneOf() []any {
type PanelPluginKind string
const (
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
PanelKindTable PanelPluginKind = "signoz/TablePanel"
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
PanelKindList PanelPluginKind = "signoz/ListPanel"
PanelKindText PanelPluginKind = "signoz/TextPanel"
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
PanelKindTable PanelPluginKind = "signoz/TablePanel"
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
PanelKindList PanelPluginKind = "signoz/ListPanel"
PanelKindText PanelPluginKind = "signoz/TextPanel"
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
)
func (PanelPluginKind) Enum() []any {
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap}
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot}
}
func (k PanelPluginKind) rendersWithoutQuery() bool {
@@ -329,6 +331,69 @@ type TextPanelSpec struct {
HeaderOptions HeaderOptions `json:"headerOptions"`
}
type ScatterPlotPanelSpec struct {
Visualization BasicVisualization `json:"visualization"`
Dimensions ScatterPlotDimensions `json:"dimensions"`
Formatting TableFormatting `json:"formatting"`
Axes ScatterPlotAxes `json:"axes"`
ChartAppearance ScatterPlotChartAppearance `json:"chartAppearance"`
Legend Legend `json:"legend"`
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive"`
}
// ScatterPlotDimensions binds result columns to what a dot encodes. Value columns
// use the Table column key, so formatting.columnUnits carries over between the
// two kinds.
type ScatterPlotDimensions struct {
X string `json:"x" description:"Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column."`
Y string `json:"y" description:"Value column key plotted on the y axis. Empty uses the second value column."`
SizeBy string `json:"sizeBy" description:"Value column key that scales dot size. Empty draws every dot at the default size."`
ColorBy []string `json:"colorBy" description:"Group-by label names (e.g. k8s.namespace.name) whose combined values colour dots and drive the legend. Empty colours by every group-by label."`
}
type ScatterPlotAxes struct {
X ScatterPlotAxis `json:"x"`
Y ScatterPlotAxis `json:"y"`
}
type ScatterPlotAxis struct {
SoftMin *float64 `json:"softMin"`
SoftMax *float64 `json:"softMax"`
Scale AxisScale `json:"scale"`
Label string `json:"label" description:"Axis title. Empty draws none."`
}
type ScatterPlotChartAppearance struct {
Points ScatterPlotPoints `json:"points"`
}
// ScatterPlotPoints keeps the fixed size and the size range side by side so
// binding or unbinding dimensions.size restores the other's last setting. Nil
// fields resolve to the renderer default.
type ScatterPlotPoints struct {
Size *PointDiameter `json:"size" description:"Diameter of every dot when dimensions.size is unset."`
MinSize *PointDiameter `json:"minSize" description:"Diameter of the smallest dot when dimensions.size is set."`
MaxSize *PointDiameter `json:"maxSize" description:"Diameter of the largest dot when dimensions.size is set."`
Opacity *PointOpacity `json:"opacity"`
}
func (p *ScatterPlotPoints) UnmarshalJSON(data []byte) error {
type alias ScatterPlotPoints
var tmp alias
if err := json.Unmarshal(data, &tmp); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid points")
}
*p = ScatterPlotPoints(tmp)
return p.validate()
}
func (p ScatterPlotPoints) validate() error {
if p.MinSize != nil && p.MaxSize != nil && *p.MinSize > *p.MaxSize {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid points: minSize %d must not exceed maxSize %d", *p.MinSize, *p.MaxSize)
}
return nil
}
type TextPresentation struct {
TextAlign TextAlign `json:"textAlign"`
VerticalAlign VerticalAlign `json:"verticalAlign"`
@@ -695,6 +760,47 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
}
}
// AxisScale `auto` leaves the choice to the renderer; `symlog` is log-like but
// defined at and below zero.
type AxisScale struct{ valuer.String }
var (
AxisScaleAuto = AxisScale{valuer.NewString("auto")} // default
AxisScaleLinear = AxisScale{valuer.NewString("linear")}
AxisScaleLog = AxisScale{valuer.NewString("log")}
AxisScaleSymlog = AxisScale{valuer.NewString("symlog")}
)
func (AxisScale) Enum() []any {
return []any{AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog}
}
func (as AxisScale) ValueOrDefault() string {
if as.IsZero() {
return AxisScaleAuto.StringValue()
}
return as.StringValue()
}
func (as AxisScale) MarshalJSON() ([]byte, error) {
return json.Marshal(as.ValueOrDefault())
}
func (as *AxisScale) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid axis scale: must be a string, one of `auto`, `linear`, `log`, or `symlog`")
}
val := AxisScale{valuer.NewString(v)}
switch val {
case AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog:
*as = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid axis scale %q: must be `auto`, `linear`, `log`, or `symlog`", v)
}
}
type FillMode struct{ valuer.String }
var (
@@ -833,6 +939,53 @@ func (o *FillOpacity) UnmarshalJSON(data []byte) error {
return nil
}
const (
minPointDiameter = 2
maxPointDiameter = 40
)
// PointDiameter is a scatter dot's diameter in whole CSS pixels.
type PointDiameter int
func (PointDiameter) PrepareJSONSchema(s *jsonschema.Schema) error {
s.WithMinimum(minPointDiameter).WithMaximum(maxPointDiameter)
return nil
}
func (d *PointDiameter) UnmarshalJSON(data []byte) error {
var v float64
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point size: must be a whole number between %d and %d", minPointDiameter, maxPointDiameter)
}
if v != math.Trunc(v) || v < minPointDiameter || v > maxPointDiameter {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point size %v: must be a whole number between %d and %d", v, minPointDiameter, maxPointDiameter)
}
*d = PointDiameter(v)
return nil
}
const minPointOpacity = 0.1
// PointOpacity is a scatter dot's fill alpha. The floor keeps a dot visible.
type PointOpacity float64
func (PointOpacity) PrepareJSONSchema(s *jsonschema.Schema) error {
s.WithMinimum(minPointOpacity).WithMaximum(1)
return nil
}
func (o *PointOpacity) UnmarshalJSON(data []byte) error {
var v float64
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point opacity: must be a number between %v and 1", minPointOpacity)
}
if v < minPointOpacity || v > 1 {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point opacity %v: must be between %v and 1", v, minPointOpacity)
}
*o = PointOpacity(v)
return nil
}
type SpanGaps struct {
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
FillLessThan string `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`

View File

@@ -31,15 +31,12 @@ type SpanMapperStore interface {
// TraceStore defines the data access interface for trace detail queries.
type TraceStore interface {
GetTraceBounds(ctx context.Context, traceID string) (*TraceBounds, error)
GetTraceStats(ctx context.Context, orgID valuer.UUID, traceID string, bounds *TraceBounds) (*TraceStats, error)
GetTraceSpans(ctx context.Context, traceID string, bounds *TraceBounds) ([]StorableSpan, error)
GetTraceSummary(ctx context.Context, traceID string) (*TraceSummary, error)
GetTraceSpans(ctx context.Context, traceID string, summary *TraceSummary) ([]StorableSpan, error)
GetMinimalSpans(ctx context.Context, traceID string, start, end time.Time) ([]MinimalSpan, error)
GetTraceSpansByIDs(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
GetThreadSpans(ctx context.Context, orgID valuer.UUID, traceID string, bounds *TraceBounds, page ThreadPage) ([]StorableSpan, error)
GetThreadCursor(ctx context.Context, traceID string, bounds *TraceBounds, spanID string) (*ThreadCursor, error)
GetSpanCountByField(ctx context.Context, traceID string, bounds *TraceBounds, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
GetSpanDurationByField(ctx context.Context, traceID string, bounds *TraceBounds, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
GetSpanCountByField(ctx context.Context, traceID string, summary *TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
GetSpanDurationByField(ctx context.Context, traceID string, summary *TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
}

View File

@@ -1,202 +0,0 @@
package spantypes
import (
"encoding/base64"
"encoding/json"
"maps"
"github.com/SigNoz/signoz/pkg/errors"
)
const (
threadDefaultLimit = 20
threadMaxLimit = 100
)
const (
ThreadAfter ThreadFrom = iota // > cursor, ascending
ThreadAt // >= cursor, ascending
ThreadBefore // < cursor, descending
)
var (
ErrCodeThreadInvalidLimit = errors.MustNewCode("trace_thread_invalid_limit")
ErrCodeThreadInvalidCursor = errors.MustNewCode("trace_thread_invalid_cursor")
ErrCodeThreadInvalidAnchor = errors.MustNewCode("trace_thread_invalid_anchor")
ErrCodeThreadSpanNotFound = errors.MustNewCode("trace_thread_span_not_found")
)
type GetTraceThreadParams struct {
Limit int `query:"limit" description:"Page size, at most 100. 0 means 20."`
After string `query:"after" description:"The nextCursor of a page; returns the spans after it. Set only one of after, before and spanId."`
Before string `query:"before" description:"The prevCursor of a page; returns the spans before it. Set only one of after, before and spanId."`
SpanID string `query:"spanId" description:"Returns the page around this span. Set only one of after, before and spanId."`
}
type ThreadQuery struct {
Limit int
After *ThreadCursor
Before *ThreadCursor
SpanID string
}
// ThreadCursor is the (TimeUnixNano, SpanID) key of a span.
type ThreadCursor struct {
TimeUnixNano uint64 `json:"timeUnixNano"`
SpanID string `json:"spanId"`
}
type ThreadFrom int
type ThreadPage struct {
Cursor *ThreadCursor
From ThreadFrom
Limit int
}
type GettableTraceThread struct {
Spans []*ThreadSpan `json:"spans" required:"true" nullable:"false"`
PrevCursor string `json:"prevCursor,omitempty"`
NextCursor string `json:"nextCursor,omitempty"`
}
// ThreadSpan carries the fields the span details pane reads; snake_case keys match WaterfallSpan.
type ThreadSpan struct {
SpanID string `json:"span_id" required:"true"`
TraceID string `json:"trace_id" required:"true"`
ParentSpanID string `json:"parent_span_id" required:"true"`
Name string `json:"name" required:"true"`
KindString string `json:"kind_string" required:"true"`
TimeUnix uint64 `json:"time_unix" required:"true"`
DurationNano uint64 `json:"duration_nano" required:"true"`
HasError bool `json:"has_error" required:"true"`
StatusCodeString string `json:"status_code_string" required:"true"`
StatusMessage string `json:"status_message" required:"true"`
Resource map[string]string `json:"resource" required:"true" nullable:"false"`
Attributes map[string]any `json:"attributes" required:"true" nullable:"false"`
Events []Event `json:"events" required:"true" nullable:"false"`
References []OtelSpanRef `json:"references" required:"true" nullable:"false"`
timeUnixNano uint64
}
func NewThreadQuery(params *GetTraceThreadParams) (*ThreadQuery, error) {
query := &ThreadQuery{Limit: params.Limit, SpanID: params.SpanID}
if query.Limit < 0 {
return nil, errors.NewInvalidInputf(ErrCodeThreadInvalidLimit, "limit cannot be negative, got %d", query.Limit)
}
if query.Limit == 0 {
query.Limit = threadDefaultLimit
}
if query.Limit > threadMaxLimit {
return nil, errors.NewInvalidInputf(ErrCodeThreadInvalidLimit, "limit cannot exceed %d, got %d", threadMaxLimit, query.Limit)
}
anchors := 0
for _, value := range []string{params.After, params.Before, params.SpanID} {
if value != "" {
anchors++
}
}
if anchors > 1 {
return nil, errors.NewInvalidInputf(ErrCodeThreadInvalidAnchor, "only one of after, before and spanId can be set")
}
encoded := params.After
if encoded == "" {
encoded = params.Before
}
if encoded == "" {
return query, nil
}
data, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeThreadInvalidCursor, "invalid cursor")
}
cursor := new(ThreadCursor)
if err := json.Unmarshal(data, cursor); err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeThreadInvalidCursor, "invalid cursor")
}
if cursor.SpanID == "" {
return nil, errors.NewInvalidInputf(ErrCodeThreadInvalidCursor, "invalid cursor: missing span id")
}
if params.After != "" {
query.After = cursor
} else {
query.Before = cursor
}
return query, nil
}
func (c ThreadCursor) Encode() string {
data, _ := json.Marshal(c)
return base64.RawURLEncoding.EncodeToString(data)
}
// NewGettableTraceThread takes up to limit+1 spans on each side of the anchor: before in
// descending order, after in ascending order. Half the page goes to before, the rest to after,
// and a short side gives its room to the other. An extra span on a side sets that side's cursor.
func NewGettableTraceThread(traceID string, query *ThreadQuery, before, after []StorableSpan) *GettableTraceThread {
nAfter := min(len(after), query.Limit-min(len(before), query.Limit/2))
nBefore := min(len(before), query.Limit-nAfter)
hasPrev := len(before) > nBefore || query.After != nil
hasNext := len(after) > nAfter || query.Before != nil
spans := make([]*ThreadSpan, 0, nBefore+nAfter)
for i := nBefore - 1; i >= 0; i-- {
spans = append(spans, newThreadSpan(traceID, &before[i]))
}
for i := range nAfter {
spans = append(spans, newThreadSpan(traceID, &after[i]))
}
thread := &GettableTraceThread{Spans: spans}
if len(spans) == 0 {
return thread
}
if hasPrev {
thread.PrevCursor = spans[0].cursor().Encode()
}
if hasNext {
thread.NextCursor = spans[len(spans)-1].cursor().Encode()
}
return thread
}
func (s *ThreadSpan) cursor() ThreadCursor {
return ThreadCursor{TimeUnixNano: s.timeUnixNano, SpanID: s.SpanID}
}
func newThreadSpan(traceID string, storable *StorableSpan) *ThreadSpan {
resources := make(map[string]string, len(storable.ResourcesString))
maps.Copy(resources, storable.ResourcesString)
timeUnixNano := uint64(storable.StartTime.UnixNano())
return &ThreadSpan{
SpanID: storable.SpanID,
TraceID: traceID,
ParentSpanID: storable.ParentSpanID,
Name: storable.Name,
KindString: storable.SpanKind,
TimeUnix: timeUnixNano / 1_000_000, // client expects millis, as in the waterfall
DurationNano: storable.DurationNano,
HasError: storable.HasError,
StatusCodeString: storable.StatusCodeString,
StatusMessage: storable.StatusMessage,
Resource: resources,
Attributes: threadAttributes(storable),
Events: storable.UnmarshalledEvents(),
References: storable.UnmarshalledRefs(),
timeUnixNano: timeUnixNano,
}
}
// threadAttributes reads the JSON column and falls back to the legacy maps for spans written
// before the JSON rollout.
func threadAttributes(storable *StorableSpan) map[string]any {
if len(storable.AttributesJSON) > 0 {
attributes := make(map[string]any, len(storable.AttributesJSON))
storable.AttributesJSON.FlattenInto("", attributes)
return attributes
}
return storable.Attributes()
}

View File

@@ -1,75 +0,0 @@
package spantypes
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewThreadQuery_Cursor(t *testing.T) {
testCases := []struct {
name string
cursor string
want *ThreadCursor
wantErr bool
}{
{name: "EncodedCursor_RoundTrips", cursor: ThreadCursor{TimeUnixNano: 1757500000123456789, SpanID: "f1fa1bc863e94dd0"}.Encode(), want: &ThreadCursor{TimeUnixNano: 1757500000123456789, SpanID: "f1fa1bc863e94dd0"}},
{name: "NotBase64_Rejected", cursor: "not base64!", wantErr: true},
{name: "NotJSON_Rejected", cursor: "bm90IGpzb24", wantErr: true},
{name: "MissingSpanID_Rejected", cursor: "eyJ0aW1lVW5peE5hbm8iOiAxfQ", wantErr: true},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
after, err := NewThreadQuery(&GetTraceThreadParams{After: testCase.cursor})
before, errBefore := NewThreadQuery(&GetTraceThreadParams{Before: testCase.cursor})
if testCase.wantErr {
assert.Error(t, err)
assert.Error(t, errBefore)
return
}
require.NoError(t, err)
require.NoError(t, errBefore)
assert.Equal(t, testCase.want, after.After)
assert.Nil(t, after.Before)
assert.Equal(t, testCase.want, before.Before)
assert.Nil(t, before.After)
})
}
}
func TestThreadAttributes(t *testing.T) {
testCases := []struct {
name string
span StorableSpan
wantAttrs map[string]any
}{
{
name: "JSONColumnPresent_LegacyMapsIgnored",
span: StorableSpan{
AttributesJSON: telemetrystoretypes.JSONValue{"gen_ai": map[string]any{"request": map[string]any{"model": "json"}}},
AttributesString: map[string]string{"gen_ai.request.model": "map", "http.method": "GET"},
},
wantAttrs: map[string]any{"gen_ai.request.model": "json"},
},
{
name: "NoJSONColumn_FallsBackToLegacyMaps",
span: StorableSpan{
AttributesString: map[string]string{"gen_ai.output.messages": `[{"role":"assistant","content":"hello"}]`},
AttributesNumber: map[string]float64{"gen_ai.usage.input_tokens": 12},
AttributesBool: map[string]bool{"gen_ai.stream": true},
},
wantAttrs: map[string]any{
"gen_ai.output.messages": `[{"role":"assistant","content":"hello"}]`,
"gen_ai.usage.input_tokens": float64(12),
"gen_ai.stream": true,
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.wantAttrs, threadAttributes(&testCase.span))
})
}
}

View File

@@ -1,62 +0,0 @@
package spantypes
// TraceStats is the single-row result of the trace summary aggregate query.
type TraceStats struct {
StartNs uint64
EndNs uint64
RootServiceName string
RootEntryPoint string
RootSpanStatusCode string
TotalSpans uint64
TotalErrorSpans uint64
HasMissingSpans bool
GenAISpanCount uint64
Tokens TraceAITokens
TotalCost *float64
}
// GettableTraceSummary is the response for the trace summary API; the trace-level
// fields match the waterfall response.
type GettableTraceSummary struct {
StartTimestampMillis uint64 `json:"startTimestampMillis" required:"true"`
EndTimestampMillis uint64 `json:"endTimestampMillis" required:"true"`
RootServiceName string `json:"rootServiceName" required:"true"`
RootServiceEntryPoint string `json:"rootServiceEntryPoint" required:"true"`
RootSpanStatusCode string `json:"rootSpanStatusCode" required:"true"`
TotalSpansCount uint64 `json:"totalSpansCount" required:"true"`
TotalErrorSpansCount uint64 `json:"totalErrorSpansCount" required:"true"`
HasMissingSpans bool `json:"hasMissingSpans" required:"true"`
AI *TraceAISummary `json:"ai,omitempty"`
}
// TraceAISummary is present when any span carries a gen_ai gate key.
type TraceAISummary struct {
Tokens TraceAITokens `json:"tokens" required:"true"`
// TotalCost is omitted when no span carries a cost attribute.
TotalCost *float64 `json:"totalCost,omitempty" nullable:"false"`
}
type TraceAITokens struct {
Input uint64 `json:"input" required:"true"`
Output uint64 `json:"output" required:"true"`
CacheRead uint64 `json:"cacheRead" required:"true"`
CacheWrite uint64 `json:"cacheWrite" required:"true"`
Reasoning uint64 `json:"reasoning" required:"true"`
}
func NewGettableTraceSummary(stats *TraceStats) *GettableTraceSummary {
summary := &GettableTraceSummary{
StartTimestampMillis: stats.StartNs / 1_000_000,
EndTimestampMillis: stats.EndNs / 1_000_000,
RootServiceName: stats.RootServiceName,
RootServiceEntryPoint: stats.RootEntryPoint,
RootSpanStatusCode: stats.RootSpanStatusCode,
TotalSpansCount: stats.TotalSpans,
TotalErrorSpansCount: stats.TotalErrorSpans,
HasMissingSpans: stats.HasMissingSpans,
}
if stats.GenAISpanCount > 0 {
summary.AI = &TraceAISummary{Tokens: stats.Tokens, TotalCost: stats.TotalCost}
}
return summary
}

View File

@@ -8,7 +8,6 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -94,36 +93,35 @@ type WaterfallSpan struct {
// StorableSpan is the ClickHouse scan struct for the v3 waterfall query.
type StorableSpan struct {
StartTime time.Time `ch:"timestamp"`
DurationNano uint64 `ch:"duration_nano"`
SpanID string `ch:"span_id"`
HasError bool `ch:"has_error"`
Kind int8 `ch:"kind"`
ServiceName string `ch:"resource_string_service$$name"`
Name string `ch:"name"`
AttributesString map[string]string `ch:"attributes_string"`
AttributesNumber map[string]float64 `ch:"attributes_number"`
AttributesBool map[string]bool `ch:"attributes_bool"`
AttributesJSON telemetrystoretypes.JSONValue `ch:"attributes"`
ResourcesString map[string]string `ch:"resources_string"`
Events []string `ch:"events"`
StatusMessage string `ch:"status_message"`
StatusCodeString string `ch:"status_code_string"`
SpanKind string `ch:"kind_string"`
ParentSpanID string `ch:"parent_span_id"`
Flags uint32 `ch:"flags"`
IsRemote string `ch:"is_remote"`
TraceState string `ch:"trace_state"`
StatusCode int16 `ch:"status_code"`
DBName string `ch:"db_name"`
DBOperation string `ch:"db_operation"`
HTTPMethod string `ch:"http_method"`
HTTPURL string `ch:"http_url"`
HTTPHost string `ch:"http_host"`
ExternalHTTPMethod string `ch:"external_http_method"`
ExternalHTTPURL string `ch:"external_http_url"`
ResponseStatusCode string `ch:"response_status_code"`
References string `ch:"references"`
StartTime time.Time `ch:"timestamp"`
DurationNano uint64 `ch:"duration_nano"`
SpanID string `ch:"span_id"`
HasError bool `ch:"has_error"`
Kind int8 `ch:"kind"`
ServiceName string `ch:"resource_string_service$$name"`
Name string `ch:"name"`
AttributesString map[string]string `ch:"attributes_string"`
AttributesNumber map[string]float64 `ch:"attributes_number"`
AttributesBool map[string]bool `ch:"attributes_bool"`
ResourcesString map[string]string `ch:"resources_string"`
Events []string `ch:"events"`
StatusMessage string `ch:"status_message"`
StatusCodeString string `ch:"status_code_string"`
SpanKind string `ch:"kind_string"`
ParentSpanID string `ch:"parent_span_id"`
Flags uint32 `ch:"flags"`
IsRemote string `ch:"is_remote"`
TraceState string `ch:"trace_state"`
StatusCode int16 `ch:"status_code"`
DBName string `ch:"db_name"`
DBOperation string `ch:"db_operation"`
HTTPMethod string `ch:"http_method"`
HTTPURL string `ch:"http_url"`
HTTPHost string `ch:"http_host"`
ExternalHTTPMethod string `ch:"external_http_method"`
ExternalHTTPURL string `ch:"external_http_url"`
ResponseStatusCode string `ch:"response_status_code"`
References string `ch:"references"`
}
// MinimalSpan with only the fields needed to build the parent-child tree.

View File

@@ -10,7 +10,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/cachetypes"
)
type TraceBounds struct {
type TraceSummary struct {
TraceID string `ch:"trace_id"`
Start time.Time `ch:"start"`
End time.Time `ch:"end"`

View File

@@ -35,21 +35,3 @@ func (v *JSONValue) Scan(src any) error {
*v = decoded
return nil
}
// FlattenInto writes v into out under dotted keys, overwriting existing keys.
func (v JSONValue) FlattenInto(prefix string, out map[string]any) {
for k, value := range v {
key := k
if prefix != "" {
key = prefix + "." + k
}
switch child := value.(type) {
case map[string]any:
JSONValue(child).FlattenInto(key, out)
case JSONValue:
child.FlattenInto(key, out)
default:
out[key] = value
}
}
}

View File

@@ -99,7 +99,6 @@ def pytest_addoption(parser: pytest.Parser):
default="delete",
help="sqlite mode",
)
# Keep the version defaults below in sync with pull-images in .github/workflows/e2eci.yaml.
parser.addoption(
"--postgres-version",
action="store",

View File

@@ -33,8 +33,6 @@ test.describe('LLM Observability — Model Pricing', () => {
await expect(page.getByTestId('drawer-model-id-input')).toBeVisible();
await page.getByTestId('drawer-model-id-input').fill(MODEL_NAME);
await page.getByTestId('drawer-pattern-input').fill(MODEL_NAME);
await page.getByTestId('drawer-pattern-add-btn').click();
await page.getByTestId('drawer-input-cost').fill(INPUT_COST);
await page.getByTestId('drawer-output-cost').fill(OUTPUT_COST);

View File

@@ -895,7 +895,6 @@ _TRACES_TABLES_TO_TRUNCATE = [
"span_attributes_keys",
"signoz_error_index_v2",
"top_level_operations",
"trace_summary",
]

View File

@@ -1,283 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querierai import root_span
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
WATERFALL_FIELDS = (
"startTimestampMillis",
"endTimestampMillis",
"rootServiceName",
"rootServiceEntryPoint",
"totalSpansCount",
"totalErrorSpansCount",
"hasMissingSpans",
)
@pytest.mark.parametrize("attribute_backend", ["map", "json"])
def test_summary_ai_trace(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
use_attribute_backend: Callable[[str], None],
attribute_backend: str,
) -> None:
"""The summary carries the waterfall's trace-level fields and, for a trace with gen_ai
spans, token totals over every LLM span and the cost summed over the spans that carry it.
Spans are written to one layout only, so a read from the wrong column sums to zero."""
use_attribute_backend(attribute_backend)
write_mode = "json_only" if attribute_backend == "json" else "legacy_only"
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = f"td-summary-{attribute_backend}"
resources = {"service.name": service}
trace_id = TraceIdGenerator.trace_id()
root_id = TraceIdGenerator.span_id()
insert_traces(
[
root_span(now=now, trace_id=trace_id, span_id=root_id, resources=resources, duration_s=4),
Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=1),
trace_id=trace_id,
span_id=TraceIdGenerator.span_id(),
parent_span_id=root_id,
name="chat gpt-4o-mini",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources=resources,
attributes={
"gen_ai.request.model": "gpt-4o-mini",
"gen_ai.usage.input_tokens": 100,
"gen_ai.usage.output_tokens": 20,
"gen_ai.usage.cache_read.input_tokens": 7,
"signoz.gen_ai.usage.tokens.cost": 0.01,
},
attribute_write_mode=write_mode,
),
# a failed LLM call: counted in tokens and errors, but priced by nobody
Traces(
timestamp=now - timedelta(seconds=3),
duration=timedelta(seconds=0.5),
trace_id=trace_id,
span_id=TraceIdGenerator.span_id(),
parent_span_id=root_id,
name="chat gpt-4o-mini",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_ERROR,
resources=resources,
attributes={
"gen_ai.request.model": "gpt-4o-mini",
"gen_ai.usage.input_tokens": 50,
"gen_ai.usage.output_tokens": 5,
},
attribute_write_mode=write_mode,
),
Traces(
timestamp=now - timedelta(seconds=2),
duration=timedelta(seconds=0.5),
trace_id=trace_id,
span_id=TraceIdGenerator.span_id(),
parent_span_id=root_id,
name="execute_tool",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources=resources,
attributes={"gen_ai.tool.name": "get_weather"},
attribute_write_mode=write_mode,
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"authorization": f"Bearer {token}", "content-type": "application/json"}
summary = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/summary"), timeout=10, headers=headers)
assert summary.status_code == HTTPStatus.OK, summary.text
summary = summary.json()["data"]
waterfall = requests.post(
signoz.self.host_configs["8080"].get(f"/api/v4/traces/{trace_id}/waterfall"),
timeout=10,
headers=headers,
json={"selectedSpanId": "", "uncollapsedSpans": []},
)
assert waterfall.status_code == HTTPStatus.OK, waterfall.text
waterfall = waterfall.json()["data"]
assert {k: summary[k] for k in WATERFALL_FIELDS} == {k: waterfall[k] for k in WATERFALL_FIELDS}
assert summary["rootServiceName"] == service
assert summary["rootServiceEntryPoint"] == "POST /api/chat"
assert summary["totalSpansCount"] == 4
assert summary["totalErrorSpansCount"] == 1
assert summary["hasMissingSpans"] is False
assert summary["ai"]["tokens"] == {"input": 150, "output": 25, "cacheRead": 7, "cacheWrite": 0, "reasoning": 0}
assert summary["ai"]["totalCost"] == pytest.approx(0.01)
def test_summary_ai_trace_across_json_rollout(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
seed_attribute_evolution: Callable[[str, datetime], None],
) -> None:
"""A trace that straddles the attribute JSON rollout has LLM spans written only to the legacy
maps before it and to the JSON column after it. The summary window covers both, so the gen_ai
reads must fall back across columns and sum every span."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
rollout = now - timedelta(minutes=30)
seed_attribute_evolution("traces", rollout)
service = "td-summary-rollout"
resources = {"service.name": service}
trace_id = TraceIdGenerator.trace_id()
root_id = TraceIdGenerator.span_id()
insert_traces(
[
Traces(
timestamp=rollout - timedelta(minutes=10),
duration=timedelta(minutes=15),
trace_id=trace_id,
span_id=root_id,
parent_span_id="",
name="long agent run",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources=resources,
attributes={"http.response.status_code": 200},
attribute_write_mode="legacy_only",
),
Traces(
timestamp=rollout - timedelta(minutes=5),
duration=timedelta(seconds=1),
trace_id=trace_id,
span_id=TraceIdGenerator.span_id(),
parent_span_id=root_id,
name="chat gpt-4o-mini",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources=resources,
attributes={"gen_ai.request.model": "gpt-4o-mini", "gen_ai.usage.input_tokens": 100, "gen_ai.usage.output_tokens": 20, "signoz.gen_ai.usage.tokens.cost": 0.01},
attribute_write_mode="legacy_only",
),
Traces(
timestamp=rollout + timedelta(minutes=4),
duration=timedelta(seconds=1),
trace_id=trace_id,
span_id=TraceIdGenerator.span_id(),
parent_span_id=root_id,
name="chat gpt-4o-mini",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources=resources,
attributes={"gen_ai.request.model": "gpt-4o-mini", "gen_ai.usage.input_tokens": 50, "gen_ai.usage.output_tokens": 5, "signoz.gen_ai.usage.tokens.cost": 0.02},
attribute_write_mode="json_only",
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
summary = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/summary"),
timeout=10,
headers={"authorization": f"Bearer {token}"},
)
assert summary.status_code == HTTPStatus.OK, summary.text
summary = summary.json()["data"]
assert summary["totalSpansCount"] == 3
assert summary["rootServiceEntryPoint"] == "long agent run"
assert summary["rootSpanStatusCode"] == "200"
assert summary["ai"]["tokens"] == {"input": 150, "output": 25, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0}
assert summary["ai"]["totalCost"] == pytest.approx(0.03)
def test_summary_non_ai_trace_with_missing_root(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""A trace whose recorded spans all hang off an unrecorded parent reports the synthetic
"Missing Span" root exactly as the waterfall does, and a trace without gen_ai spans has
no `ai` block."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
resources = {"service.name": "td-summary-orphan"}
trace_id = TraceIdGenerator.trace_id()
missing_parent_id = TraceIdGenerator.span_id()
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=5),
duration=timedelta(seconds=2),
trace_id=trace_id,
span_id=TraceIdGenerator.span_id(),
parent_span_id=missing_parent_id,
name="SELECT users",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources=resources,
),
Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=1),
trace_id=trace_id,
span_id=TraceIdGenerator.span_id(),
parent_span_id=missing_parent_id,
name="publish event",
kind=TracesKind.SPAN_KIND_PRODUCER,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources=resources,
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"authorization": f"Bearer {token}", "content-type": "application/json"}
summary = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/summary"), timeout=10, headers=headers)
assert summary.status_code == HTTPStatus.OK, summary.text
summary = summary.json()["data"]
waterfall = requests.post(
signoz.self.host_configs["8080"].get(f"/api/v4/traces/{trace_id}/waterfall"),
timeout=10,
headers=headers,
json={"selectedSpanId": "", "uncollapsedSpans": []},
)
assert waterfall.status_code == HTTPStatus.OK, waterfall.text
waterfall = waterfall.json()["data"]
assert {k: summary[k] for k in WATERFALL_FIELDS} == {k: waterfall[k] for k in WATERFALL_FIELDS}
assert summary["hasMissingSpans"] is True
assert summary["rootServiceName"] == ""
assert summary["rootServiceEntryPoint"] == "Missing Span"
assert summary["rootSpanStatusCode"] == ""
assert summary["totalSpansCount"] == 2
assert "ai" not in summary
def test_summary_unknown_trace(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/traces/{TraceIdGenerator.trace_id()}/summary"),
timeout=10,
headers={"authorization": f"Bearer {token}"},
)
assert response.status_code == HTTPStatus.NOT_FOUND, response.text

View File

@@ -1,327 +0,0 @@
import base64
import json
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.traces import ATTRIBUTE_JSON_ROLLOUT_TIME, TraceIdGenerator, Traces, TracesKind
def test_thread_returns_message_spans_in_order(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
seed_attribute_evolution: Callable[[str, datetime], None],
) -> None:
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
now = datetime.now(tz=UTC).replace(microsecond=0)
trace_id = TraceIdGenerator.trace_id()
root_id, first_llm_id, tool_id, second_llm_id, third_llm_id = (TraceIdGenerator.span_id() for _ in range(5))
resources = {"service.name": "tracedetail-thread"}
first_input = json.dumps([{"role": "user", "parts": [{"type": "text", "content": "weather in Bangalore?"}]}])
first_output = json.dumps([{"role": "assistant", "parts": [{"type": "tool_call", "id": "call_1", "name": "get_weather", "arguments": {"city": "Bangalore"}}], "finish_reason": "tool_call"}])
second_input = json.dumps([{"role": "tool", "content": "sunny", "tool_call_id": "call_1"}])
insert_traces(
[
Traces(timestamp=now - timedelta(seconds=10), duration=timedelta(seconds=9), trace_id=trace_id, span_id=root_id, name="POST /chat", kind=TracesKind.SPAN_KIND_SERVER, resources=resources, attribute_write_mode="json_only"),
Traces(
timestamp=now - timedelta(seconds=8), trace_id=trace_id, span_id=first_llm_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": first_input, "gen_ai.output.messages": first_output}, attribute_write_mode="json_only"
),
Traces(timestamp=now - timedelta(seconds=6), trace_id=trace_id, span_id=tool_id, parent_span_id=root_id, name="execute_tool get_weather", resources=resources, attributes={"gen_ai.tool.name": "get_weather"}, attribute_write_mode="json_only"),
Traces(timestamp=now - timedelta(seconds=4), trace_id=trace_id, span_id=second_llm_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": second_input}, attribute_write_mode="json_only"),
Traces(timestamp=now - timedelta(seconds=2), trace_id=trace_id, span_id=third_llm_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.output.messages": "It is sunny in Bangalore."}, attribute_write_mode="json_only"),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/thread"), headers={"Authorization": f"Bearer {token}"}, timeout=10)
assert response.status_code == HTTPStatus.OK, response.text
thread = response.json()["data"]
assert [span["span_id"] for span in thread["spans"]] == [first_llm_id, second_llm_id, third_llm_id]
assert "nextCursor" not in thread
first, input_only, output_only = thread["spans"]
assert first["time_unix"] == int((now - timedelta(seconds=8)).timestamp() * 1000)
assert first["attributes"]["gen_ai.input.messages"] == first_input
assert first["attributes"]["gen_ai.request.model"] == "gpt-4o"
assert first["attributes"]["gen_ai.output.messages"] == first_output
assert input_only["attributes"]["gen_ai.input.messages"] == second_input
assert "gen_ai.output.messages" not in input_only["attributes"]
assert "gen_ai.input.messages" not in output_only["attributes"]
assert output_only["attributes"]["gen_ai.output.messages"] == "It is sunny in Bangalore."
def test_thread_paginates_with_cursors(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
seed_attribute_evolution: Callable[[str, datetime], None],
) -> None:
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
now = datetime.now(tz=UTC).replace(microsecond=0)
trace_id = TraceIdGenerator.trace_id()
span_ids = [TraceIdGenerator.span_id() for _ in range(3)]
# identical timestamps on the last two exercise the span_id tie-break
timestamps = [now - timedelta(seconds=6), now - timedelta(seconds=3), now - timedelta(seconds=3)]
insert_traces(
[
Traces(timestamp=timestamp, trace_id=trace_id, span_id=span_id, name="chat gpt-4o", resources={"service.name": "tracedetail-thread-pages"}, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
for span_id, timestamp in zip(span_ids, timestamps, strict=True)
]
)
expected_order = [span_ids[0], *sorted(span_ids[1:])]
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
url = signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/thread")
headers = {"Authorization": f"Bearer {token}"}
def get_page(params: dict) -> dict:
response = requests.get(url, params=params, headers=headers, timeout=10)
assert response.status_code == HTTPStatus.OK, f"{params}: {response.text}"
return response.json()["data"]
first = get_page({"limit": 2})
assert [span["span_id"] for span in first["spans"]] == expected_order[:2]
assert "prevCursor" not in first
assert first["nextCursor"]
last = get_page({"limit": 2, "after": first["nextCursor"]})
assert [span["span_id"] for span in last["spans"]] == expected_order[2:]
assert last["prevCursor"]
assert "nextCursor" not in last
previous = get_page({"limit": 2, "before": last["prevCursor"]})
assert [span["span_id"] for span in previous["spans"]] == expected_order[:2]
assert "prevCursor" not in previous
assert previous["nextCursor"] == first["nextCursor"]
middle = get_page({"limit": 1, "before": last["prevCursor"]})
assert [span["span_id"] for span in middle["spans"]] == expected_order[1:2]
assert middle["prevCursor"]
assert middle["nextCursor"]
start = get_page({"limit": 2, "before": middle["prevCursor"]})
assert [span["span_id"] for span in start["spans"]] == expected_order[:1]
assert "prevCursor" not in start
def test_thread_paginates_across_buckets(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
seed_attribute_evolution: Callable[[str, datetime], None],
) -> None:
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
now = datetime.now(tz=UTC).replace(microsecond=0)
bucket = now.replace(minute=0 if now.minute < 30 else 30, second=0)
trace_id = TraceIdGenerator.trace_id()
# neighbours within one 30-minute ts_bucket_start and across bucket boundaries
timestamps = [
bucket - timedelta(minutes=59, seconds=59),
bucket - timedelta(minutes=30, seconds=1),
bucket - timedelta(minutes=30),
bucket - timedelta(seconds=1),
bucket,
]
span_ids = [TraceIdGenerator.span_id() for _ in timestamps]
insert_traces(
[
Traces(timestamp=timestamp, trace_id=trace_id, span_id=span_id, name="chat gpt-4o", resources={"service.name": "tracedetail-thread-buckets"}, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
for span_id, timestamp in zip(span_ids, timestamps, strict=True)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
url = signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/thread")
headers = {"Authorization": f"Bearer {token}"}
def get_page(params: dict) -> dict:
response = requests.get(url, params=params, headers=headers, timeout=10)
assert response.status_code == HTTPStatus.OK, f"{params}: {response.text}"
return response.json()["data"]
page = get_page({"limit": 1})
forward = [span["span_id"] for span in page["spans"]]
while "nextCursor" in page:
page = get_page({"limit": 1, "after": page["nextCursor"]})
forward += [span["span_id"] for span in page["spans"]]
assert forward == span_ids
backward = [span["span_id"] for span in page["spans"]]
while "prevCursor" in page:
page = get_page({"limit": 1, "before": page["prevCursor"]})
backward = [span["span_id"] for span in page["spans"]] + backward
assert backward == span_ids
for index in range(1, len(span_ids)):
around = get_page({"limit": 2, "spanId": span_ids[index]})
assert [span["span_id"] for span in around["spans"]] == span_ids[index - 1 : index + 1], index
def test_thread_opens_around_span(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
seed_attribute_evolution: Callable[[str, datetime], None],
) -> None:
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
now = datetime.now(tz=UTC).replace(microsecond=0)
trace_id = TraceIdGenerator.trace_id()
resources = {"service.name": "tracedetail-thread-anchor"}
root_id = TraceIdGenerator.span_id()
llm_ids = [TraceIdGenerator.span_id() for _ in range(5)]
tool_id = TraceIdGenerator.span_id()
# tool span sits between the third and fourth llm spans
insert_traces(
[
Traces(timestamp=now - timedelta(seconds=20), duration=timedelta(seconds=19), trace_id=trace_id, span_id=root_id, name="POST /chat", kind=TracesKind.SPAN_KIND_SERVER, resources=resources, attribute_write_mode="json_only"),
*(
Traces(timestamp=now - timedelta(seconds=18 - 3 * i), trace_id=trace_id, span_id=span_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
for i, span_id in enumerate(llm_ids)
),
Traces(timestamp=now - timedelta(seconds=11), trace_id=trace_id, span_id=tool_id, parent_span_id=root_id, name="execute_tool get_weather", resources=resources, attributes={"gen_ai.tool.name": "get_weather"}, attribute_write_mode="json_only"),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
url = signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/thread")
headers = {"Authorization": f"Bearer {token}"}
def get_page(params: dict) -> dict:
response = requests.get(url, params=params, headers=headers, timeout=10)
assert response.status_code == HTTPStatus.OK, f"{params}: {response.text}"
return response.json()["data"]
# span with messages: included with its neighbours
around = get_page({"limit": 3, "spanId": llm_ids[2]})
assert [span["span_id"] for span in around["spans"]] == llm_ids[1:4]
assert around["prevCursor"]
assert around["nextCursor"]
assert [span["span_id"] for span in get_page({"limit": 3, "before": around["prevCursor"]})["spans"]] == llm_ids[:1]
assert [span["span_id"] for span in get_page({"limit": 3, "after": around["nextCursor"]})["spans"]] == llm_ids[4:]
# span without messages: only its neighbours
around_tool = get_page({"limit": 2, "spanId": tool_id})
assert [span["span_id"] for span in around_tool["spans"]] == llm_ids[2:4]
assert around_tool["prevCursor"]
assert around_tool["nextCursor"]
# near the start, the short side gives its room to the other
at_start = get_page({"limit": 3, "spanId": llm_ids[0]})
assert [span["span_id"] for span in at_start["spans"]] == llm_ids[:3]
assert "prevCursor" not in at_start
assert at_start["nextCursor"]
# near the end
at_end = get_page({"limit": 3, "spanId": llm_ids[4]})
assert [span["span_id"] for span in at_end["spans"]] == llm_ids[2:]
assert at_end["prevCursor"]
assert "nextCursor" not in at_end
# page covers the whole thread
whole = get_page({"limit": 10, "spanId": root_id})
assert [span["span_id"] for span in whole["spans"]] == llm_ids
assert "prevCursor" not in whole
assert "nextCursor" not in whole
missing = requests.get(url, params={"spanId": TraceIdGenerator.span_id()}, headers=headers, timeout=10)
assert missing.status_code == HTTPStatus.NOT_FOUND, missing.text
def test_thread_reads_spans_across_json_rollout(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
seed_attribute_evolution: Callable[[str, datetime], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
rollout = now - timedelta(minutes=30)
seed_attribute_evolution("traces", rollout)
resources = {"service.name": "tracedetail-thread-rollout"}
# trace entirely before the rollout: messages live only in the legacy maps
before_trace_id = TraceIdGenerator.trace_id()
before_ids = [TraceIdGenerator.span_id() for _ in range(2)]
# trace straddling the rollout: one span in the maps, one in the JSON column
straddle_trace_id = TraceIdGenerator.trace_id()
legacy_id, json_id = TraceIdGenerator.span_id(), TraceIdGenerator.span_id()
insert_traces(
[
Traces(timestamp=rollout - timedelta(minutes=10), trace_id=before_trace_id, span_id=before_ids[0], name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": "first"}])}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout - timedelta(minutes=8), trace_id=before_trace_id, span_id=TraceIdGenerator.span_id(), name="execute_tool get_weather", resources=resources, attributes={"gen_ai.tool.name": "get_weather"}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout - timedelta(minutes=5), trace_id=before_trace_id, span_id=before_ids[1], name="chat gpt-4o", resources=resources, attributes={"gen_ai.output.messages": json.dumps([{"role": "assistant", "content": "second"}])}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout - timedelta(minutes=5), trace_id=straddle_trace_id, span_id=legacy_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": "legacy"}])}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout + timedelta(minutes=5), trace_id=straddle_trace_id, span_id=json_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": "json"}])}, attribute_write_mode="json_only"),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
before = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{before_trace_id}/thread"), headers=headers, timeout=10)
assert before.status_code == HTTPStatus.OK, before.text
before_spans = before.json()["data"]["spans"]
assert [span["span_id"] for span in before_spans] == before_ids
assert before_spans[0]["attributes"]["gen_ai.input.messages"] == json.dumps([{"role": "user", "content": "first"}])
assert before_spans[1]["attributes"]["gen_ai.output.messages"] == json.dumps([{"role": "assistant", "content": "second"}])
straddle = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{straddle_trace_id}/thread"), headers=headers, timeout=10)
assert straddle.status_code == HTTPStatus.OK, straddle.text
straddle_spans = straddle.json()["data"]["spans"]
assert [span["span_id"] for span in straddle_spans] == [legacy_id, json_id]
assert straddle_spans[0]["attributes"]["gen_ai.input.messages"] == json.dumps([{"role": "user", "content": "legacy"}])
assert straddle_spans[1]["attributes"]["gen_ai.input.messages"] == json.dumps([{"role": "user", "content": "json"}])
def test_thread_without_messages_is_empty(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
seed_attribute_evolution: Callable[[str, datetime], None],
) -> None:
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
trace_id = TraceIdGenerator.trace_id()
insert_traces([Traces(timestamp=datetime.now(tz=UTC) - timedelta(seconds=5), trace_id=trace_id, span_id=TraceIdGenerator.span_id(), name="GET /health", resources={"service.name": "tracedetail-thread-empty"}, attribute_write_mode="json_only")])
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/thread"), headers={"Authorization": f"Bearer {token}"}, timeout=10)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"] == {"spans": []}
def test_thread_rejects_invalid_requests(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
url = signoz.self.host_configs["8080"].get(f"/api/v1/traces/{TraceIdGenerator.trace_id()}/thread")
cursor = base64.urlsafe_b64encode(json.dumps({"timeUnixNano": 1, "spanId": "f1fa1bc863e94dd0"}).encode()).decode().rstrip("=")
for params in (
{"limit": -1},
{"limit": 101},
{"after": "not-a-cursor"},
{"before": "not-a-cursor"},
{"after": cursor, "before": cursor},
{"after": cursor, "spanId": "f1fa1bc863e94dd0"},
{"before": cursor, "spanId": "f1fa1bc863e94dd0"},
):
response = requests.get(url, params=params, headers=headers, timeout=10)
assert response.status_code == HTTPStatus.BAD_REQUEST, f"{params}: {response.text}"
missing = requests.get(url, headers=headers, timeout=10)
assert missing.status_code == HTTPStatus.NOT_FOUND, missing.text

View File

@@ -1,30 +0,0 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.signoz import create_signoz
@pytest.fixture(name="signoz", scope="package")
def signoz_trace_attributes_json(
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz-trace-attributes-json",
env_overrides={
"SIGNOZ_FLAGGER_CONFIG_BOOLEAN_USE__TRACE__ATTRIBUTES__JSON": True,
},
)