mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-15 14:40:41 +01:00
Compare commits
6 Commits
main
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c2adc0824 | ||
|
|
82074b5ccd | ||
|
|
c33577dfd9 | ||
|
|
dc01e3a4a1 | ||
|
|
56e849ba53 | ||
|
|
a65e34d799 |
@@ -10517,11 +10517,13 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesEvent'
|
||||
type: array
|
||||
formattedInput:
|
||||
formatted_input:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesInputMessages'
|
||||
formattedOutput:
|
||||
formatted_output:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesOutputMessages'
|
||||
formatterWarnings:
|
||||
formatter:
|
||||
type: string
|
||||
formatter_warnings:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
@@ -10567,9 +10569,10 @@ components:
|
||||
- attributes
|
||||
- events
|
||||
- references
|
||||
- formattedInput
|
||||
- formattedOutput
|
||||
- formatterWarnings
|
||||
- formatted_input
|
||||
- formatted_output
|
||||
- formatter
|
||||
- formatter_warnings
|
||||
type: object
|
||||
SpantypesTraceAISummary:
|
||||
properties:
|
||||
@@ -10650,14 +10653,6 @@ components:
|
||||
flags:
|
||||
minimum: 0
|
||||
type: integer
|
||||
formattedInput:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesInputMessages'
|
||||
formattedOutput:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesOutputMessages'
|
||||
formatterWarnings:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
has_children:
|
||||
type: boolean
|
||||
has_error:
|
||||
@@ -16445,12 +16440,12 @@ paths:
|
||||
/api/v1/traces/{traceID}/thread:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the gen_ai spans of a trace, model calls, tool calls and
|
||||
agent spans, in timestamp order. The messages a span carries are decoded into
|
||||
formattedInput and formattedOutput, with formatterWarnings holding what the
|
||||
converter could not resolve; a span without messages has both empty. Pass
|
||||
nextCursor as after or prevCursor as before to page, or spanId to open the
|
||||
page around a span.
|
||||
description: Returns the spans carrying gen_ai input or output messages, or
|
||||
a tool execution, in timestamp order. Messages already in the OTel GenAI shape
|
||||
are decoded into formatted_input and formatted_output, with formatter naming
|
||||
the converter and formatter_warnings what it could not resolve. 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.
|
||||
@@ -28594,9 +28589,7 @@ paths:
|
||||
deprecated: false
|
||||
description: Returns the waterfall view of spans including all spans if total
|
||||
spans are under a limit, a max count otherwise. Aggregations are dropped compared
|
||||
to v3. A span carrying gen_ai input or output messages also has them decoded
|
||||
into formattedInput and formattedOutput, with formatterWarnings holding what
|
||||
the converter could not resolve.
|
||||
to v3
|
||||
operationId: GetWaterfallV4
|
||||
parameters:
|
||||
- in: path
|
||||
|
||||
@@ -20,7 +20,6 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
|
||||
- [Provider](provider.md) - Dependency injection and provider patterns
|
||||
- [Packages](packages.md) - Naming, layout, and conventions for `pkg/` packages
|
||||
- [Service](service.md) - Managed service lifecycle with `factory.Service`
|
||||
- [Span Mapper](spanmapper.md) - Provenance of the shipped span mapper definitions and what is left unmapped
|
||||
- [SQL](sql.md) - Database and SQL patterns
|
||||
- [DSL Filtering to SQL](dslfilteringtosql.md) - Compiling the list filter DSL to relational-store WHERE clauses
|
||||
- [Types](types.md) - Domain types, request/response bodies, and storage rows in `pkg/types/`
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# Span Mapper
|
||||
|
||||
Where each source key in the shipped definitions under `pkg/modules/spanmapper/implspanmapper/fs/definitions/` comes from. A group runs on spans with an attribute key containing one of its condition substrings; sources are tried by priority and the first present wins; an existing target is never overwritten. Bump `version` on every change.
|
||||
|
||||
## gen_ai.llm
|
||||
|
||||
Condition: `model`, `llm.input_messages` (OpenInference sets `llm.model_name` from the response, so a failed call has no model key).
|
||||
|
||||
| Source | Emitted by |
|
||||
|---|---|
|
||||
| `llm.model_name`, `llm.provider`, `llm.system`, `llm.token_count.prompt`, `llm.token_count.completion`, `llm.token_count.prompt_details.cache_read`, `llm.token_count.prompt_details.cache_write`, `llm.token_count.completion_details.reasoning`, `llm.finish_reason`, `input.value`, `output.value`, `embedding.model_name`, `reranker.model_name`, `openinference.span.kind` | OpenInference |
|
||||
| `llm.request.model`, `llm.response.model`, `llm.vendor`, `llm.usage.prompt_tokens`, `llm.usage.completion_tokens`, `llm.response.finish_reason`, `llm.response.stop_reason` | OpenLLMetry before the `gen_ai` rename |
|
||||
| `gen_ai.system`, `gen_ai.usage.prompt_tokens`, `gen_ai.usage.completion_tokens`, `gen_ai.usage.cache_read_input_tokens`, `gen_ai.usage.cache_creation_input_tokens`, `gen_ai.usage.reasoning_tokens`, `gen_ai.prompt`, `gen_ai.completion`, `llm.request.type`, `traceloop.span.kind` | OpenLLMetry, LiteLLM, CrewAI, Mistral, LangSmith, OpenRouter gateway |
|
||||
| `gen_ai.usage.cache_write.input_tokens` | OTel semconv draft spelling, Codex |
|
||||
| `gen_ai.response.finish_reason`, `gen_ai.usage.prompt_token_details.cached_read_tokens`, `gen_ai.usage.completion_token_details.reasoning_tokens` | Bifrost gateway |
|
||||
| `gen_ai.usage.input_tokens.cached` | OpenRouter gateway |
|
||||
| `gen_ai.response.model`, last source for the request model | vLLM, which writes only the response model |
|
||||
| `ai.model.id`, `ai.model.provider`, `ai.response.model`, `ai.response.finishReason`, `ai.prompt.messages`, `ai.response.text`, `ai.response.toolCalls`, `ai.usage.promptTokens`, `ai.usage.completionTokens` | Vercel AI SDK v4, Mastra |
|
||||
| `ai.usage.inputTokens`, `ai.usage.outputTokens`, `ai.usage.cachedInputTokens`, `ai.usage.reasoningTokens` | Vercel AI SDK v5 |
|
||||
| `langfuse.observation.model.name`, `langfuse.session.id` | Langfuse SDK v3 |
|
||||
| `session.id` | OpenInference, Langfuse, Claude Code |
|
||||
| `gen_ai.client.session_id` | opentelemetry-hooks runner (Cursor, Copilot, Claude Code hooks) |
|
||||
| `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens` | Claude Code `claude_code.llm_request` spans ([docs](https://code.claude.com/docs/en/monitoring-usage#span-attributes)) |
|
||||
|
||||
## gen_ai.tool
|
||||
|
||||
Condition: `tool.name` (OpenInference), `ai.toolCall.` (Vercel, Mastra), `tool_call_args`, `tool_response` (Google ADK), `tool_use_id` (Claude Code, opentelemetry-hooks). Not `tool_name`: Codex records it on the model call span.
|
||||
|
||||
| Source | Emitted by |
|
||||
|---|---|
|
||||
| `tool.name`, `tool.id`, `tool.description`, `input.value`, `output.value`, `openinference.span.kind` | OpenInference |
|
||||
| `ai.toolCall.name`, `ai.toolCall.id`, `ai.toolCall.args`, `ai.toolCall.result` | Vercel AI SDK, Mastra |
|
||||
| `traceloop.entity.input`, `traceloop.entity.output`, `traceloop.span.kind`, `llm.request.type` | OpenLLMetry |
|
||||
| `gcp.vertex.agent.tool_call_args`, `gcp.vertex.agent.tool_response` | Google ADK |
|
||||
| `tool_name` | Claude Code `claude_code.tool` spans |
|
||||
| `gen_ai.client.tool_name`, `gen_ai.client.tool_use_id` | opentelemetry-hooks runner (Cursor, Copilot, Claude Code hooks) |
|
||||
|
||||
## gen_ai.agent
|
||||
|
||||
Condition: `agent`.
|
||||
|
||||
| Source | Emitted by |
|
||||
|---|---|
|
||||
| `agent.name`, `agent.id`, `agent.description`, `openinference.span.kind` | OpenInference; `agent.id` and `agent.description` seen in no capture |
|
||||
| `agent_name`, `final_result` | Pydantic AI |
|
||||
| `crewai.agent.role`, `crewai.agent.id` | CrewAI |
|
||||
| `traceloop.span.kind`, `llm.request.type` | OpenLLMetry |
|
||||
| `agent_id` | Claude Code |
|
||||
|
||||
## Coverage of documented integrations
|
||||
|
||||
Every integration under [signoz.io/docs/llm-observability](https://signoz.io/docs/llm-observability/) was checked by looking at the instrumentation its page installs and the keys that instrumentation writes.
|
||||
|
||||
- Most pages install OpenInference or OpenLLMetry, or an SDK that writes OTel GenAI semconv itself (OpenAI, OpenAI Agents, Pydantic AI, AutoGen, Microsoft Agent Framework, Semantic Kernel, Copilot Chat, LiteLLM, OpenRouter). Their keys are the rows above.
|
||||
- Three write their own keys and have their own rows: Claude Code and Claude Agent SDK, Cursor through opentelemetry-hooks, vLLM (response model only).
|
||||
- Codex cannot be priced by renaming: `model` sits on the sampling function spans, `gen_ai.usage.*` on the child `handle_responses` span, `conversation.id` on the `codex.sampling` phase span, never together on one span.
|
||||
|
||||
To check a new integration, capture a trace and look for a model, usage, tool or messages key that no row above lists.
|
||||
|
||||
## Left unmapped on purpose
|
||||
|
||||
LiteLLM `llm.openai.messages` and `llm.openai.choices` (debug echo of the parent span's messages, Python repr). Total tokens and vendor cost keys (no target; the pricing processor owns cost). `ai.response.msToFirstChunk` and `gen_ai.latency.time_to_first_token` (milliseconds and seconds under one target).
|
||||
@@ -41,12 +41,6 @@ These hold for every page. The per-pattern sections below only add to them.
|
||||
`create` on API Keys, so blocking the outer container would hide work they are allowed to do.
|
||||
7. **Verbs not covered here** (`attach`, `detach`, `assignee`) behave like `delete`: gate the control that triggers
|
||||
them, not the surrounding content.
|
||||
8. **Expose the selector.** A role is scoped to a resource by pasting its selector into the role editor, so every
|
||||
resource a role can be scoped to shows that selector with a copy control on its detail view or drawer. The selector
|
||||
is whatever the editor accepts for the resource (dashboard ID, service account ID, role name, ...), listed per
|
||||
resource in [permissions.config.ts](../src/container/RolesSettings/permissions.config.ts). It is already known from
|
||||
the row or URL, so it stays visible and copyable without `read`. Reference: the ID field in
|
||||
[ServiceAccountDrawer/OverviewTab.tsx](../src/components/ServiceAccountDrawer/OverviewTab.tsx).
|
||||
|
||||
## Page patterns
|
||||
|
||||
@@ -142,8 +136,6 @@ Use the existing components. Create a new one only if none fit.
|
||||
- [ ] Am I gating a shared component rather than a page or section?
|
||||
- If so, it must stay functional with no permission. Example: the Query Builder works without field suggestions when
|
||||
the user cannot read them.
|
||||
- [ ] Does the page expose the resource's selector with a copy control (rule 8)?
|
||||
- The expected format is the `selectorPlaceholder` for the resource in `permissions.config.ts`.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -11789,12 +11789,16 @@ export interface SpantypesThreadSpanDTO {
|
||||
* @type array
|
||||
*/
|
||||
events: SpantypesEventDTO[];
|
||||
formattedInput: AiobservabilitytypesInputMessagesDTO;
|
||||
formattedOutput: AiobservabilitytypesOutputMessagesDTO;
|
||||
formatted_input: AiobservabilitytypesInputMessagesDTO;
|
||||
formatted_output: AiobservabilitytypesOutputMessagesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
formatter: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
formatterWarnings: string[];
|
||||
formatter_warnings: string[];
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -11910,12 +11914,6 @@ export interface SpantypesWaterfallSpanDTO {
|
||||
* @minimum 0
|
||||
*/
|
||||
flags?: number;
|
||||
formattedInput?: AiobservabilitytypesInputMessagesDTO;
|
||||
formattedOutput?: AiobservabilitytypesOutputMessagesDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
formatterWarnings?: string[];
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
|
||||
@@ -261,7 +261,7 @@ export const invalidateGetTraceSummary = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the gen_ai spans of a trace, model calls, tool calls and agent spans, in timestamp order. The messages a span carries are decoded into formattedInput and formattedOutput, with formatterWarnings holding what the converter could not resolve; a span without messages has both empty. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
|
||||
* Returns the spans carrying gen_ai input or output messages, or a tool execution, in timestamp order. Messages already in the OTel GenAI shape are decoded into formatted_input and formatted_output, with formatter naming the converter and formatter_warnings what it could not resolve. 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 = (
|
||||
@@ -475,7 +475,7 @@ export const useGetFlamegraph = <
|
||||
return useMutation(getGetFlamegraphMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3. A span carrying gen_ai input or output messages also has them decoded into formattedInput and formattedOutput, with formatterWarnings holding what the converter could not resolve.
|
||||
* Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3
|
||||
* @summary Get waterfall view for a trace
|
||||
*/
|
||||
export const getWaterfallV4 = (
|
||||
|
||||
@@ -10,13 +10,6 @@ import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
|
||||
import { useQueryBuilderV2Context } from '../../QueryBuilderV2Context';
|
||||
import {
|
||||
QueryBuilderField,
|
||||
type QueryBuilderFieldsConfig,
|
||||
} from '../../queryBuilderFields.types';
|
||||
import { resolveQueryBuilderField } from '../../queryBuilderFields.utils';
|
||||
|
||||
import { BUILT_IN_PLACEHOLDERS } from './constants';
|
||||
|
||||
import './MetricsAggregateSection.styles.scss';
|
||||
|
||||
@@ -25,18 +18,13 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
index,
|
||||
version,
|
||||
panelType,
|
||||
fieldsConfig,
|
||||
}: {
|
||||
query: IBuilderQuery;
|
||||
index: number;
|
||||
version: string;
|
||||
panelType: PANEL_TYPES | null;
|
||||
fieldsConfig?: QueryBuilderFieldsConfig;
|
||||
}): JSX.Element {
|
||||
const { setAggregationOptions } = useQueryBuilderV2Context();
|
||||
const placeholderOf = (key: keyof typeof BUILT_IN_PLACEHOLDERS): string =>
|
||||
resolveQueryBuilderField(key, fieldsConfig).placeholder ??
|
||||
BUILT_IN_PLACEHOLDERS[key];
|
||||
const {
|
||||
operators,
|
||||
spaceAggregationOptions,
|
||||
@@ -164,7 +152,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
<InputWithLabel
|
||||
onChange={handleChangeAggregateEvery}
|
||||
label="Seconds"
|
||||
placeholder={placeholderOf(QueryBuilderField.StepInterval)}
|
||||
placeholder="Auto"
|
||||
labelAfter
|
||||
initialValue={query?.stepInterval ?? null}
|
||||
/>
|
||||
@@ -216,7 +204,6 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
disabled={!queryAggregation.metricName}
|
||||
query={query}
|
||||
onChange={handleChangeGroupByKeys}
|
||||
placeholder={placeholderOf(QueryBuilderField.GroupBy)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,7 +240,6 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
disabled={!queryAggregation.metricName}
|
||||
query={query}
|
||||
onChange={handleChangeGroupByKeys}
|
||||
placeholder={placeholderOf(QueryBuilderField.GroupBy)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,7 +273,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
<InputWithLabel
|
||||
onChange={handleChangeAggregateEvery}
|
||||
label="Seconds"
|
||||
placeholder={placeholderOf(QueryBuilderField.StepInterval)}
|
||||
placeholder="Auto"
|
||||
labelAfter
|
||||
initialValue={query?.stepInterval ?? null}
|
||||
className="histogram-every-input"
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { QueryBuilderField } from '../../queryBuilderFields.types';
|
||||
import { STEP_INTERVAL_PLACEHOLDER } from '../../queryBuilderFields.utils';
|
||||
import { ADD_ON_DEFINITIONS } from '../QueryAddOns/constants';
|
||||
|
||||
export const BUILT_IN_PLACEHOLDERS = {
|
||||
[QueryBuilderField.StepInterval]: STEP_INTERVAL_PLACEHOLDER,
|
||||
[QueryBuilderField.GroupBy]:
|
||||
ADD_ON_DEFINITIONS[QueryBuilderField.GroupBy].placeholder,
|
||||
} as const;
|
||||
@@ -88,12 +88,10 @@ function HavingFilter({
|
||||
onClose,
|
||||
onChange,
|
||||
queryData,
|
||||
placeholder = 'Type Having query like count() > 10 ...',
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onChange: (value: string) => void;
|
||||
queryData: IBuilderQuery;
|
||||
placeholder?: string;
|
||||
}): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { getAggregationOptions } = useQueryBuilderV2Context();
|
||||
@@ -347,7 +345,7 @@ function HavingFilter({
|
||||
},
|
||||
]),
|
||||
]}
|
||||
placeholder={placeholder}
|
||||
placeholder="Type Having query like count() > 10 ..."
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
autocompletion: true,
|
||||
|
||||
@@ -343,29 +343,8 @@
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
// Not select search inputs: they would grow the select past 36px.
|
||||
.input-with-label input {
|
||||
input {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.input-with-label,
|
||||
.periscope-input-with-label {
|
||||
min-height: 36px;
|
||||
|
||||
.label {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
// `ghost` strips the border.
|
||||
.close-btn.periscope-btn {
|
||||
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
|
||||
background: var(--input-with-label-background-color, var(--l2-background));
|
||||
}
|
||||
}
|
||||
|
||||
.periscope-input-with-label .input .ant-select-selection-placeholder {
|
||||
color: var(--query-builder-v2-placeholder-color, var(--l3-foreground));
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
|
||||
import { OrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter';
|
||||
import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { get, isEmpty } from 'lodash-es';
|
||||
import { ChevronUp } from '@signozhq/icons';
|
||||
import {
|
||||
BarChart,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
Grid3X3,
|
||||
ScrollText,
|
||||
} from '@signozhq/icons';
|
||||
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
@@ -29,20 +34,162 @@ import {
|
||||
|
||||
import BucketOptions from './BucketOptions/BucketOptions';
|
||||
import HavingFilter from './HavingFilter/HavingFilter';
|
||||
import {
|
||||
ADD_ON_DEFINITIONS,
|
||||
BASE_ADD_ONS,
|
||||
BUCKET_OPTIONS,
|
||||
HISTOGRAM_ATTRIBUTE_TYPES,
|
||||
type InputAddOnField,
|
||||
REDUCE_TO,
|
||||
} from './constants';
|
||||
import TooltipContent from './TooltipContent';
|
||||
import type { AddOn } from './types';
|
||||
import { buildDefaultLegendFromGroupBy, hasValue } from './utils';
|
||||
import { buildDefaultLegendFromGroupBy } from './utils';
|
||||
|
||||
import './QueryAddOns.styles.scss';
|
||||
|
||||
interface AddOn {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
key: QueryBuilderField;
|
||||
description?: string;
|
||||
docLink?: string;
|
||||
}
|
||||
|
||||
/** Fields the add-on bar does not own: each has its own control elsewhere in the query. */
|
||||
type NonAddOnField =
|
||||
| QueryBuilderField.Aggregation
|
||||
| QueryBuilderField.StepInterval
|
||||
| QueryBuilderField.Functions
|
||||
| QueryBuilderField.Formula
|
||||
| QueryBuilderField.AdditionalQueries;
|
||||
|
||||
// Omit rather than Partial, so a field added to the enum has to be placed on one side.
|
||||
const ADD_ONS_KEYS_TO_QUERY_PATH: Omit<
|
||||
Record<QueryBuilderField, string>,
|
||||
NonAddOnField
|
||||
> = {
|
||||
[QueryBuilderField.GroupBy]: 'groupBy',
|
||||
[QueryBuilderField.Having]: 'having.expression',
|
||||
[QueryBuilderField.OrderBy]: 'orderBy',
|
||||
[QueryBuilderField.Limit]: 'limit',
|
||||
[QueryBuilderField.Legend]: 'legend',
|
||||
[QueryBuilderField.ReduceTo]: 'reduceTo',
|
||||
[QueryBuilderField.BucketOptions]: 'bucketOptions',
|
||||
};
|
||||
|
||||
const ADD_ONS: AddOn[] = [
|
||||
{
|
||||
icon: <BarChart size={14} />,
|
||||
label: 'Group By',
|
||||
key: QueryBuilderField.GroupBy,
|
||||
description:
|
||||
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
|
||||
docLink: 'https://signoz.io/docs/querying/aggregation-grouping/#grouping',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Having',
|
||||
key: QueryBuilderField.Having,
|
||||
description:
|
||||
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Order By',
|
||||
key: QueryBuilderField.OrderBy,
|
||||
description:
|
||||
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/result-manipulation/#sorting--limiting',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Limit',
|
||||
key: QueryBuilderField.Limit,
|
||||
description:
|
||||
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/result-manipulation/#how-limit-works-for-time-series',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Legend format',
|
||||
key: QueryBuilderField.Legend,
|
||||
description:
|
||||
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/aggregation-grouping/#legend-formatting',
|
||||
},
|
||||
];
|
||||
|
||||
const REDUCE_TO: AddOn = {
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Reduce to',
|
||||
key: QueryBuilderField.ReduceTo,
|
||||
description:
|
||||
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
|
||||
};
|
||||
|
||||
// Offered only by a heatmap over metrics: the bucket axis is what a heatmap plots
|
||||
// against, and no other panel type sends one. A histogram brings its own buckets.
|
||||
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
|
||||
ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
]);
|
||||
|
||||
const BUCKET_OPTIONS: AddOn = {
|
||||
icon: <Grid3X3 size={14} />,
|
||||
label: 'Bucket by',
|
||||
key: QueryBuilderField.BucketOptions,
|
||||
description:
|
||||
'Choose how the bucket axis is spaced — logarithmically, so every band is the same height and the tail stays readable, or linearly up to a max value. Left to Auto, the query picks the finest log axis it can return.',
|
||||
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/',
|
||||
};
|
||||
|
||||
const hasValue = (value: unknown): boolean =>
|
||||
value != null && value !== '' && !(Array.isArray(value) && value.length === 0);
|
||||
|
||||
// Custom tooltip content component
|
||||
function TooltipContent({
|
||||
label,
|
||||
description,
|
||||
docLink,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
docLink?: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
maxWidth: '300px',
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: '14px' }}>{label}</strong>
|
||||
{description && (
|
||||
<span style={{ fontSize: '12px', lineHeight: '1.5' }}>{description}</span>
|
||||
)}
|
||||
{docLink && (
|
||||
<a
|
||||
href={docLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
color: '#4096ff',
|
||||
fontSize: '12px',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueryAddOns({
|
||||
query,
|
||||
version,
|
||||
@@ -80,16 +227,12 @@ function QueryAddOns({
|
||||
let addOns: AddOn[];
|
||||
|
||||
if (panelType === PANEL_TYPES.VALUE) {
|
||||
addOns = BASE_ADD_ONS.filter(
|
||||
(addOn) => addOn.key === QueryBuilderField.Legend,
|
||||
);
|
||||
addOns = ADD_ONS.filter((addOn) => addOn.key === QueryBuilderField.Legend);
|
||||
} else if (query.dataSource === DataSource.METRICS) {
|
||||
// Group by for metrics is offered by MetricsAggregateSection instead.
|
||||
addOns = BASE_ADD_ONS.filter(
|
||||
(addOn) => addOn.key !== QueryBuilderField.GroupBy,
|
||||
);
|
||||
addOns = ADD_ONS.filter((addOn) => addOn.key !== QueryBuilderField.GroupBy);
|
||||
} else {
|
||||
addOns = [...BASE_ADD_ONS];
|
||||
addOns = [...ADD_ONS];
|
||||
}
|
||||
|
||||
if (showReduceTo) {
|
||||
@@ -145,12 +288,6 @@ function QueryAddOns({
|
||||
[resolvedFields],
|
||||
);
|
||||
|
||||
const placeholderOf = useCallback(
|
||||
(key: InputAddOnField): string =>
|
||||
resolvedFields.get(key)?.placeholder ?? ADD_ON_DEFINITIONS[key].placeholder,
|
||||
[resolvedFields],
|
||||
);
|
||||
|
||||
const isDisabled = useCallback(
|
||||
(key: QueryBuilderField): boolean =>
|
||||
Boolean(resolvedFields.get(key)?.disabled),
|
||||
@@ -169,6 +306,12 @@ function QueryAddOns({
|
||||
if (!initializedRef.current || hasAvailabilityItemsChanged) {
|
||||
initializedRef.current = true;
|
||||
|
||||
const activeAddOnKeys = new Set(
|
||||
Object.entries(ADD_ONS_KEYS_TO_QUERY_PATH)
|
||||
.filter(([, path]) => hasValue(get(query, path)))
|
||||
.map(([key]) => key as QueryBuilderField),
|
||||
);
|
||||
|
||||
// Initial seeding from query values on mount. A disabled field never opens.
|
||||
setSelectedViews(
|
||||
offeredAddOns.filter((addOn) => {
|
||||
@@ -176,7 +319,7 @@ function QueryAddOns({
|
||||
|
||||
return (
|
||||
resolved?.pinned ||
|
||||
(hasValue(get(query, addOn.queryPath)) && !resolved?.disabled)
|
||||
(activeAddOnKeys.has(addOn.key) && !resolved?.disabled)
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -305,7 +448,7 @@ function QueryAddOns({
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.GroupBy) && (
|
||||
<div className="add-on-content" data-testid="group-by-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<TooltipSimple
|
||||
<Tooltip
|
||||
title={
|
||||
<TooltipContent
|
||||
label="Group By"
|
||||
@@ -313,13 +456,13 @@ function QueryAddOns({
|
||||
docLink="https://signoz.io/docs/querying/aggregation-grouping/#grouping"
|
||||
/>
|
||||
}
|
||||
arrow
|
||||
delayDuration={500}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<div className="label" style={{ cursor: 'help' }}>
|
||||
Group By
|
||||
</div>
|
||||
</TooltipSimple>
|
||||
</Tooltip>
|
||||
<div className="input">
|
||||
<GroupByFilter
|
||||
disabled={
|
||||
@@ -328,7 +471,6 @@ function QueryAddOns({
|
||||
}
|
||||
query={query}
|
||||
onChange={handleChangeGroupByKeys}
|
||||
placeholder={placeholderOf(QueryBuilderField.GroupBy)}
|
||||
/>
|
||||
</div>
|
||||
{!isPinned(QueryBuilderField.GroupBy) && (
|
||||
@@ -344,7 +486,7 @@ function QueryAddOns({
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.Having) && (
|
||||
<div className="add-on-content" data-testid="having-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<TooltipSimple
|
||||
<Tooltip
|
||||
title={
|
||||
<TooltipContent
|
||||
label="Having"
|
||||
@@ -352,19 +494,18 @@ function QueryAddOns({
|
||||
docLink="https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having"
|
||||
/>
|
||||
}
|
||||
arrow
|
||||
delayDuration={500}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<div className="label" style={{ cursor: 'help' }}>
|
||||
Having
|
||||
</div>
|
||||
</TooltipSimple>
|
||||
</Tooltip>
|
||||
<div className="input">
|
||||
<HavingFilter
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Having)}
|
||||
onChange={handleChangeHaving}
|
||||
queryData={query}
|
||||
placeholder={placeholderOf(QueryBuilderField.Having)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -377,7 +518,7 @@ function QueryAddOns({
|
||||
type="number"
|
||||
onChange={handleChangeLimit}
|
||||
initialValue={query?.limit ?? undefined}
|
||||
placeholder={placeholderOf(QueryBuilderField.Limit)}
|
||||
placeholder="Enter limit"
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}
|
||||
closeIcon={<ChevronUp size={16} />}
|
||||
/>
|
||||
@@ -386,7 +527,7 @@ function QueryAddOns({
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.OrderBy) && (
|
||||
<div className="add-on-content" data-testid="order-by-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<TooltipSimple
|
||||
<Tooltip
|
||||
title={
|
||||
<TooltipContent
|
||||
label="Order By"
|
||||
@@ -394,13 +535,13 @@ function QueryAddOns({
|
||||
docLink="https://signoz.io/docs/querying/result-manipulation/#sorting--limiting"
|
||||
/>
|
||||
}
|
||||
arrow
|
||||
delayDuration={500}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<div className="label" style={{ cursor: 'help' }}>
|
||||
Order By
|
||||
</div>
|
||||
</TooltipSimple>
|
||||
</Tooltip>
|
||||
<div className="input">
|
||||
<OrderByFilter
|
||||
entityVersion={version}
|
||||
@@ -408,7 +549,6 @@ function QueryAddOns({
|
||||
onChange={handleChangeOrderByKeys}
|
||||
isRawQuery={isRawQuery}
|
||||
isNewQueryV2
|
||||
placeholder={placeholderOf(QueryBuilderField.OrderBy)}
|
||||
/>
|
||||
</div>
|
||||
{!isPinned(QueryBuilderField.OrderBy) && (
|
||||
@@ -426,7 +566,7 @@ function QueryAddOns({
|
||||
showReduceTo && (
|
||||
<div className="add-on-content" data-testid="reduce-to-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<TooltipSimple
|
||||
<Tooltip
|
||||
title={
|
||||
<TooltipContent
|
||||
label="Reduce to"
|
||||
@@ -434,13 +574,13 @@ function QueryAddOns({
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation"
|
||||
/>
|
||||
}
|
||||
arrow
|
||||
delayDuration={500}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<div className="label" style={{ cursor: 'help' }}>
|
||||
Reduce to
|
||||
</div>
|
||||
</TooltipSimple>
|
||||
</Tooltip>
|
||||
<div className="input">
|
||||
<ReduceToFilter query={query} onChange={handleChangeReduceToV5} />
|
||||
</div>
|
||||
@@ -460,7 +600,7 @@ function QueryAddOns({
|
||||
<div className="add-on-content" data-testid="legend-format-content">
|
||||
<InputWithLabel
|
||||
label="Legend format"
|
||||
placeholder={placeholderOf(QueryBuilderField.Legend)}
|
||||
placeholder="Write legend format"
|
||||
onChange={handleChangeQueryLegend}
|
||||
initialValue={isEmpty(query?.legend) ? undefined : query?.legend}
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Legend)}
|
||||
@@ -507,7 +647,7 @@ function QueryAddOns({
|
||||
return {
|
||||
value: addOn.key,
|
||||
label: (
|
||||
<TooltipSimple
|
||||
<Tooltip
|
||||
title={
|
||||
<TooltipContent
|
||||
label={addOn.label}
|
||||
@@ -515,8 +655,8 @@ function QueryAddOns({
|
||||
docLink={resolved?.disabled ? undefined : addOn.docLink}
|
||||
/>
|
||||
}
|
||||
arrow
|
||||
delayDuration={500}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<span
|
||||
className={cx('add-on-tab-title', {
|
||||
@@ -528,7 +668,7 @@ function QueryAddOns({
|
||||
{addOn.icon}
|
||||
{addOn.label}
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
</Tooltip>
|
||||
),
|
||||
};
|
||||
})}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-normal);
|
||||
}
|
||||
|
||||
.docLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
margin-top: var(--spacing-2);
|
||||
color: var(--accent-primary);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { ExternalLink } from '@signozhq/icons';
|
||||
|
||||
import styles from './TooltipContent.module.scss';
|
||||
|
||||
// Custom tooltip content component
|
||||
export default function TooltipContent({
|
||||
label,
|
||||
description,
|
||||
docLink,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
docLink?: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<strong className={styles.label}>{label}</strong>
|
||||
{description && <span className={styles.description}>{description}</span>}
|
||||
{docLink && (
|
||||
<a
|
||||
href={docLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
className={styles.docLink}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { BarChart, Grid3X3, ScrollText } from '@signozhq/icons';
|
||||
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import { QueryBuilderField } from '../../queryBuilderFields.types';
|
||||
|
||||
import type { AddOn, AddOnDefinition, AddOnField } from './types';
|
||||
|
||||
// A Record, so a field added to the enum has to be placed here or in `NonAddOnField`.
|
||||
export const ADD_ON_DEFINITIONS = {
|
||||
[QueryBuilderField.GroupBy]: {
|
||||
input: true,
|
||||
icon: <BarChart size={14} />,
|
||||
label: 'Group By',
|
||||
queryPath: 'groupBy',
|
||||
placeholder: 'Everything (no breakdown)',
|
||||
description:
|
||||
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
|
||||
docLink: 'https://signoz.io/docs/querying/aggregation-grouping/#grouping',
|
||||
},
|
||||
[QueryBuilderField.Having]: {
|
||||
input: true,
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Having',
|
||||
queryPath: 'having.expression',
|
||||
placeholder: 'Type Having query like count() > 10 ...',
|
||||
description:
|
||||
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having',
|
||||
},
|
||||
[QueryBuilderField.OrderBy]: {
|
||||
input: true,
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Order By',
|
||||
queryPath: 'orderBy',
|
||||
placeholder: 'Select fields to order by',
|
||||
description:
|
||||
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/result-manipulation/#sorting--limiting',
|
||||
},
|
||||
[QueryBuilderField.Limit]: {
|
||||
input: true,
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Limit',
|
||||
queryPath: 'limit',
|
||||
placeholder: 'Enter limit',
|
||||
description:
|
||||
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/result-manipulation/#how-limit-works-for-time-series',
|
||||
},
|
||||
[QueryBuilderField.Legend]: {
|
||||
input: true,
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Legend format',
|
||||
queryPath: 'legend',
|
||||
placeholder: 'Write legend format',
|
||||
description:
|
||||
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/aggregation-grouping/#legend-formatting',
|
||||
},
|
||||
[QueryBuilderField.ReduceTo]: {
|
||||
input: false,
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Reduce to',
|
||||
queryPath: 'reduceTo',
|
||||
description:
|
||||
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
|
||||
},
|
||||
[QueryBuilderField.BucketOptions]: {
|
||||
input: false,
|
||||
icon: <Grid3X3 size={14} />,
|
||||
label: 'Bucket by',
|
||||
queryPath: 'bucketOptions',
|
||||
description:
|
||||
'Choose how the bucket axis is spaced — logarithmically, so every band is the same height and the tail stays readable, or linearly up to a max value. Left to Auto, the query picks the finest log axis it can return.',
|
||||
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/',
|
||||
},
|
||||
} as const satisfies Record<AddOnField, AddOnDefinition>;
|
||||
|
||||
export type InputAddOnField = {
|
||||
[K in AddOnField]: (typeof ADD_ON_DEFINITIONS)[K] extends { input: true }
|
||||
? K
|
||||
: never;
|
||||
}[AddOnField];
|
||||
|
||||
const toAddOn = (key: AddOnField): AddOn => ({
|
||||
key,
|
||||
...ADD_ON_DEFINITIONS[key],
|
||||
});
|
||||
|
||||
export const BASE_ADD_ONS = (
|
||||
[
|
||||
QueryBuilderField.GroupBy,
|
||||
QueryBuilderField.Having,
|
||||
QueryBuilderField.OrderBy,
|
||||
QueryBuilderField.Limit,
|
||||
QueryBuilderField.Legend,
|
||||
] as const satisfies readonly AddOnField[]
|
||||
).map(toAddOn);
|
||||
|
||||
export const REDUCE_TO = toAddOn(QueryBuilderField.ReduceTo);
|
||||
|
||||
// Offered only by a heatmap over metrics: the bucket axis is what a heatmap plots
|
||||
// against, and no other panel type sends one. A histogram brings its own buckets.
|
||||
export const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
|
||||
ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
]);
|
||||
|
||||
export const BUCKET_OPTIONS = toAddOn(QueryBuilderField.BucketOptions);
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { QueryBuilderField } from '../../queryBuilderFields.types';
|
||||
|
||||
/** Fields the add-on bar does not own: each has its own control elsewhere in the query. */
|
||||
export type NonAddOnField =
|
||||
| QueryBuilderField.Aggregation
|
||||
| QueryBuilderField.StepInterval
|
||||
| QueryBuilderField.Functions
|
||||
| QueryBuilderField.Formula
|
||||
| QueryBuilderField.AdditionalQueries;
|
||||
|
||||
export type AddOnField = Exclude<QueryBuilderField, NonAddOnField>;
|
||||
|
||||
export interface AddOnSpec {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
/** Where the add-on's value lives on the query; a value there opens it on mount. */
|
||||
queryPath: string;
|
||||
description?: string;
|
||||
docLink?: string;
|
||||
}
|
||||
|
||||
/** A text field has to name what leaving it empty means. */
|
||||
export type AddOnDefinition =
|
||||
| (AddOnSpec & { input: true; placeholder: string })
|
||||
| (AddOnSpec & { input: false });
|
||||
|
||||
export type AddOn = AddOnDefinition & { key: AddOnField };
|
||||
@@ -14,6 +14,3 @@ export const buildDefaultLegendFromGroupBy = (
|
||||
|
||||
return segments.join(', ');
|
||||
};
|
||||
|
||||
export const hasValue = (value: unknown): boolean =>
|
||||
value != null && value !== '' && !(Array.isArray(value) && value.length === 0);
|
||||
|
||||
@@ -12,11 +12,7 @@ import {
|
||||
QueryBuilderField,
|
||||
QueryBuilderFieldsConfig,
|
||||
} from '../../queryBuilderFields.types';
|
||||
import {
|
||||
resolveQueryBuilderField,
|
||||
type ResolvedQueryBuilderField,
|
||||
STEP_INTERVAL_PLACEHOLDER,
|
||||
} from '../../queryBuilderFields.utils';
|
||||
import { resolveQueryBuilderField } from '../../queryBuilderFields.utils';
|
||||
|
||||
import QueryAggregationSelect from './QueryAggregationSelect';
|
||||
|
||||
@@ -37,9 +33,9 @@ function QueryAggregationOptions({
|
||||
queryData: IBuilderQuery | IBuilderTraceOperator;
|
||||
fieldsConfig?: QueryBuilderFieldsConfig;
|
||||
}): JSX.Element {
|
||||
const stepInterval = useMemo<ResolvedQueryBuilderField>(() => {
|
||||
const stepInterval = useMemo(() => {
|
||||
if (panelType === PANEL_TYPES.VALUE) {
|
||||
return { hidden: true, disabled: false, pinned: false };
|
||||
return { hidden: true, disabled: false, reason: undefined };
|
||||
}
|
||||
|
||||
const isNonMetricSource =
|
||||
@@ -49,7 +45,7 @@ function QueryAggregationOptions({
|
||||
isNonMetricSource &&
|
||||
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
|
||||
) {
|
||||
return { hidden: true, disabled: false, pinned: false };
|
||||
return { hidden: true, disabled: false, reason: undefined };
|
||||
}
|
||||
|
||||
return resolveQueryBuilderField(QueryBuilderField.StepInterval, fieldsConfig);
|
||||
@@ -109,7 +105,7 @@ function QueryAggregationOptions({
|
||||
initialValue={queryData?.stepInterval ? queryData?.stepInterval : null}
|
||||
className="query-aggregation-interval-input"
|
||||
label="Seconds"
|
||||
placeholder={stepInterval.placeholder ?? STEP_INTERVAL_PLACEHOLDER}
|
||||
placeholder="Auto"
|
||||
type="number"
|
||||
onChange={handleAggregationIntervalChange}
|
||||
disabled={stepInterval.disabled}
|
||||
|
||||
@@ -316,7 +316,6 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
index={index}
|
||||
key={`metrics-aggregate-section-${query.queryName}-${query.dataSource}-${signalSource}`}
|
||||
version="v4"
|
||||
fieldsConfig={fieldsConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from 'tests/test-utils';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import { QueryBuilderField } from '../queryBuilderFields.types';
|
||||
import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
|
||||
|
||||
// Mocks: only what is required for this component to render and for us to assert handler calls
|
||||
@@ -35,10 +34,9 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('container/QueryBuilder/filters/GroupByFilter/GroupByFilter', () => ({
|
||||
GroupByFilter: ({ onChange, placeholder }: any): JSX.Element => (
|
||||
GroupByFilter: ({ onChange }: any): JSX.Element => (
|
||||
<button
|
||||
data-testid="groupby"
|
||||
data-placeholder={placeholder}
|
||||
onClick={(): void => onChange(['service.name'])}
|
||||
>
|
||||
GroupByFilter
|
||||
@@ -47,10 +45,9 @@ jest.mock('container/QueryBuilder/filters/GroupByFilter/GroupByFilter', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('container/QueryBuilder/filters/OrderByFilter/OrderByFilter', () => ({
|
||||
OrderByFilter: ({ onChange, placeholder }: any): JSX.Element => (
|
||||
OrderByFilter: ({ onChange }: any): JSX.Element => (
|
||||
<button
|
||||
data-testid="orderby"
|
||||
data-placeholder={placeholder}
|
||||
onClick={(): void => onChange([{ columnName: 'duration', order: 'desc' }])}
|
||||
>
|
||||
OrderByFilter
|
||||
@@ -60,11 +57,10 @@ jest.mock('container/QueryBuilder/filters/OrderByFilter/OrderByFilter', () => ({
|
||||
|
||||
jest.mock('../QueryV2/QueryAddOns/HavingFilter/HavingFilter', () => ({
|
||||
__esModule: true,
|
||||
default: ({ onChange, onClose, placeholder }: any): JSX.Element => (
|
||||
default: ({ onChange, onClose }: any): JSX.Element => (
|
||||
<div>
|
||||
<button
|
||||
data-testid="having-change"
|
||||
data-placeholder={placeholder}
|
||||
onClick={(): void => onChange('p99 > 500')}
|
||||
>
|
||||
HavingFilter
|
||||
@@ -173,55 +169,6 @@ describe('QueryAddOns', () => {
|
||||
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[QueryBuilderField.Limit, 'input-Limit', 'placeholder'],
|
||||
[QueryBuilderField.Legend, 'input-Legend format', 'placeholder'],
|
||||
[QueryBuilderField.GroupBy, 'groupby', 'data-placeholder'],
|
||||
[QueryBuilderField.Having, 'having-change', 'data-placeholder'],
|
||||
[QueryBuilderField.OrderBy, 'orderby', 'data-placeholder'],
|
||||
])('shows the configured %s placeholder', (field, inputTestId, attribute) => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery()}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
fieldsConfig={{
|
||||
[field]: { state: 'pinned', placeholder: 'Default X' },
|
||||
}}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(inputTestId)).toHaveAttribute(
|
||||
attribute,
|
||||
'Default X',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the built-in placeholder without a rule', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery()}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('query-add-on-limit'));
|
||||
|
||||
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
|
||||
'placeholder',
|
||||
'Enter limit',
|
||||
);
|
||||
});
|
||||
|
||||
it('auto-opens Order By and Limit when present in query', () => {
|
||||
const query = baseQuery({
|
||||
orderBy: [{ columnName: 'duration', order: 'desc' }],
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { QueryBuilderField } from '../queryBuilderFields.types';
|
||||
import QueryAggregationOptions from '../QueryV2/QueryAggregation/QueryAggregation';
|
||||
|
||||
jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
const queryData = initialQueriesMap.traces.builder
|
||||
.queryData[0] as IBuilderQuery;
|
||||
|
||||
describe('QueryAggregationOptions step interval placeholder', () => {
|
||||
it('shows the configured placeholder', () => {
|
||||
render(
|
||||
<QueryAggregationOptions
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
onAggregationIntervalChange={jest.fn()}
|
||||
queryData={queryData}
|
||||
fieldsConfig={{
|
||||
[QueryBuilderField.StepInterval]: {
|
||||
state: 'pinned',
|
||||
placeholder: 'Default 60',
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByPlaceholderText('Default 60')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to Auto', () => {
|
||||
render(
|
||||
<QueryAggregationOptions
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
onAggregationIntervalChange={jest.fn()}
|
||||
queryData={queryData}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByPlaceholderText('Auto')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -57,14 +57,6 @@ describe('resolveQueryBuilderField', () => {
|
||||
expect(resolved.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('carries the placeholder on a pinned field', () => {
|
||||
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
|
||||
[QueryBuilderField.Limit]: { state: 'pinned', placeholder: 'Default 10' },
|
||||
});
|
||||
|
||||
expect(resolved.placeholder).toBe('Default 10');
|
||||
});
|
||||
|
||||
it('only ever resolves one state at a time', () => {
|
||||
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
|
||||
[QueryBuilderField.Limit]: { state: 'disabled', reason: 'why' },
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
QueryBuilderField,
|
||||
type SyncedQueryBuilderField,
|
||||
} from '../queryBuilderFields.types';
|
||||
import {
|
||||
snapshotQueries,
|
||||
syncQueryBuilderFields,
|
||||
} from '../syncQueryBuilderFields';
|
||||
|
||||
const key = (name: string): BaseAutocompleteData =>
|
||||
({ key: name }) as BaseAutocompleteData;
|
||||
|
||||
function query(
|
||||
queryName: string,
|
||||
{
|
||||
groupBy = [],
|
||||
limit = null,
|
||||
dataSource = 'traces',
|
||||
}: { groupBy?: string[]; limit?: number | null; dataSource?: string } = {},
|
||||
): IBuilderQuery {
|
||||
return {
|
||||
queryName,
|
||||
groupBy: groupBy.map(key),
|
||||
limit,
|
||||
dataSource,
|
||||
} as IBuilderQuery;
|
||||
}
|
||||
|
||||
const GROUP_BY: SyncedQueryBuilderField[] = [QueryBuilderField.GroupBy];
|
||||
|
||||
describe('syncQueryBuilderFields', () => {
|
||||
it('copies an edited field to the other queries', () => {
|
||||
const previous = snapshotQueries([query('A'), query('B')]);
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(
|
||||
[query('A', { groupBy: ['service.name'] }), query('B')],
|
||||
previous,
|
||||
GROUP_BY,
|
||||
),
|
||||
).toStrictEqual([
|
||||
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives a new query the existing values', () => {
|
||||
const previous = snapshotQueries([query('A', { groupBy: ['service.name'] })]);
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(
|
||||
[query('A', { groupBy: ['service.name'] }), query('B')],
|
||||
previous,
|
||||
GROUP_BY,
|
||||
),
|
||||
).toStrictEqual([
|
||||
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives a query switched to another signal the others' values, not its reset ones", () => {
|
||||
const previous = snapshotQueries([
|
||||
query('A', { groupBy: ['service.name'] }),
|
||||
query('B', { groupBy: ['service.name'], dataSource: 'metrics' }),
|
||||
]);
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(
|
||||
[query('A', { groupBy: ['service.name'] }), query('B')],
|
||||
previous,
|
||||
GROUP_BY,
|
||||
),
|
||||
).toStrictEqual([
|
||||
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves values that already differ until one is edited', () => {
|
||||
const queries = [query('A', { groupBy: ['host.name'] }), query('B')];
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(queries, snapshotQueries(queries), GROUP_BY),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('gives a new query the first value without touching differing ones', () => {
|
||||
const saved = [
|
||||
query('A', { groupBy: ['service.name'] }),
|
||||
query('B', { groupBy: ['host.name'] }),
|
||||
];
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(
|
||||
[...saved, query('C')],
|
||||
snapshotQueries(saved),
|
||||
GROUP_BY,
|
||||
),
|
||||
).toStrictEqual([
|
||||
{ index: 2, query: query('C', { groupBy: ['service.name'] }) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('copies an edit to every query even when one was added', () => {
|
||||
const saved = [
|
||||
query('A', { groupBy: ['service.name'] }),
|
||||
query('B', { groupBy: ['host.name'] }),
|
||||
];
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(
|
||||
[query('A', { groupBy: ['k8s.pod.name'] }), saved[1], query('C')],
|
||||
snapshotQueries(saved),
|
||||
GROUP_BY,
|
||||
),
|
||||
).toStrictEqual([
|
||||
{ index: 1, query: query('B', { groupBy: ['k8s.pod.name'] }) },
|
||||
{ index: 2, query: query('C', { groupBy: ['k8s.pod.name'] }) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('syncs each field from its own source', () => {
|
||||
const previous = snapshotQueries([query('A'), query('B')]);
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(
|
||||
[query('A', { groupBy: ['service.name'] }), query('B', { limit: 50 })],
|
||||
previous,
|
||||
[QueryBuilderField.GroupBy, QueryBuilderField.Limit],
|
||||
),
|
||||
).toStrictEqual([
|
||||
{ index: 0, query: query('A', { groupBy: ['service.name'], limit: 50 }) },
|
||||
{ index: 1, query: query('B', { groupBy: ['service.name'], limit: 50 }) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves fields it is not asked to sync', () => {
|
||||
const previous = snapshotQueries([query('A'), query('B')]);
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(
|
||||
[query('A', { limit: 50 }), query('B')],
|
||||
previous,
|
||||
GROUP_BY,
|
||||
),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
QueryBuilderField,
|
||||
type SyncedQueryBuilderField,
|
||||
} from '../queryBuilderFields.types';
|
||||
import { useSyncQueryBuilderFields } from '../useSyncQueryBuilderFields';
|
||||
|
||||
const mockHandleSetQueryData = jest.fn();
|
||||
let mockQueries: IBuilderQuery[] = [];
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => ({
|
||||
currentQuery: { builder: { queryData: mockQueries } },
|
||||
handleSetQueryData: mockHandleSetQueryData,
|
||||
}),
|
||||
}));
|
||||
|
||||
const query = (queryName: string, groupBy: string[]): IBuilderQuery =>
|
||||
({
|
||||
queryName,
|
||||
groupBy: groupBy.map((name) => ({ key: name }) as BaseAutocompleteData),
|
||||
dataSource: 'traces',
|
||||
}) as IBuilderQuery;
|
||||
|
||||
const GROUP_BY: SyncedQueryBuilderField[] = [QueryBuilderField.GroupBy];
|
||||
|
||||
function setup(
|
||||
fields: SyncedQueryBuilderField[] | undefined,
|
||||
initial: IBuilderQuery[],
|
||||
): (next: IBuilderQuery[]) => void {
|
||||
mockQueries = initial;
|
||||
const { rerender } = renderHook(() => useSyncQueryBuilderFields(fields));
|
||||
return (next): void => {
|
||||
mockQueries = next;
|
||||
rerender();
|
||||
};
|
||||
}
|
||||
|
||||
describe('useSyncQueryBuilderFields', () => {
|
||||
beforeEach(() => mockHandleSetQueryData.mockClear());
|
||||
|
||||
it('writes the synced value into the other queries', () => {
|
||||
const update = setup(GROUP_BY, [query('A', []), query('B', [])]);
|
||||
|
||||
update([query('A', ['service.name']), query('B', [])]);
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenCalledTimes(1);
|
||||
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
|
||||
1,
|
||||
query('B', ['service.name']),
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when the kind syncs no fields', () => {
|
||||
const update = setup(undefined, [query('A', []), query('B', [])]);
|
||||
|
||||
update([query('A', ['service.name']), query('B', [])]);
|
||||
|
||||
expect(mockHandleSetQueryData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -22,23 +22,11 @@ export enum QueryBuilderField {
|
||||
AdditionalQueries = 'additional_queries',
|
||||
}
|
||||
|
||||
/** Per-query builder fields that can be kept equal across a panel's queries. */
|
||||
export type SyncedQueryBuilderField =
|
||||
| QueryBuilderField.GroupBy
|
||||
| QueryBuilderField.OrderBy
|
||||
| QueryBuilderField.Having
|
||||
| QueryBuilderField.Limit
|
||||
| QueryBuilderField.StepInterval
|
||||
| QueryBuilderField.ReduceTo;
|
||||
|
||||
/**
|
||||
* `reason` is required on `disabled`: an inert control the user can see has to explain itself.
|
||||
* `placeholder` on `pinned` names the value an empty field falls back to.
|
||||
*/
|
||||
/** `reason` is required on `disabled`: an inert control the user can see has to explain itself. */
|
||||
export type QueryBuilderFieldRule =
|
||||
| { state: 'hidden' }
|
||||
| { state: 'disabled'; reason: string }
|
||||
| { state: 'pinned'; placeholder?: string };
|
||||
| { state: 'pinned' };
|
||||
|
||||
/**
|
||||
* A caller's narrowing of the builder's surface. The builder works out which fields suit
|
||||
|
||||
@@ -10,7 +10,6 @@ export interface ResolvedQueryBuilderField {
|
||||
reason?: string;
|
||||
/** Rendered open, not dismissable, and kept out of the add-on toggle bar. */
|
||||
pinned: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const AVAILABLE: ResolvedQueryBuilderField = {
|
||||
@@ -19,8 +18,6 @@ const AVAILABLE: ResolvedQueryBuilderField = {
|
||||
pinned: false,
|
||||
};
|
||||
|
||||
export const STEP_INTERVAL_PLACEHOLDER = 'Auto';
|
||||
|
||||
function fromRule(rule: QueryBuilderFieldRule): ResolvedQueryBuilderField {
|
||||
switch (rule.state) {
|
||||
case 'hidden':
|
||||
@@ -44,10 +41,8 @@ export function resolveQueryBuilderField(
|
||||
config?: QueryBuilderFieldsConfig,
|
||||
): ResolvedQueryBuilderField {
|
||||
const rule = config?.[field];
|
||||
const resolved = rule ? fromRule(rule) : AVAILABLE;
|
||||
const placeholder = rule?.state === 'pinned' ? rule.placeholder : undefined;
|
||||
|
||||
return placeholder ? { ...resolved, placeholder } : resolved;
|
||||
return rule ? fromRule(rule) : AVAILABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
QueryBuilderField,
|
||||
type SyncedQueryBuilderField,
|
||||
} from './queryBuilderFields.types';
|
||||
|
||||
const QUERY_PROPERTY = {
|
||||
[QueryBuilderField.GroupBy]: 'groupBy',
|
||||
[QueryBuilderField.OrderBy]: 'orderBy',
|
||||
[QueryBuilderField.Having]: 'having',
|
||||
[QueryBuilderField.Limit]: 'limit',
|
||||
[QueryBuilderField.StepInterval]: 'stepInterval',
|
||||
[QueryBuilderField.ReduceTo]: 'reduceTo',
|
||||
} as const satisfies Record<SyncedQueryBuilderField, keyof IBuilderQuery>;
|
||||
|
||||
export function snapshotQueries(
|
||||
queries: IBuilderQuery[],
|
||||
): Map<string, IBuilderQuery> {
|
||||
return new Map(queries.map((query) => [query.queryName, query]));
|
||||
}
|
||||
|
||||
/**
|
||||
* The query each field should be copied from: the one whose value just changed
|
||||
* (to all), else a settled one (to newcomers only) when a query was added or
|
||||
* switched signal. A signal switch reloads that query's own state, so it is
|
||||
* never the source.
|
||||
*/
|
||||
function findSource(
|
||||
property: keyof IBuilderQuery,
|
||||
settled: IBuilderQuery[],
|
||||
hasNewcomer: boolean,
|
||||
previous: Map<string, IBuilderQuery>,
|
||||
): { source: IBuilderQuery; newcomersOnly: boolean } | undefined {
|
||||
const changed = settled.find(
|
||||
(query) =>
|
||||
!isEqual(previous.get(query.queryName)?.[property], query[property]),
|
||||
);
|
||||
if (changed) {
|
||||
return { source: changed, newcomersOnly: false };
|
||||
}
|
||||
return hasNewcomer && settled.length > 0
|
||||
? { source: settled[0], newcomersOnly: true }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function syncQueryBuilderFields(
|
||||
queries: IBuilderQuery[],
|
||||
previous: Map<string, IBuilderQuery>,
|
||||
fields: SyncedQueryBuilderField[],
|
||||
): { index: number; query: IBuilderQuery }[] {
|
||||
const settled = queries.filter((query) => {
|
||||
const before = previous.get(query.queryName);
|
||||
return (
|
||||
before?.dataSource === query.dataSource && before.source === query.source
|
||||
);
|
||||
});
|
||||
const hasNewcomer = settled.length < queries.length;
|
||||
const sources = fields
|
||||
.map((field) => QUERY_PROPERTY[field])
|
||||
.map((property) => ({
|
||||
property,
|
||||
found: findSource(property, settled, hasNewcomer, previous),
|
||||
}));
|
||||
|
||||
return queries.flatMap((query, index) => {
|
||||
const isSettled = settled.includes(query);
|
||||
const patch = sources.reduce<Partial<IBuilderQuery>>(
|
||||
(acc, { property, found }) =>
|
||||
found &&
|
||||
!(found.newcomersOnly && isSettled) &&
|
||||
!isEqual(query[property], found.source[property])
|
||||
? { ...acc, [property]: found.source[property] }
|
||||
: acc,
|
||||
{},
|
||||
);
|
||||
return Object.keys(patch).length > 0
|
||||
? [{ index, query: { ...query, ...patch } }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import type { SyncedQueryBuilderField } from './queryBuilderFields.types';
|
||||
import {
|
||||
snapshotQueries,
|
||||
syncQueryBuilderFields,
|
||||
} from './syncQueryBuilderFields';
|
||||
|
||||
const NO_FIELDS: SyncedQueryBuilderField[] = [];
|
||||
|
||||
/**
|
||||
* Keeps `fields` equal across builder queries: an edit to one query copies to
|
||||
* the rest, and a query added or switched to another signal takes the others'
|
||||
* values. Values that already differ when this starts are left alone until one
|
||||
* of them is edited.
|
||||
*/
|
||||
export function useSyncQueryBuilderFields(
|
||||
fields: SyncedQueryBuilderField[] = NO_FIELDS,
|
||||
): void {
|
||||
const { currentQuery, handleSetQueryData } = useQueryBuilder();
|
||||
const queries = currentQuery.builder.queryData;
|
||||
const previousRef = useRef<Map<string, IBuilderQuery>>();
|
||||
const enabled = fields.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const previous = previousRef.current;
|
||||
previousRef.current = enabled ? snapshotQueries(queries) : undefined;
|
||||
if (!enabled || !previous || queries.length < 2) {
|
||||
return;
|
||||
}
|
||||
syncQueryBuilderFields(queries, previous, fields).forEach(
|
||||
({ index, query }) => handleSetQueryData(index, query),
|
||||
);
|
||||
}, [enabled, fields, queries, handleSetQueryData]);
|
||||
}
|
||||
@@ -5,5 +5,4 @@ export type GroupByFilterProps = {
|
||||
query: IBuilderQuery;
|
||||
onChange: (values: BaseAutocompleteData[]) => void;
|
||||
disabled: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
@@ -34,7 +34,6 @@ export const GroupByFilter = memo(function GroupByFilter({
|
||||
query,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = 'Everything (no breakdown)',
|
||||
}: GroupByFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
@@ -154,7 +153,7 @@ export const GroupByFilter = memo(function GroupByFilter({
|
||||
notFoundContent={isFetching ? <Spin size="small" /> : null}
|
||||
onChange={handleChange}
|
||||
data-testid="group-by"
|
||||
placeholder={localValues?.length === 0 ? placeholder : ''}
|
||||
placeholder={localValues?.length === 0 ? 'Everything (no breakdown)' : ''}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ export type OrderByFilterProps = {
|
||||
isRawQuery?: boolean;
|
||||
entityVersion?: string;
|
||||
isNewQueryV2?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export type OrderByFilterValue = {
|
||||
|
||||
@@ -21,7 +21,6 @@ export function OrderByFilter({
|
||||
isRawQuery = false,
|
||||
entityVersion,
|
||||
isNewQueryV2 = false,
|
||||
placeholder,
|
||||
}: OrderByFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const {
|
||||
@@ -102,7 +101,6 @@ export function OrderByFilter({
|
||||
options={optionsData}
|
||||
notFoundContent={isFetching ? <Spin size="small" /> : null}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
data-testid="order-by"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { useTraceActions } from '../useTraceActions';
|
||||
|
||||
// Mock external dependencies
|
||||
const mockRedirectWithQueryBuilderData = jest.fn();
|
||||
const mockNotifications = {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
const mockSetCopy = jest.fn();
|
||||
const mockQueryClient = {
|
||||
fetchQuery: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock the hooks and dependencies
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: 'signoz_span_duration' },
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: '' },
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): any => ({ notifications: mockNotifications }),
|
||||
}));
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): any => [{ value: '' }, mockSetCopy],
|
||||
}));
|
||||
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueryClient: (): any => mockQueryClient,
|
||||
}));
|
||||
|
||||
// Mock the API response for getAggregateKeys
|
||||
const mockAggregateKeysResponse = {
|
||||
payload: {
|
||||
attributeKeys: [
|
||||
{
|
||||
key: 'http.method',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
isColumn: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockQueryClient.fetchQuery.mockResolvedValue(mockAggregateKeysResponse);
|
||||
});
|
||||
|
||||
describe('useTraceActions Hook User Flow Tests', () => {
|
||||
it('should handle copy field name action', async () => {
|
||||
const { result } = renderHook(() => useTraceActions());
|
||||
|
||||
// Test copying field name
|
||||
result.current.onCopyFieldName('http.method');
|
||||
|
||||
// Verify clipboard and notification
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('http.method');
|
||||
expect(mockNotifications.success).toHaveBeenCalledWith({
|
||||
message: 'Field name copied to clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle copy field value action', async () => {
|
||||
const { result } = renderHook(() => useTraceActions());
|
||||
|
||||
// Test copying field value
|
||||
result.current.onCopyFieldValue('GET');
|
||||
|
||||
// Verify clipboard and notification
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('GET');
|
||||
expect(mockNotifications.success).toHaveBeenCalledWith({
|
||||
message: 'Field value copied to clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle copy field value with quoted strings', async () => {
|
||||
const { result } = renderHook(() => useTraceActions());
|
||||
|
||||
// Test copying quoted field value (should remain as-is since hook doesn't strip quotes)
|
||||
result.current.onCopyFieldValue('"quoted_value"');
|
||||
|
||||
// Verify clipboard contains the exact value passed
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('"quoted_value"');
|
||||
expect(mockNotifications.success).toHaveBeenCalledWith({
|
||||
message: 'Field value copied to clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle group by attribute action', async () => {
|
||||
const { result } = renderHook(() => useTraceActions());
|
||||
|
||||
// Test group by action
|
||||
await result.current.onGroupByAttribute('http.method');
|
||||
|
||||
// Verify navigation with groupBy applied
|
||||
await waitFor(() => {
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
dataSource: 'traces',
|
||||
groupBy: expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'http.method' }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle filter actions with proper query building', async () => {
|
||||
const { result } = renderHook(() => useTraceActions());
|
||||
|
||||
// Test inclusive filter
|
||||
await result.current.onAddToQuery('http.method', 'GET', '=');
|
||||
|
||||
// Verify navigation to traces explorer with inclusive filter
|
||||
await waitFor(() => {
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
dataSource: 'traces',
|
||||
filters: expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'http.method' }),
|
||||
op: '=',
|
||||
value: 'GET',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
});
|
||||
|
||||
// Reset mock and test exclusive filter
|
||||
mockRedirectWithQueryBuilderData.mockClear();
|
||||
await result.current.onAddToQuery('http.method', 'POST', '!=');
|
||||
|
||||
// Verify navigation to traces explorer with exclusive filter
|
||||
await waitFor(() => {
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
dataSource: 'traces',
|
||||
filters: expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'http.method' }),
|
||||
op: '!=',
|
||||
value: 'POST',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
// Mock API to throw error
|
||||
mockQueryClient.fetchQuery.mockRejectedValueOnce(new Error('API Error'));
|
||||
|
||||
const { result } = renderHook(() => useTraceActions());
|
||||
|
||||
// Test that error is handled properly
|
||||
await result.current.onAddToQuery('http.method', 'GET', '=');
|
||||
|
||||
// Verify error notification is shown
|
||||
await waitFor(() => {
|
||||
expect(mockNotifications.error).toHaveBeenCalledWith({
|
||||
message: 'Something went wrong',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
192
frontend/src/hooks/trace/useTraceActions.ts
Normal file
192
frontend/src/hooks/trace/useTraceActions.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
|
||||
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { QueryBuilderKeys } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export interface UseTraceActionsReturn {
|
||||
onAddToQuery: (
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
operator: string,
|
||||
) => Promise<void>;
|
||||
onGroupByAttribute: (fieldKey: string) => Promise<void>;
|
||||
onCopyFieldName: (fieldName: string) => void;
|
||||
onCopyFieldValue: (fieldValue: string) => void;
|
||||
}
|
||||
|
||||
export const useTraceActions = (): UseTraceActionsReturn => {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const queryClient = useQueryClient();
|
||||
const { notifications } = useNotifications();
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
|
||||
const removeExistingFieldFilters = useCallback(
|
||||
(filters: TagFilterItem[], fieldKey: BaseAutocompleteData): TagFilterItem[] =>
|
||||
filters.filter((filter: TagFilterItem) => filter.key?.key !== fieldKey.key),
|
||||
[],
|
||||
);
|
||||
|
||||
const getAutocompleteKey = useCallback(
|
||||
async (fieldKey: string): Promise<BaseAutocompleteData> => {
|
||||
const keysAutocompleteResponse = await queryClient.fetchQuery(
|
||||
[QueryBuilderKeys.GET_AGGREGATE_KEYS, fieldKey],
|
||||
async () =>
|
||||
getAggregateKeys({
|
||||
searchText: fieldKey,
|
||||
aggregateOperator:
|
||||
currentQuery.builder.queryData[0].aggregateOperator || '',
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateAttribute:
|
||||
currentQuery.builder.queryData[0].aggregateAttribute?.key || '',
|
||||
}),
|
||||
);
|
||||
|
||||
const keysAutocomplete: BaseAutocompleteData[] =
|
||||
keysAutocompleteResponse.payload?.attributeKeys || [];
|
||||
|
||||
return chooseAutocompleteFromCustomValue(
|
||||
keysAutocomplete,
|
||||
fieldKey,
|
||||
DataTypes.String,
|
||||
);
|
||||
},
|
||||
[queryClient, currentQuery.builder.queryData],
|
||||
);
|
||||
|
||||
const onAddToQuery = useCallback(
|
||||
async (
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
operator: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const existAutocompleteKey = await getAutocompleteKey(fieldKey);
|
||||
const currentOperator = getOperatorValue(operator);
|
||||
|
||||
const nextQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) => {
|
||||
// Get existing filters and remove any for the same field
|
||||
const currentFilters = item.filters?.items || [];
|
||||
const cleanedFilters = removeExistingFieldFilters(
|
||||
currentFilters,
|
||||
existAutocompleteKey,
|
||||
);
|
||||
|
||||
// Add the new filter to the cleaned list
|
||||
const newFilters = [
|
||||
...cleanedFilters,
|
||||
{
|
||||
id: uuid(),
|
||||
key: existAutocompleteKey,
|
||||
op: currentOperator,
|
||||
value: fieldValue,
|
||||
},
|
||||
];
|
||||
|
||||
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
|
||||
{
|
||||
items: newFilters,
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
item.filter?.expression || '',
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
dataSource: DataSource.TRACES,
|
||||
filters: convertedFilter.filters,
|
||||
filter: convertedFilter.filter,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
redirectWithQueryBuilderData(nextQuery, {}, ROUTES.TRACES_EXPLORER);
|
||||
} catch {
|
||||
notifications.error({ message: SOMETHING_WENT_WRONG });
|
||||
}
|
||||
},
|
||||
[
|
||||
currentQuery,
|
||||
notifications,
|
||||
getAutocompleteKey,
|
||||
redirectWithQueryBuilderData,
|
||||
removeExistingFieldFilters,
|
||||
],
|
||||
);
|
||||
|
||||
const onGroupByAttribute = useCallback(
|
||||
async (fieldKey: string): Promise<void> => {
|
||||
try {
|
||||
const existAutocompleteKey = await getAutocompleteKey(fieldKey);
|
||||
|
||||
const nextQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
dataSource: DataSource.TRACES,
|
||||
groupBy: [...item.groupBy, existAutocompleteKey],
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
redirectWithQueryBuilderData(nextQuery, {}, ROUTES.TRACES_EXPLORER);
|
||||
} catch {
|
||||
notifications.error({ message: SOMETHING_WENT_WRONG });
|
||||
}
|
||||
},
|
||||
[
|
||||
currentQuery,
|
||||
notifications,
|
||||
getAutocompleteKey,
|
||||
redirectWithQueryBuilderData,
|
||||
],
|
||||
);
|
||||
|
||||
const onCopyFieldName = useCallback(
|
||||
(fieldName: string): void => {
|
||||
setCopy(fieldName);
|
||||
notifications.success({
|
||||
message: 'Field name copied to clipboard',
|
||||
});
|
||||
},
|
||||
[setCopy, notifications],
|
||||
);
|
||||
|
||||
const onCopyFieldValue = useCallback(
|
||||
(fieldValue: string): void => {
|
||||
setCopy(fieldValue);
|
||||
notifications.success({
|
||||
message: 'Field value copied to clipboard',
|
||||
});
|
||||
},
|
||||
[setCopy, notifications],
|
||||
);
|
||||
|
||||
return {
|
||||
onAddToQuery,
|
||||
onGroupByAttribute,
|
||||
onCopyFieldName,
|
||||
onCopyFieldValue,
|
||||
};
|
||||
};
|
||||
@@ -5,7 +5,6 @@
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
background-color: var(--l1-background);
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import PromQLQueryContainer from 'container/QueryBuilder/rawQueryEditors/PromQL'
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSyncQueryBuilderFields } from 'components/QueryBuilderV2/useSyncQueryBuilderFields';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
@@ -73,7 +72,6 @@ function PanelEditorQueryBuilder({
|
||||
const isRawQuery = isRawRequest(panelDefinition.queryCapabilities);
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
useSyncQueryBuilderFields(panelDefinition.syncedQueryBuilderFields);
|
||||
|
||||
// Cleared here, not in QueryBuilderV2: builder panes unmount on every tab switch.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -20,9 +20,6 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: (): boolean => false }));
|
||||
jest.mock('components/QueryBuilderV2/useSyncQueryBuilderFields', () => ({
|
||||
useSyncQueryBuilderFields: jest.fn(),
|
||||
}));
|
||||
jest.mock('components/QueryBuilderV2/QueryBuilderV2', () => ({
|
||||
QueryBuilderV2: (props: unknown): null => {
|
||||
mockQueryBuilderV2(props);
|
||||
|
||||
@@ -32,11 +32,6 @@ export interface PanelQueryCapabilities {
|
||||
* carries no explicit limit" — an explicit limit means the user asked for a fixed set.
|
||||
*/
|
||||
serverPaginated: boolean;
|
||||
/**
|
||||
* `limit` for a lone builder query that sets none. Skipped with several queries,
|
||||
* where per-query limits can keep different groups and break the join.
|
||||
*/
|
||||
defaultRowLimit?: number;
|
||||
}
|
||||
|
||||
/** Raw rows rather than an aggregated result — the single source for "is this raw?". */
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { ChartLine } from '@signozhq/icons';
|
||||
import type { SectionConfig } from './sections';
|
||||
import type { AnyPanelInteractionProps } from './interactions';
|
||||
import type { PanelKind } from './panelKind';
|
||||
import type { SyncedQueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
|
||||
import type {
|
||||
PanelQueryCapabilities,
|
||||
QueryBuilderFieldsConfig,
|
||||
@@ -122,8 +121,6 @@ export interface QueryPanelDefinition<
|
||||
queryBuilderFields: QueryBuilderFieldsConfig;
|
||||
/** How this kind's query-range request is shaped (request type, paging, result formatting). */
|
||||
queryCapabilities: PanelQueryCapabilities;
|
||||
/** Builder fields kept equal across queries: an edit to one query copies to the rest. */
|
||||
syncedQueryBuilderFields?: SyncedQueryBuilderField[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -71,10 +71,6 @@ const LIST_PANEL_CAPABILITIES = {
|
||||
orderTiebreaker: true,
|
||||
serverPaginated: true,
|
||||
};
|
||||
const DEFAULT_LIMIT_CAPABILITIES = {
|
||||
...TABLE_CAPABILITIES,
|
||||
defaultRowLimit: 10_000,
|
||||
};
|
||||
|
||||
describe('requestType', () => {
|
||||
it.each([
|
||||
@@ -400,51 +396,6 @@ describe('buildQueryRangeRequest', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultRowLimit', () => {
|
||||
function limits(queries: DashboardtypesQueryDTO[]): unknown[] {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries,
|
||||
queryCapabilities: DEFAULT_LIMIT_CAPABILITIES,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
return (request.compositeQuery?.queries ?? []).map(
|
||||
(envelope) => (envelope.spec as { limit?: number }).limit,
|
||||
);
|
||||
}
|
||||
|
||||
it('limits a lone builder query', () => {
|
||||
expect(limits(bareBuilderQuery({ name: 'A' }))).toStrictEqual([10_000]);
|
||||
});
|
||||
|
||||
it('keeps a limit the user set', () => {
|
||||
expect(limits(bareBuilderQuery({ name: 'A', limit: 50 }))).toStrictEqual([
|
||||
50,
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves several queries unlimited so their join keeps every group', () => {
|
||||
expect(
|
||||
limits(
|
||||
compositeQuery([
|
||||
{ type: 'builder_query', spec: { name: 'A' } },
|
||||
{ type: 'builder_query', spec: { name: 'B' } },
|
||||
]),
|
||||
),
|
||||
).toStrictEqual([undefined, undefined]);
|
||||
});
|
||||
|
||||
it('leaves a lone non-builder query alone', () => {
|
||||
expect(
|
||||
limits(
|
||||
compositeQuery([
|
||||
{ type: 'clickhouse_sql', spec: { name: 'A', query: 'SELECT 1' } },
|
||||
]),
|
||||
),
|
||||
).toStrictEqual([undefined]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBucketedStepIntervalSeconds', () => {
|
||||
// V1 parity: getBarStepIntervalPoints in container/GridCardLayout/utils.ts
|
||||
it.each([
|
||||
|
||||
@@ -217,30 +217,6 @@ function withPagination(
|
||||
});
|
||||
}
|
||||
|
||||
function withDefaultRowLimit(
|
||||
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
|
||||
limit: number,
|
||||
): Querybuildertypesv5QueryEnvelopeDTO[] {
|
||||
const [envelope] = envelopes;
|
||||
if (
|
||||
envelopes.length !== 1 ||
|
||||
envelope.type !==
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query ||
|
||||
envelope.spec?.limit
|
||||
) {
|
||||
return envelopes;
|
||||
}
|
||||
return [
|
||||
{
|
||||
...envelope,
|
||||
spec: {
|
||||
...envelope.spec,
|
||||
limit,
|
||||
} as Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export interface BuildQueryRangeRequestArgs {
|
||||
queries: DashboardtypesQueryDTO[];
|
||||
/**
|
||||
@@ -273,7 +249,6 @@ export function buildQueryRangeRequest({
|
||||
formatTableResultForUI,
|
||||
bucketedStepInterval,
|
||||
orderTiebreaker,
|
||||
defaultRowLimit,
|
||||
},
|
||||
startMs,
|
||||
endMs,
|
||||
@@ -291,9 +266,6 @@ export function buildQueryRangeRequest({
|
||||
if (pagination) {
|
||||
envelopes = withPagination(envelopes, pagination);
|
||||
}
|
||||
if (defaultRowLimit) {
|
||||
envelopes = withDefaultRowLimit(envelopes, defaultRowLimit);
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 'v1',
|
||||
|
||||
@@ -7,3 +7,7 @@
|
||||
cursor: default;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
composes: tooltipContent from '../shared.module.scss';
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './EntityMetadataItem.module.scss';
|
||||
|
||||
interface EntityMetadataItemProps {
|
||||
tooltip: string;
|
||||
tooltip: TooltipSimpleProps['title'];
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -16,7 +16,10 @@ function EntityMetadataItem({
|
||||
children,
|
||||
}: EntityMetadataItemProps): JSX.Element {
|
||||
return (
|
||||
<TooltipSimple title={tooltip}>
|
||||
<TooltipSimple
|
||||
title={tooltip}
|
||||
tooltipContentProps={{ className: styles.tooltip }}
|
||||
>
|
||||
<span className={styles.item}>
|
||||
{icon}
|
||||
<Typography.Text as="span">{children}</Typography.Text>
|
||||
|
||||
@@ -8,3 +8,12 @@
|
||||
// Keep Typography.Text at the row's size (it reads this var, not inherited font-size).
|
||||
--typography-font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.tokenTooltip {
|
||||
composes: tooltipBody from '../shared.module.scss';
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.tokenTooltipTitle {
|
||||
padding-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { CalendarClock, Server, Timer } from '@signozhq/icons';
|
||||
import { CalendarClock, Coins, Landmark, Server, Timer } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import cx from 'classnames';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
|
||||
import {
|
||||
AiTokenCounts,
|
||||
formatCost,
|
||||
formatTokens,
|
||||
getTotalInputTokens,
|
||||
hasValue,
|
||||
} from '../utils/aiTraceUtils';
|
||||
import EntityMetadataItem from './EntityMetadataItem';
|
||||
import TokenUsageTooltip from './TokenUsageTooltip';
|
||||
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
@@ -16,6 +24,8 @@ interface EntityMetadataRowProps {
|
||||
execTimePercent?: number;
|
||||
timestamp?: string;
|
||||
statusCode?: string | number;
|
||||
tokens?: AiTokenCounts;
|
||||
cost?: number | null;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 14;
|
||||
@@ -32,8 +42,11 @@ function EntityMetadataRow({
|
||||
execTimePercent,
|
||||
timestamp,
|
||||
statusCode,
|
||||
tokens,
|
||||
cost,
|
||||
}: EntityMetadataRowProps): JSX.Element {
|
||||
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
|
||||
const totalInput = tokens && getTotalInputTokens(tokens);
|
||||
const durationTooltip =
|
||||
entity === 'trace' ? 'Trace Duration' : 'Span Duration';
|
||||
// Single source of duration formatting so both rows label units identically.
|
||||
@@ -91,6 +104,24 @@ function EntityMetadataRow({
|
||||
<HttpStatusBadge statusCode={statusCode} />
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{tokens && (
|
||||
<EntityMetadataItem
|
||||
tooltip={<TokenUsageTooltip tokens={tokens} />}
|
||||
icon={<Coins size={ICON_SIZE} />}
|
||||
>
|
||||
Tokens: {formatTokens(totalInput)} → {formatTokens(tokens.output)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{hasValue(cost) && (
|
||||
<EntityMetadataItem
|
||||
tooltip="Total cost"
|
||||
icon={<Landmark size={ICON_SIZE} />}
|
||||
>
|
||||
Cost — {formatCost(cost)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -102,6 +133,8 @@ EntityMetadataRow.defaultProps = {
|
||||
execTimePercent: undefined,
|
||||
timestamp: undefined,
|
||||
statusCode: undefined,
|
||||
tokens: undefined,
|
||||
cost: undefined,
|
||||
};
|
||||
|
||||
export default EntityMetadataRow;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
|
||||
import { AiTokenCounts } from '../utils/aiTraceUtils';
|
||||
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
interface TokenUsageTooltipProps {
|
||||
tokens: AiTokenCounts;
|
||||
}
|
||||
|
||||
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
|
||||
const { input, output, cacheRead, cacheWrite, reasoning } = tokens;
|
||||
|
||||
return (
|
||||
<div className={styles.tokenTooltip}>
|
||||
<Typography.Text
|
||||
size="small"
|
||||
weight="normal"
|
||||
className={styles.tokenTooltipTitle}
|
||||
>
|
||||
Usage Breakdown
|
||||
</Typography.Text>
|
||||
<TokenBreakdown
|
||||
input={input}
|
||||
output={output}
|
||||
cacheRead={cacheRead}
|
||||
cacheWrite={cacheWrite}
|
||||
reasoning={reasoning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenUsageTooltip;
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link2 } from '@signozhq/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
||||
import { getAIUsageDetails, convertToAiTokens } from '../utils/aiTraceUtils';
|
||||
import { HIGHLIGHTED_OPTIONS } from './config';
|
||||
import {
|
||||
LinkedSpansPanel,
|
||||
@@ -31,6 +33,10 @@ function SpanSummary({
|
||||
}: SpanSummaryProps): JSX.Element {
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
const aiUsage = useMemo(
|
||||
() => getAIUsageDetails(selectedSpan)?.usage,
|
||||
[selectedSpan],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -63,6 +69,8 @@ function SpanSummary({
|
||||
: undefined
|
||||
}
|
||||
timestamp={dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
|
||||
tokens={aiUsage && convertToAiTokens(aiUsage)}
|
||||
cost={aiUsage?.cost}
|
||||
/>
|
||||
|
||||
<div className={styles.spanInfoItem}>
|
||||
|
||||
@@ -95,4 +95,26 @@ describe('SpanSummary', () => {
|
||||
|
||||
expect(screen.queryByText(/of total exec time/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tokens and cost from the gen_ai usage attributes', () => {
|
||||
const span = {
|
||||
...createMockSpan(),
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 1000,
|
||||
'gen_ai.usage.output_tokens': 9,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.000009,
|
||||
},
|
||||
};
|
||||
render(<SpanSummary selectedSpan={span} />);
|
||||
|
||||
expect(screen.getByText(/Tokens: 1,000 → 9/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Cost — \$ 0.000009/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits tokens and cost for non-AI spans', () => {
|
||||
render(<SpanSummary selectedSpan={createMockSpan()} />);
|
||||
|
||||
expect(screen.queryByText(/Tokens:/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Cost —/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
SPAN_ACTION,
|
||||
useSpanAttributeActions,
|
||||
} from '../useSpanAttributeActions';
|
||||
|
||||
const mockRedirect = jest.fn();
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => ({
|
||||
currentQuery: initialQueriesMap.traces,
|
||||
redirectWithQueryBuilderData: mockRedirect,
|
||||
}),
|
||||
}));
|
||||
|
||||
const runAction = (
|
||||
value: string,
|
||||
args: { key: string; value: string },
|
||||
): Query => {
|
||||
const { result } = renderHook(() => useSpanAttributeActions());
|
||||
const action = result.current.find((a) => a.value === value);
|
||||
if (!action) {
|
||||
throw new Error(`action ${value} not found`);
|
||||
}
|
||||
action.callback(args);
|
||||
expect(mockRedirect).toHaveBeenCalledTimes(1);
|
||||
return mockRedirect.mock.calls[0][0] as Query;
|
||||
};
|
||||
|
||||
describe('useSpanAttributeActions', () => {
|
||||
beforeEach(() => {
|
||||
mockRedirect.mockClear();
|
||||
});
|
||||
|
||||
it('filter for value redirects to the traces list with the key applied', () => {
|
||||
const query = runAction(SPAN_ACTION.FILTER_IN, {
|
||||
key: 'http.status_code',
|
||||
value: '500',
|
||||
});
|
||||
|
||||
const queryData = query.builder.queryData[0];
|
||||
const item = queryData.filters?.items.at(-1);
|
||||
expect(item?.key?.key).toBe('http.status_code');
|
||||
expect(item?.op).toBe('=');
|
||||
expect(item?.value).toBe('500');
|
||||
expect(queryData.filter?.expression).toBe("http.status_code = '500'");
|
||||
expect(mockRedirect.mock.calls[0][1]).toStrictEqual({
|
||||
panelTypes: PANEL_TYPES.LIST,
|
||||
});
|
||||
expect(mockRedirect.mock.calls[0][2]).toBe(ROUTES.TRACES_EXPLORER);
|
||||
});
|
||||
|
||||
it('filter out value negates the clause', () => {
|
||||
const query = runAction(SPAN_ACTION.FILTER_OUT, {
|
||||
key: 'service.name',
|
||||
value: 'frontend',
|
||||
});
|
||||
|
||||
const queryData = query.builder.queryData[0];
|
||||
expect(queryData.filters?.items.at(-1)?.op).toBe('!=');
|
||||
expect(queryData.filter?.expression).toBe("service.name != 'frontend'");
|
||||
});
|
||||
|
||||
it('group by attribute redirects to the time series view with the bare key', () => {
|
||||
const query = runAction(SPAN_ACTION.GROUP_BY, {
|
||||
key: 'service.name',
|
||||
value: 'frontend',
|
||||
});
|
||||
|
||||
const groupBy = query.builder.queryData[0].groupBy.at(-1);
|
||||
expect(groupBy).toMatchObject({
|
||||
key: 'service.name',
|
||||
dataType: '',
|
||||
type: '',
|
||||
});
|
||||
expect(mockRedirect.mock.calls[0][1]).toStrictEqual({
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
expect(mockRedirect.mock.calls[0][2]).toBe(ROUTES.TRACES_EXPLORER);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,20 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
|
||||
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
|
||||
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { PANEL_TYPES, QueryBuilderKeys } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
import { ArrowDownToDot, ArrowUpFromDot } from '@signozhq/icons';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
@@ -17,7 +25,7 @@ export interface SpanAttributeAction {
|
||||
icon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
callback: (args: { key: string; value: string }) => void;
|
||||
callback: (args: { key: string; value: string; dataType?: string }) => void;
|
||||
/**
|
||||
* Returns true if this action should be hidden. Receives the leaf key and
|
||||
* the full path so we can hide based on ancestor segments too.
|
||||
@@ -62,50 +70,82 @@ export const SPAN_ACTION = {
|
||||
|
||||
export function useSpanAttributeActions(): SpanAttributeAction[] {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleFilter = useCallback(
|
||||
({ key, value }: { key: string; value: string }, operator: string): void => {
|
||||
const filterKey = chooseAutocompleteFromCustomValue([], key);
|
||||
const resolvedOperator = getOperatorValue(operator);
|
||||
|
||||
const nextQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) => {
|
||||
const cleanedFilters = (item.filters?.items || []).filter(
|
||||
(f) => f.key?.key !== filterKey.key,
|
||||
);
|
||||
const newFilters = [
|
||||
...cleanedFilters,
|
||||
{
|
||||
id: uuid(),
|
||||
key: filterKey,
|
||||
op: resolvedOperator,
|
||||
value,
|
||||
},
|
||||
];
|
||||
const converted = convertFiltersToExpressionWithExistingQuery(
|
||||
{ items: newFilters, op: item.filters?.op || 'AND' },
|
||||
item.filter?.expression || '',
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
dataSource: DataSource.TRACES,
|
||||
filters: converted.filters,
|
||||
filter: converted.filter,
|
||||
};
|
||||
const getAutocompleteKey = useCallback(
|
||||
async (fieldKey: string): Promise<BaseAutocompleteData> => {
|
||||
const response = await queryClient.fetchQuery(
|
||||
[QueryBuilderKeys.GET_AGGREGATE_KEYS, fieldKey],
|
||||
async () =>
|
||||
getAggregateKeys({
|
||||
searchText: fieldKey,
|
||||
aggregateOperator:
|
||||
currentQuery.builder.queryData[0].aggregateOperator || '',
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateAttribute:
|
||||
currentQuery.builder.queryData[0].aggregateAttribute?.key || '',
|
||||
}),
|
||||
},
|
||||
};
|
||||
);
|
||||
|
||||
redirectWithQueryBuilderData(
|
||||
nextQuery,
|
||||
{ panelTypes: PANEL_TYPES.LIST },
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
return chooseAutocompleteFromCustomValue(
|
||||
response.payload?.attributeKeys || [],
|
||||
fieldKey,
|
||||
DataTypes.String,
|
||||
);
|
||||
},
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
[queryClient, currentQuery.builder.queryData],
|
||||
);
|
||||
|
||||
const handleFilter = useCallback(
|
||||
async (
|
||||
{ key, value }: { key: string; value: string },
|
||||
operator: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const autocompleteKey = await getAutocompleteKey(key);
|
||||
const resolvedOperator = getOperatorValue(operator);
|
||||
|
||||
const nextQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) => {
|
||||
const cleanedFilters = (item.filters?.items || []).filter(
|
||||
(f) => f.key?.key !== autocompleteKey.key,
|
||||
);
|
||||
const newFilters = [
|
||||
...cleanedFilters,
|
||||
{
|
||||
id: uuid(),
|
||||
key: autocompleteKey,
|
||||
op: resolvedOperator,
|
||||
value,
|
||||
},
|
||||
];
|
||||
const converted = convertFiltersToExpressionWithExistingQuery(
|
||||
{ items: newFilters, op: item.filters?.op || 'AND' },
|
||||
item.filter?.expression || '',
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
dataSource: DataSource.TRACES,
|
||||
filters: converted.filters,
|
||||
filter: converted.filter,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
redirectWithQueryBuilderData(
|
||||
nextQuery,
|
||||
{ panelTypes: PANEL_TYPES.LIST },
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
} catch {
|
||||
toast.error(SOMETHING_WENT_WRONG, { position: 'top-right' });
|
||||
}
|
||||
},
|
||||
[currentQuery, getAutocompleteKey, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
const handleFilterIn = useCallback(
|
||||
@@ -123,28 +163,32 @@ export function useSpanAttributeActions(): SpanAttributeAction[] {
|
||||
);
|
||||
|
||||
const handleGroupBy = useCallback(
|
||||
({ key }: { key: string }): void => {
|
||||
const groupByKey = chooseAutocompleteFromCustomValue([], key);
|
||||
async ({ key }: { key: string }): Promise<void> => {
|
||||
try {
|
||||
const autocompleteKey = await getAutocompleteKey(key);
|
||||
|
||||
const nextQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
dataSource: DataSource.TRACES,
|
||||
groupBy: [...item.groupBy, groupByKey],
|
||||
})),
|
||||
},
|
||||
};
|
||||
const nextQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
dataSource: DataSource.TRACES,
|
||||
groupBy: [...item.groupBy, autocompleteKey],
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
redirectWithQueryBuilderData(
|
||||
nextQuery,
|
||||
{ panelTypes: PANEL_TYPES.TIME_SERIES },
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
redirectWithQueryBuilderData(
|
||||
nextQuery,
|
||||
{ panelTypes: PANEL_TYPES.TIME_SERIES },
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
} catch {
|
||||
toast.error(SOMETHING_WENT_WRONG, { position: 'top-right' });
|
||||
}
|
||||
},
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
[currentQuery, getAutocompleteKey, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { getAiSpanKind } from '../utils/aiTraceUtils';
|
||||
import { AI_SPAN_BADGES } from './constants';
|
||||
|
||||
interface SpanHintBadgeProps {
|
||||
span: SpanV3;
|
||||
}
|
||||
|
||||
function SpanHintBadge({ span }: SpanHintBadgeProps): JSX.Element | null {
|
||||
const aiSpanKind = getAiSpanKind(span);
|
||||
|
||||
if (aiSpanKind) {
|
||||
const { label, color } = AI_SPAN_BADGES[aiSpanKind];
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
// The outline variant derives text, fill and border from this color.
|
||||
style={{ '--badge-background': color } as CSSProperties}
|
||||
data-testid={`span-hint-badge-${aiSpanKind}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (!span.response_status_code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HttpStatusBadge statusCode={span.response_status_code} />;
|
||||
}
|
||||
|
||||
export default SpanHintBadge;
|
||||
10
frontend/src/pages/TraceDetailsV3/SpanHintBadge/constants.ts
Normal file
10
frontend/src/pages/TraceDetailsV3/SpanHintBadge/constants.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { AiSpanKind } from '../utils/aiTraceUtils';
|
||||
|
||||
export const AI_SPAN_BADGES: Record<
|
||||
AiSpanKind,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
[AiSpanKind.Llm]: { label: 'LLM', color: 'rgba(155, 255, 0, 1)' },
|
||||
[AiSpanKind.Tool]: { label: 'TOOL', color: 'rgba(255, 209, 0, 1)' },
|
||||
[AiSpanKind.Agent]: { label: 'AGENT', color: 'rgba(255, 10, 138, 1)' },
|
||||
};
|
||||
@@ -8,16 +8,12 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--l2-foreground);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.name {
|
||||
|
||||
@@ -9,40 +9,24 @@
|
||||
}
|
||||
|
||||
.popover {
|
||||
composes: tooltipContent from '../shared.module.scss';
|
||||
// Hover card may be rendered while the SpanDetailsPanel is docked as
|
||||
// a FloatingPanel (z-index 999); bump above the default tooltip z-index.
|
||||
--tooltip-z-index: 1000;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
composes: tooltipBody from '../shared.module.scss';
|
||||
min-width: 223px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
.title {
|
||||
composes: tooltipTitle from '../shared.module.scss';
|
||||
}
|
||||
|
||||
.row {
|
||||
line-height: 1.5;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.preview {
|
||||
// container for additional preview rows
|
||||
}
|
||||
|
||||
.previewKey {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.previewValue {
|
||||
color: var(--l1-foreground);
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
@@ -12,6 +13,11 @@ import { useMemo } from 'react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { getAIUsageDetails, SpanAiDetails } from '../utils/aiTraceUtils';
|
||||
import SpanUsageBreakdown from './SpanUsageBreakdown';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
import styles from './SpanHoverCard.module.scss';
|
||||
|
||||
/**
|
||||
@@ -38,6 +44,7 @@ export interface SpanTooltipContentProps {
|
||||
relativeStartMs: number;
|
||||
durationMs: number;
|
||||
previewRows?: SpanPreviewRow[];
|
||||
ai?: SpanAiDetails;
|
||||
}
|
||||
|
||||
export function SpanTooltipContent({
|
||||
@@ -47,33 +54,54 @@ export function SpanTooltipContent({
|
||||
relativeStartMs,
|
||||
durationMs,
|
||||
previewRows,
|
||||
ai,
|
||||
}: SpanTooltipContentProps): JSX.Element {
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(durationMs);
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.name} style={{ color }}>
|
||||
{spanName}
|
||||
</div>
|
||||
<div className={styles.row}>status: {hasError ? 'error' : 'ok'}</div>
|
||||
<div className={styles.row}>start: {toFixed(relativeStartMs, 2)} ms</div>
|
||||
<div className={styles.row}>
|
||||
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
|
||||
<div className={styles.header}>
|
||||
<Typography.Text
|
||||
className={styles.title}
|
||||
size="small"
|
||||
weight="normal"
|
||||
style={{ color }}
|
||||
>
|
||||
{spanName}
|
||||
</Typography.Text>
|
||||
{ai?.usage && (
|
||||
<Typography.Text size="small" weight="normal">
|
||||
Usage Breakdown
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
|
||||
<TraceTooltipSection>
|
||||
{ai?.model && <TraceTooltipRow label="model" value={ai.model} />}
|
||||
{ai?.toolName && <TraceTooltipRow label="tool" value={ai.toolName} />}
|
||||
{ai?.agentName && <TraceTooltipRow label="agent" value={ai.agentName} />}
|
||||
<TraceTooltipRow label="status" value={hasError ? 'error' : 'ok'} />
|
||||
<TraceTooltipRow
|
||||
label="start"
|
||||
value={`${toFixed(relativeStartMs, 2)} ms`}
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label="duration"
|
||||
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
{previewRows && previewRows.length > 0 && (
|
||||
<div className={styles.preview}>
|
||||
<TraceTooltipSection>
|
||||
{previewRows.map((row) => (
|
||||
<div
|
||||
<TraceTooltipRow
|
||||
key={row.key}
|
||||
className={styles.row}
|
||||
data-testid={`span-hover-card-preview-${row.key}`}
|
||||
>
|
||||
<span className={styles.previewKey}>{row.key}:</span>{' '}
|
||||
<span className={styles.previewValue}>{row.value}</span>
|
||||
</div>
|
||||
label={row.key}
|
||||
value={row.value}
|
||||
testId={`span-hover-card-preview-${row.key}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TraceTooltipSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -137,6 +165,7 @@ export function SpanHoverCard({
|
||||
relativeStartMs: span.timestamp - traceStartTime,
|
||||
durationMs: span.duration_nano / 1e6,
|
||||
previewRows,
|
||||
ai: getAIUsageDetails(span),
|
||||
},
|
||||
};
|
||||
}, [
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { formatCost, hasValue, SpanAiUsage } from '../utils/aiTraceUtils';
|
||||
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
interface SpanUsageBreakdownProps {
|
||||
usage: SpanAiUsage;
|
||||
}
|
||||
|
||||
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
|
||||
const {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
reasoningTokens,
|
||||
cost,
|
||||
} = usage;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TokenBreakdown
|
||||
input={inputTokens}
|
||||
output={outputTokens}
|
||||
cacheRead={cacheReadTokens}
|
||||
cacheWrite={cacheCreationTokens}
|
||||
reasoning={reasoningTokens}
|
||||
/>
|
||||
{hasValue(cost) && (
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow isHeading label="Cost" value={formatCost(cost)} />
|
||||
</TraceTooltipSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanUsageBreakdown;
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
AiTokenCounts,
|
||||
formatTokens,
|
||||
getTokenLabel,
|
||||
getTotalInputTokens,
|
||||
} from '../utils/aiTraceUtils';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
type TokenBreakdownProps = AiTokenCounts;
|
||||
|
||||
function TokenBreakdown({
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
reasoning,
|
||||
}: TokenBreakdownProps): JSX.Element {
|
||||
const totalInput = getTotalInputTokens({ input, cacheRead, cacheWrite });
|
||||
|
||||
return (
|
||||
<>
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Total Input', totalInput)}
|
||||
value={formatTokens(totalInput)}
|
||||
isHeading
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Cache Read', cacheRead)}
|
||||
value={formatTokens(cacheRead)}
|
||||
isNested
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Cache Write', cacheWrite)}
|
||||
value={formatTokens(cacheWrite)}
|
||||
isNested
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Input', input)}
|
||||
value={formatTokens(input)}
|
||||
isNested
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Reasoning', reasoning)}
|
||||
value={formatTokens(reasoning)}
|
||||
isHeading
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Output', output)}
|
||||
value={formatTokens(output)}
|
||||
isHeading
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label="Total Token Usage"
|
||||
value={formatTokens(totalInput + (output ?? 0))}
|
||||
isHeading
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TokenBreakdown.defaultProps = {
|
||||
input: undefined,
|
||||
output: undefined,
|
||||
cacheRead: undefined,
|
||||
cacheWrite: undefined,
|
||||
reasoning: undefined,
|
||||
};
|
||||
|
||||
export default TokenBreakdown;
|
||||
@@ -34,6 +34,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: unset;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.filterSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
@@ -18,22 +18,26 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
import TraceDownloadPanel from './TraceDownloadPanel';
|
||||
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
||||
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
|
||||
import MissingSpansBanner from './MissingSpansBanner';
|
||||
import TraceOptionsMenu from './TraceOptionsMenu';
|
||||
import { useTraceSummary } from './useTraceSummary';
|
||||
import TraceDetailsTabs from './TraceDetailsTabs';
|
||||
import TraceOptionsMenu from './TraceOptionsMenu';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
|
||||
interface TraceDetailsHeaderProps {
|
||||
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
showTraceDetailsHeaderOptions?: boolean;
|
||||
}
|
||||
|
||||
@@ -65,6 +69,14 @@ function TraceDetailsHeader({
|
||||
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
|
||||
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
|
||||
const { data: traceSummary } = useTraceSummary(traceID);
|
||||
const [tab] = useTraceDetailsTab();
|
||||
const isOverview = tab === TraceDetailsTab.Overview;
|
||||
|
||||
// Overview-only panels must not linger over another tab.
|
||||
useEffect(() => {
|
||||
setIsPreviewFieldsOpen(false);
|
||||
setIsAnalyticsOpen(false);
|
||||
}, [tab]);
|
||||
const previewFields = useTraceStore((s) => s.previewFields);
|
||||
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
|
||||
|
||||
@@ -98,13 +110,14 @@ function TraceDetailsHeader({
|
||||
setShowTraceDetails((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
useTraceDetailsStripInfo({
|
||||
totalSpansCount: traceSummary?.totalSpansCount ?? 0,
|
||||
totalErrorSpansCount: traceSummary?.totalErrorSpansCount ?? 0,
|
||||
});
|
||||
|
||||
const startTime = (traceSummary?.startTimestampMillis ?? 0) / 1e3;
|
||||
const endTime = (traceSummary?.endTimestampMillis ?? 0) / 1e3;
|
||||
|
||||
const durationMs = traceSummary
|
||||
? traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.header}>
|
||||
@@ -125,9 +138,10 @@ function TraceDetailsHeader({
|
||||
badgeValue={traceID}
|
||||
maxCharacters={100}
|
||||
/>
|
||||
<TraceDetailsTabs />
|
||||
</div>
|
||||
)}
|
||||
{showTraceDetailsHeaderOptions && traceSummary && (
|
||||
{isOverview && showTraceDetailsHeaderOptions && traceSummary && (
|
||||
<div
|
||||
className={cx(
|
||||
styles.filterSection,
|
||||
@@ -181,28 +195,32 @@ function TraceDetailsHeader({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showTraceDetails && (
|
||||
{(showTraceDetails || !isOverview) && (
|
||||
<div className={styles.subHeader}>
|
||||
{traceSummary ? (
|
||||
{!traceSummary ? (
|
||||
<DetailsLoader />
|
||||
) : (
|
||||
<EntityMetadataRow
|
||||
entity="trace"
|
||||
service={{
|
||||
name: traceSummary.rootServiceName,
|
||||
entryPoint: traceSummary.rootServiceEntryPoint,
|
||||
}}
|
||||
durationMs={durationMs}
|
||||
durationMs={
|
||||
traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
|
||||
}
|
||||
timestamp={dayjs(traceSummary.startTimestampMillis).format(
|
||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||
)}
|
||||
statusCode={traceSummary.rootSpanStatusCode}
|
||||
tokens={traceSummary.ai?.tokens}
|
||||
cost={traceSummary.ai?.totalCost}
|
||||
/>
|
||||
) : (
|
||||
<DetailsLoader />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{traceSummary?.hasMissingSpans && <MissingSpansBanner />}
|
||||
{isOverview && traceSummary?.hasMissingSpans && <MissingSpansBanner />}
|
||||
|
||||
<FieldsSelector
|
||||
isOpen={isPreviewFieldsOpen}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
|
||||
function TraceDetailsTabs(): JSX.Element {
|
||||
const [tab, setTab] = useTraceDetailsTab();
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
// Clicking the active item deselects it and reports ''.
|
||||
if (value) {
|
||||
void setTab(value as TraceDetailsTab);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={tab}
|
||||
onChange={handleChange}
|
||||
className={styles.tabs}
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value={TraceDetailsTab.Overview}
|
||||
className={styles.tab}
|
||||
data-testid="trace-details-tab-overview"
|
||||
>
|
||||
Overview
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value={TraceDetailsTab.Thread}
|
||||
className={styles.tab}
|
||||
data-testid="trace-details-tab-thread"
|
||||
>
|
||||
Thread
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsTabs;
|
||||
@@ -1,6 +1,8 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
@@ -176,9 +178,19 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
hasMissingSpans: false,
|
||||
totalSpansCount: 42,
|
||||
totalErrorSpansCount: 0,
|
||||
ai: {
|
||||
tokens: {
|
||||
input: 12040,
|
||||
output: 3110,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
reasoning: 0,
|
||||
},
|
||||
totalCost: 0.0421,
|
||||
},
|
||||
};
|
||||
|
||||
const mockSummary = (data?: typeof traceMetadata): void => {
|
||||
const mockSummary = (data?: SpantypesGettableTraceSummaryDTO): void => {
|
||||
jest.mocked(useTraceSummary).mockReturnValue({ data, isLoading: false });
|
||||
};
|
||||
|
||||
@@ -201,6 +213,77 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
expect(screen.getByText(duration)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders AI tokens and cost when the summary has them', () => {
|
||||
mockSummary(traceMetadata);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits AI tokens and cost when the summary has no ai field', () => {
|
||||
mockSummary({ ...traceMetadata, ai: undefined });
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.queryByText(/^Tokens:/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows AI tokens when input and output are both zero', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: {
|
||||
...traceMetadata.ai,
|
||||
tokens: { ...traceMetadata.ai.tokens, input: 0, output: 0 },
|
||||
},
|
||||
} as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows zero for a missing input', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: {
|
||||
...traceMetadata.ai,
|
||||
tokens: { ...traceMetadata.ai.tokens, input: undefined },
|
||||
},
|
||||
} as unknown as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 0 → 3,110')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows zero AI tokens when input and output are both missing', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: {
|
||||
...traceMetadata.ai,
|
||||
tokens: {
|
||||
...traceMetadata.ai.tokens,
|
||||
input: undefined,
|
||||
output: undefined,
|
||||
},
|
||||
},
|
||||
} as unknown as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the cost when the summary sends null', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: { ...traceMetadata.ai, totalCost: null },
|
||||
} as unknown as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is shown by default and can be hidden / shown again via the Trace options menu', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
mockSummary(traceMetadata);
|
||||
@@ -233,3 +316,96 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
const metadataText = /Missing Span/;
|
||||
|
||||
describe('TraceDetailsHeader – tabs', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(useTraceSummary).mockReturnValue({
|
||||
data: {
|
||||
...SUMMARY,
|
||||
rootServiceEntryPoint: 'Missing Span',
|
||||
hasMissingSpans: true,
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest
|
||||
.mocked(useTraceSummary)
|
||||
.mockReturnValue({ data: undefined, isLoading: false });
|
||||
});
|
||||
|
||||
// In-memory URL state, so a tab switch can't leak into the next test.
|
||||
const renderHeader = (): void => {
|
||||
render(
|
||||
<NuqsTestingAdapter hasMemory>
|
||||
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
};
|
||||
|
||||
it('shows the Overview-only sections on the Overview tab', () => {
|
||||
renderHeader();
|
||||
|
||||
expect(screen.getByTestId('filters-stub')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /^analytics$/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /trace options/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('missing-spans-banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps only the metadata row on the Thread tab', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.queryByTestId('filters-stub')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^analytics$/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /trace options/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('missing-spans-banner')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the metadata row on the Thread tab even when hidden on Overview', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /trace options/i }));
|
||||
await user.click(
|
||||
await screen.findByRole('menuitem', { name: /hide trace details/i }),
|
||||
);
|
||||
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes the Analytics panel when switching to the Thread tab', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^analytics$/i }));
|
||||
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
|
||||
'data-open',
|
||||
'true',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
|
||||
'data-open',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layoutRow {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -0,0 +1,495 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
ChartNoAxesGantt,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
} from '@signozhq/icons';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import { Collapse } from 'antd';
|
||||
import { useDetailsPanel } from 'components/DetailsPanel';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import NoData from '../NoData/NoData';
|
||||
import {
|
||||
setTraceStoreAvailableColorByFields,
|
||||
useTraceStore,
|
||||
} from '../stores/traceStore';
|
||||
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from '../SpanDetailsPanel/SpanDetailsPanel';
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from '../TraceFlamegraph/constants';
|
||||
import TraceFlamegraph from '../TraceFlamegraph/TraceFlamegraph';
|
||||
import TraceWaterfall from '../TraceWaterfall/TraceWaterfall';
|
||||
import { IInterestedSpan } from '../TraceWaterfall/types';
|
||||
import { getAncestorSpanIds } from '../TraceWaterfall/utils';
|
||||
import { getAvailableColorByFieldNames } from '../utils';
|
||||
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './TraceDetailsOverview.module.scss';
|
||||
|
||||
// Lucide chevrons for the flame/waterfall accordion headers, matching the
|
||||
// span-tree chevrons in the waterfall.
|
||||
function renderPanelExpandIcon({
|
||||
isActive,
|
||||
}: {
|
||||
isActive?: boolean;
|
||||
}): JSX.Element {
|
||||
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
|
||||
}
|
||||
|
||||
interface TraceDetailsOverviewProps {
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
onHasTraceDataChange: (hasTraceData: boolean) => void;
|
||||
}
|
||||
|
||||
function TraceDetailsOverview({
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
onHasTraceDataChange,
|
||||
}: TraceDetailsOverviewProps): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
|
||||
() => ({
|
||||
spanId: urlQuery.get('spanId') || '',
|
||||
isUncollapsed: urlQuery.get('spanId') !== '',
|
||||
}),
|
||||
);
|
||||
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
|
||||
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
|
||||
|
||||
const selectedSpanId = urlQuery.get('spanId') || undefined;
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
||||
// Tracks which traceId the load event already fired for, so navigating
|
||||
// between traces (the route component stays mounted) re-fires it once each.
|
||||
const dataLoadedFiredForRef = useRef('');
|
||||
const colorByField = useTraceStore((s) => s.colorByField);
|
||||
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
|
||||
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
|
||||
|
||||
const handleSpanDetailsClose = useCallback((): void => {
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
params.delete('spanId');
|
||||
safeNavigate({ search: params.toString() }, { replace: true });
|
||||
}, [safeNavigate]);
|
||||
|
||||
const panelState = useDetailsPanel({
|
||||
entityId: selectedSpanId,
|
||||
onClose: handleSpanDetailsClose,
|
||||
});
|
||||
|
||||
const allSpansRef = useRef<SpanV3[]>([]);
|
||||
const deepLinkResolvedRef = useRef(false);
|
||||
|
||||
// Refetch only when the URL target isn't already loaded. Keeps row clicks
|
||||
// and other in-window URL navigation from triggering a backend window slide.
|
||||
useEffect(() => {
|
||||
const spanId = urlQuery.get('spanId') || '';
|
||||
if (!spanId) {
|
||||
return;
|
||||
}
|
||||
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
|
||||
if (idx !== -1) {
|
||||
setSelectedSpan(allSpansRef.current[idx]);
|
||||
return;
|
||||
}
|
||||
setInterestedSpanId({ spanId, isUncollapsed: true });
|
||||
}, [urlQuery]);
|
||||
|
||||
// Once all spans are loaded (frontend mode), freeze query params so
|
||||
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
|
||||
const fullDataLoadedRef = useRef(false);
|
||||
const frozenParamsRef = useRef({
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
});
|
||||
|
||||
const queryParams = fullDataLoadedRef.current
|
||||
? frozenParamsRef.current
|
||||
: {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
|
||||
const {
|
||||
data: traceData,
|
||||
isFetching: isFetchingTraceData,
|
||||
error: errorFetchingTraceData,
|
||||
} = useGetTraceV4({
|
||||
traceId,
|
||||
uncollapsedSpans: queryParams.uncollapsedSpans,
|
||||
selectedSpanId: queryParams.selectedSpanId,
|
||||
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
|
||||
});
|
||||
|
||||
const allSpans = traceData?.payload?.spans || [];
|
||||
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
|
||||
|
||||
const isFullDataLoaded =
|
||||
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
|
||||
|
||||
// Color-by options, gated on fields in loaded spans. Resource attrs are
|
||||
// trace-wide, so any window has the full set — no need to accumulate.
|
||||
const availableColorByFields = useMemo(() => {
|
||||
const spans = traceData?.payload?.spans;
|
||||
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
|
||||
}, [traceData?.payload?.spans]);
|
||||
|
||||
useEffect(() => {
|
||||
setTraceStoreAvailableColorByFields(availableColorByFields);
|
||||
}, [availableColorByFields]);
|
||||
|
||||
// Lock the ref once we confirm all data is loaded
|
||||
if (isFullDataLoaded && !fullDataLoadedRef.current) {
|
||||
fullDataLoadedRef.current = true;
|
||||
frozenParamsRef.current = {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
allSpansRef.current = allSpans;
|
||||
}, [allSpans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!traceId ||
|
||||
dataLoadedFiredForRef.current === traceId ||
|
||||
!userPrefsReady
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const payload = traceData?.payload;
|
||||
if (!payload?.spans?.length) {
|
||||
return;
|
||||
}
|
||||
dataLoadedFiredForRef.current = traceId;
|
||||
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
[TraceDetailEventKeys.NumServices]: numServices,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
payload.endTimestampMillis - payload.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
|
||||
[TraceDetailEventKeys.FlamegraphSampled]:
|
||||
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
|
||||
[TraceDetailEventKeys.SpanPanelVariant]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
|
||||
SpanDetailVariant.DOCKED_RIGHT,
|
||||
[TraceDetailEventKeys.ColorByField]: colorByField.name,
|
||||
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
|
||||
});
|
||||
}, [
|
||||
traceId,
|
||||
userPrefsReady,
|
||||
traceData,
|
||||
totalSpansCount,
|
||||
colorByField,
|
||||
previewFieldsCount,
|
||||
logTraceEvent,
|
||||
]);
|
||||
|
||||
// Frontend mode: expand all parents by default when full data arrives
|
||||
useEffect(() => {
|
||||
if (isFullDataLoaded && allSpans.length > 0) {
|
||||
const parentIds = new Set(
|
||||
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
|
||||
);
|
||||
setLocalUncollapsedNodes(parentIds);
|
||||
}
|
||||
}, [isFullDataLoaded, allSpans]);
|
||||
|
||||
// Backend mode: sync uncollapsed state from API response
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isFullDataLoaded &&
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.uncollapsedSpans
|
||||
) {
|
||||
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
|
||||
}
|
||||
}, [traceData, isFullDataLoaded]);
|
||||
|
||||
// Tracks whether we've already done the initial URL→selectedSpan handoff
|
||||
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
|
||||
useEffect(() => {
|
||||
if (deepLinkResolvedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (selectedSpanId) {
|
||||
const span = allSpans.find((s) => s.span_id === selectedSpanId);
|
||||
if (!span) {
|
||||
// Span not in the current window — wait for more data (backend
|
||||
// pagination) before marking resolved.
|
||||
return;
|
||||
}
|
||||
setSelectedSpan(span);
|
||||
} else {
|
||||
setSelectedSpan((prev) => prev ?? allSpans[0]);
|
||||
}
|
||||
deepLinkResolvedRef.current = true;
|
||||
}, [selectedSpanId, allSpans]);
|
||||
|
||||
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
|
||||
// visible. Keyed on URL `spanId`(selectedSpanId).
|
||||
useEffect(() => {
|
||||
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
|
||||
if (ancestors.size === 0) {
|
||||
return;
|
||||
}
|
||||
setLocalUncollapsedNodes((prev) => {
|
||||
// Check if all ancestors are already expanded — avoid unnecessary state update
|
||||
let allPresent = true;
|
||||
for (const id of ancestors) {
|
||||
if (!prev.has(id)) {
|
||||
allPresent = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allPresent) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = new Set(prev);
|
||||
for (const id of ancestors) {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [isFullDataLoaded, selectedSpanId, allSpans]);
|
||||
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
|
||||
|
||||
const handleCollapseChange = (key: string): void => {
|
||||
logTraceEvent(
|
||||
key === 'flame'
|
||||
? TraceDetailEvents.FlameGraphToggled
|
||||
: TraceDetailEvents.WaterfallToggled,
|
||||
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
|
||||
);
|
||||
setActiveKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
};
|
||||
|
||||
const [spanDetailVariant, handleVariantChange] = useSpanDetailVariant();
|
||||
|
||||
const RIGHT_DOCK_MIN = 480;
|
||||
const RIGHT_DOCK_MAX = 720;
|
||||
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
|
||||
|
||||
const showNoData =
|
||||
!isFetchingTraceData &&
|
||||
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
|
||||
|
||||
const hasTraceData = !showNoData && allSpans.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onHasTraceDataChange(hasTraceData);
|
||||
return (): void => onHasTraceDataChange(false);
|
||||
}, [hasTraceData, onHasTraceDataChange]);
|
||||
|
||||
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
||||
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
|
||||
const isWaterfallDocked = panelState.isOpen && isDocked;
|
||||
const showRightDock = panelState.isOpen && isRightDocked;
|
||||
|
||||
const waterfallChildren = (
|
||||
<ResizableBox
|
||||
defaultHeight={300}
|
||||
minHeight={150}
|
||||
disabled={!isWaterfallDocked}
|
||||
>
|
||||
<TraceWaterfall
|
||||
traceData={traceData}
|
||||
isFetchingTraceData={isFetchingTraceData}
|
||||
errorFetchingTraceData={errorFetchingTraceData}
|
||||
traceId={traceId || ''}
|
||||
interestedSpanId={interestedSpanId}
|
||||
setInterestedSpanId={setInterestedSpanId}
|
||||
uncollapsedNodes={uncollapsedNodes}
|
||||
isFullDataLoaded={isFullDataLoaded}
|
||||
localUncollapsedNodes={localUncollapsedNodes}
|
||||
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
</ResizableBox>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showNoData ? (
|
||||
<NoData />
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.layoutRow}>
|
||||
<div className={styles.content}>
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={styles.flameCollapse}
|
||||
items={[
|
||||
{
|
||||
key: 'flame',
|
||||
label: (
|
||||
<div className={styles.collapseLabel}>
|
||||
<span className={styles.collapseTitle}>
|
||||
Flame Graph
|
||||
{traceData?.payload?.totalSpansCount &&
|
||||
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
||||
<WarningPopover
|
||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Info
|
||||
size={16}
|
||||
color="var(--l2-foreground)"
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</WarningPopover>
|
||||
)}
|
||||
</span>
|
||||
{traceData?.payload?.totalSpansCount ? (
|
||||
<span className={styles.collapseCount}>
|
||||
<span className={styles.collapseCountItem}>
|
||||
<ChartNoAxesGantt size={13} />
|
||||
Spans: {traceData.payload.totalSpansCount}
|
||||
</span>
|
||||
<span
|
||||
className={cx(styles.collapseCountItem, {
|
||||
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
|
||||
})}
|
||||
>
|
||||
<TriangleAlert size={13} />
|
||||
Errors:{' '}
|
||||
{traceData.payload.totalErrorSpansCount ?? (
|
||||
<span className="translate-safe">{0}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
||||
<TraceFlamegraph
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
selectedSpan={selectedSpan}
|
||||
totalSpansCount={totalSpansCount}
|
||||
/>
|
||||
</ResizableBox>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={cx(styles.waterfallCollapse, {
|
||||
[styles.isDocked]: isWaterfallDocked,
|
||||
})}
|
||||
items={[
|
||||
{
|
||||
key: 'waterfall',
|
||||
label: 'Waterfall',
|
||||
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{panelState.isOpen && isDocked && (
|
||||
<div className={styles.dockedSpanDetails}>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRightDock && (
|
||||
<ResizableBox
|
||||
handle="left"
|
||||
defaultWidth={rightDockWidth}
|
||||
minWidth={RIGHT_DOCK_MIN}
|
||||
maxWidth={RIGHT_DOCK_MAX}
|
||||
onResize={setRightDockWidth}
|
||||
className={styles.rightDock}
|
||||
>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED_RIGHT}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</ResizableBox>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DIALOG}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsOverview;
|
||||
@@ -0,0 +1,5 @@
|
||||
function TraceDetailsThread(): JSX.Element {
|
||||
return <div data-testid="trace-details-thread" />;
|
||||
}
|
||||
|
||||
export default TraceDetailsThread;
|
||||
@@ -0,0 +1,6 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import TraceStoreSync from '../stores/TraceStoreSync';
|
||||
|
||||
import styles from './TraceDetailsWrapper.module.scss';
|
||||
|
||||
interface TraceDetailsWrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function TraceDetailsWrapper({
|
||||
children,
|
||||
}: TraceDetailsWrapperProps): JSX.Element {
|
||||
return (
|
||||
<TraceStoreSync>
|
||||
<div className={styles.root}>{children}</div>
|
||||
</TraceStoreSync>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsWrapper;
|
||||
@@ -30,13 +30,9 @@
|
||||
// lives here. Shares visual treatment with the waterfall hover popover
|
||||
// but is positioned `fixed` instead of anchored.
|
||||
.tooltip {
|
||||
composes: tooltipSurface from '../shared.module.scss';
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EventTooltipContent } from '../SpanHoverCard/EventTooltipContent';
|
||||
import { SpanTooltipContent } from '../SpanHoverCard/SpanHoverCard';
|
||||
import { DEFAULT_ROW_HEIGHT } from './constants';
|
||||
import { useCanvasSetup } from './hooks/useCanvasSetup';
|
||||
import { useCursorTooltipPosition } from './hooks/useCursorTooltipPosition';
|
||||
import { useFlamegraphCrosshair } from './hooks/useFlamegraphCrosshair';
|
||||
import { useFlamegraphDrag } from './hooks/useFlamegraphDrag';
|
||||
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
|
||||
@@ -200,15 +201,11 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
handleHoverMouseLeave,
|
||||
]);
|
||||
|
||||
const tooltipRef = useCursorTooltipPosition<HTMLDivElement>(tooltipContent);
|
||||
|
||||
const tooltipElement = tooltipContent
|
||||
? createPortal(
|
||||
<div
|
||||
className={styles.tooltip}
|
||||
style={{
|
||||
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
|
||||
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
|
||||
}}
|
||||
>
|
||||
<div ref={tooltipRef} className={styles.tooltip}>
|
||||
{tooltipContent.event ? (
|
||||
<EventTooltipContent
|
||||
eventName={tooltipContent.event.name}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useHistory, useLocation, useParams } from 'react-router-dom';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { Skeleton } from 'antd';
|
||||
import useGetTraceFlamegraphV3 from 'hooks/trace/useGetTraceFlamegraphV3';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
@@ -30,7 +31,6 @@ function TraceFlamegraph({
|
||||
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const history = useHistory();
|
||||
const { search } = useLocation();
|
||||
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
|
||||
urlQuery.get('spanId') || '',
|
||||
);
|
||||
@@ -42,14 +42,14 @@ function TraceFlamegraph({
|
||||
const handleSpanClick = useCallback(
|
||||
(spanId: string): void => {
|
||||
setFirstSpanAtFetchLevel(spanId);
|
||||
const searchParams = new URLSearchParams(search);
|
||||
const searchParams = getUnstableCurrentSearchParams();
|
||||
//tood: use from query params constants
|
||||
if (searchParams.get('spanId') !== spanId) {
|
||||
searchParams.set('spanId', spanId);
|
||||
history.replace({ search: searchParams.toString() });
|
||||
}
|
||||
},
|
||||
[history, search],
|
||||
[history],
|
||||
);
|
||||
|
||||
const previewFields = useTraceStore((s) => s.previewFields);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { RefObject, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
const CURSOR_OFFSET = 15;
|
||||
|
||||
interface CursorAnchor {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
function resolveAxis(cursor: number, size: number, viewport: number): number {
|
||||
const after = cursor + CURSOR_OFFSET;
|
||||
if (after + size <= viewport) {
|
||||
return after;
|
||||
}
|
||||
return Math.max(0, cursor - CURSOR_OFFSET - size);
|
||||
}
|
||||
|
||||
/** Places a fixed element beside the cursor, flipping sides when it would overflow the viewport. */
|
||||
export function useCursorTooltipPosition<T extends HTMLElement>(
|
||||
anchor: CursorAnchor | null,
|
||||
): RefObject<T> {
|
||||
const ref = useRef<T>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || !anchor) {
|
||||
return;
|
||||
}
|
||||
const left = resolveAxis(anchor.clientX, el.offsetWidth, window.innerWidth);
|
||||
const top = resolveAxis(anchor.clientY, el.offsetHeight, window.innerHeight);
|
||||
el.style.left = `${left}px`;
|
||||
el.style.top = `${top}px`;
|
||||
}, [anchor]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2) 0;
|
||||
border-top: 1px solid;
|
||||
border-image: linear-gradient(
|
||||
90deg,
|
||||
var(--l3-background-transparent),
|
||||
var(--l3-border),
|
||||
var(--l3-background-transparent)
|
||||
)
|
||||
1;
|
||||
}
|
||||
|
||||
.section:first-child {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.section:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
justify-content: space-between;
|
||||
column-gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nestedRow {
|
||||
padding-left: var(--spacing-6);
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.rowValue {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './TraceTooltip.module.scss';
|
||||
|
||||
interface TraceTooltipRowProps {
|
||||
label: string;
|
||||
value?: string;
|
||||
isNested?: boolean;
|
||||
isHeading?: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function TraceTooltipRow({
|
||||
label,
|
||||
value,
|
||||
isNested,
|
||||
isHeading,
|
||||
testId,
|
||||
}: TraceTooltipRowProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.row, isNested && styles.nestedRow)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<Typography.Text
|
||||
size="small"
|
||||
color={isHeading ? undefined : 'muted'}
|
||||
className={styles.rowLabel}
|
||||
>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
{value !== undefined && (
|
||||
<Typography.Text size="small" className={styles.rowValue}>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceTooltipRow;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import styles from './TraceTooltip.module.scss';
|
||||
|
||||
interface TraceTooltipSectionProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function TraceTooltipSection({
|
||||
children,
|
||||
}: TraceTooltipSectionProps): JSX.Element {
|
||||
return <div className={styles.section}>{children}</div>;
|
||||
}
|
||||
|
||||
export default TraceTooltipSection;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { ArrowRightFromLine, Search, X } from '@signozhq/icons';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
@@ -13,19 +13,20 @@ import {
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
|
||||
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { uniqBy } from 'lodash-es';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
import NozButton from 'pages/TraceDetailsV3/TraceDetailsHeader/NozButton';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
DataSource,
|
||||
TracesAggregatorOperator,
|
||||
} from 'types/common/queryBuilder';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
import { validateQuery } from 'utils/queryValidationUtils';
|
||||
|
||||
import { BASE_FILTER_QUERY } from './constants';
|
||||
import { useHighlightErrors } from './hooks/useHighlightErrors';
|
||||
@@ -37,7 +38,7 @@ import QueryResult from './QueryResult';
|
||||
|
||||
import styles from './Filters.module.scss';
|
||||
|
||||
function prepareQuery(expression: string, traceID: string): Query {
|
||||
function prepareQuery(filters: TagFilter, traceID: string): Query {
|
||||
return {
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
@@ -47,7 +48,23 @@ function prepareQuery(expression: string, traceID: string): Query {
|
||||
...initialQueriesMap.traces.builder.queryData[0],
|
||||
aggregateOperator: TracesAggregatorOperator.NOOP,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'asc' }],
|
||||
filter: { expression: `(${expression}) AND trace_id = '${traceID}'` },
|
||||
filters: {
|
||||
...filters,
|
||||
items: [
|
||||
...filters.items,
|
||||
{
|
||||
id: '5ab8e1cf',
|
||||
key: {
|
||||
key: 'trace_id',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
id: 'trace_id--string----true',
|
||||
},
|
||||
op: '=',
|
||||
value: traceID,
|
||||
},
|
||||
],
|
||||
},
|
||||
selectColumns: [],
|
||||
},
|
||||
],
|
||||
@@ -72,8 +89,10 @@ function Filters({
|
||||
onExpand: () => void;
|
||||
onCollapse: () => void;
|
||||
}): JSX.Element {
|
||||
const [filters, setFilters] = useState<TagFilter>(
|
||||
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
|
||||
);
|
||||
const [expression, setExpression] = useState<string>('');
|
||||
const [committedExpression, setCommittedExpression] = useState<string>('');
|
||||
const [noData, setNoData] = useState<boolean>(false);
|
||||
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
|
||||
const [currentSearchedIndex, setCurrentSearchedIndex] = useState<number>(0);
|
||||
@@ -82,12 +101,10 @@ function Filters({
|
||||
|
||||
const runQuery = useCallback(
|
||||
(value: string): void => {
|
||||
if (!validateQuery(value).isValid) {
|
||||
return;
|
||||
}
|
||||
setCommittedExpression(value);
|
||||
// Clear results when expression is empty
|
||||
if (!value.trim()) {
|
||||
const items = convertExpressionToFilters(value);
|
||||
setFilters({ items, op: 'AND' });
|
||||
// Clear results when expression produces no filters
|
||||
if (items.length === 0) {
|
||||
setFilteredSpanIds([]);
|
||||
onFilteredSpansChange?.([], false);
|
||||
setCurrentSearchedIndex(0);
|
||||
@@ -104,7 +121,7 @@ function Filters({
|
||||
expressionRef.current = value;
|
||||
// Clear results when expression is emptied
|
||||
if (!value.trim()) {
|
||||
setCommittedExpression('');
|
||||
setFilters({ items: [], op: 'AND' });
|
||||
setFilteredSpanIds([]);
|
||||
onFilteredSpansChange?.([], false);
|
||||
setCurrentSearchedIndex(0);
|
||||
@@ -130,22 +147,17 @@ function Filters({
|
||||
const handleClear = useCallback((): void => {
|
||||
setExpression('');
|
||||
expressionRef.current = '';
|
||||
setCommittedExpression('');
|
||||
setFilters({ items: [], op: 'AND' });
|
||||
setFilteredSpanIds([]);
|
||||
onFilteredSpansChange?.([], false);
|
||||
setCurrentSearchedIndex(0);
|
||||
setNoData(false);
|
||||
}, [onFilteredSpansChange]);
|
||||
|
||||
const committedPairs = useMemo(
|
||||
() => extractQueryPairs(committedExpression),
|
||||
[committedExpression],
|
||||
);
|
||||
|
||||
// Expression-based filter hooks
|
||||
const filterProps = {
|
||||
expression,
|
||||
committedPairs,
|
||||
filters,
|
||||
setExpression,
|
||||
expressionRef,
|
||||
runQuery,
|
||||
@@ -168,12 +180,11 @@ function Filters({
|
||||
const { selectedCategory, categories, handleCategoryChange } =
|
||||
useSpanCategoryFilter(filterProps);
|
||||
|
||||
const { search } = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
const handlePrevNext = useCallback(
|
||||
(index: number, spanId?: string): void => {
|
||||
const searchParams = new URLSearchParams(search);
|
||||
const searchParams = getUnstableCurrentSearchParams();
|
||||
if (spanId) {
|
||||
searchParams.set('spanId', spanId);
|
||||
} else {
|
||||
@@ -182,14 +193,12 @@ function Filters({
|
||||
|
||||
history.replace({ search: searchParams.toString() });
|
||||
},
|
||||
[filteredSpanIds, history, search],
|
||||
[filteredSpanIds, history],
|
||||
);
|
||||
|
||||
const isFilterActive = committedExpression.trim().length > 0;
|
||||
|
||||
const { isFetching, error } = useGetQueryRange(
|
||||
{
|
||||
query: prepareQuery(committedExpression, traceID),
|
||||
query: prepareQuery(filters, traceID),
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
start: startTime,
|
||||
@@ -213,11 +222,12 @@ function Filters({
|
||||
],
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
DEFAULT_ENTITY_VERSION,
|
||||
{
|
||||
queryKey: [committedExpression, traceID, startTime, endTime],
|
||||
enabled: isFilterActive,
|
||||
queryKey: [filters],
|
||||
enabled: filters.items.length > 0,
|
||||
onSuccess: (data) => {
|
||||
const isFilterActive = filters.items.length > 0;
|
||||
if (data?.payload.data.newResult.data.result[0].list) {
|
||||
const uniqueSpans = uniqBy(
|
||||
data?.payload.data.newResult.data.result[0].list,
|
||||
@@ -237,6 +247,7 @@ function Filters({
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
const isFilterActive = filters.items.length > 0;
|
||||
setNoData(false);
|
||||
setFilteredSpanIds([]);
|
||||
onFilteredSpansChange?.([], isFilterActive);
|
||||
@@ -342,6 +353,7 @@ function Filters({
|
||||
<QuerySearch
|
||||
queryData={{
|
||||
...BASE_FILTER_QUERY,
|
||||
filters,
|
||||
filter: { expression },
|
||||
}}
|
||||
onChange={handleExpressionChange}
|
||||
@@ -379,7 +391,6 @@ function Filters({
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={handleClear}
|
||||
data-testid="clear-filter"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import Filters from '../Filters';
|
||||
|
||||
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
onChange,
|
||||
onRun,
|
||||
}: {
|
||||
onChange: (value: string) => void;
|
||||
onRun: (value: string) => void;
|
||||
}): JSX.Element => (
|
||||
<input
|
||||
aria-label="filter expression"
|
||||
data-testid="filter-expression"
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
onRun(e.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
|
||||
const TRACE_ID = '0000000000000000344ded1387b08a7e';
|
||||
|
||||
const spanRows = [
|
||||
{ data: { spanID: 'span-1' } },
|
||||
{ data: { spanID: 'span-2' } },
|
||||
];
|
||||
|
||||
const getFilterExpression = (
|
||||
payload: QueryRangePayloadV5,
|
||||
): string | undefined => {
|
||||
const spec = payload.compositeQuery.queries[0]?.spec as
|
||||
| { filter?: { expression: string } }
|
||||
| undefined;
|
||||
return spec?.filter?.expression;
|
||||
};
|
||||
|
||||
const commit = (expression: string): void => {
|
||||
const input = screen.getByTestId('filter-expression');
|
||||
fireEvent.change(input, { target: { value: expression } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
};
|
||||
|
||||
describe('Trace waterfall Filters', () => {
|
||||
let payloads: QueryRangePayloadV5[] = [];
|
||||
const onFilteredSpansChange = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
payloads = [];
|
||||
onFilteredSpansChange.mockClear();
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
|
||||
payloads.push((await req.json()) as QueryRangePayloadV5);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: {
|
||||
type: 'raw',
|
||||
data: { results: [{ queryName: 'A', rows: spanRows }] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<Filters
|
||||
startTime={1700000000}
|
||||
endTime={1700003600}
|
||||
traceID={TRACE_ID}
|
||||
onFilteredSpansChange={onFilteredSpansChange}
|
||||
isExpanded
|
||||
onExpand={jest.fn()}
|
||||
onCollapse={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('sends the committed expression scoped to the trace on v5 query_range', async () => {
|
||||
fireEvent.change(screen.getByTestId('filter-expression'), {
|
||||
target: { value: "service.name = 'frontend'" },
|
||||
});
|
||||
expect(payloads).toHaveLength(0);
|
||||
|
||||
commit("service.name = 'frontend'");
|
||||
|
||||
await waitFor(() => expect(payloads).toHaveLength(1));
|
||||
expect(getFilterExpression(payloads[0])).toBe(
|
||||
`(service.name = 'frontend') AND trace_id = '${TRACE_ID}'`,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onFilteredSpansChange).toHaveBeenLastCalledWith(
|
||||
['span-1', 'span-2'],
|
||||
true,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['has_error = true', "has_error = 'true'", 'has_error = TRUE'])(
|
||||
'reflects a committed %s on the highlight errors switch',
|
||||
async (expression) => {
|
||||
const toggle = screen
|
||||
.getByTestId('highlight-errors-toggle')
|
||||
.querySelector('[role="switch"]');
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||
|
||||
commit(expression);
|
||||
|
||||
await waitFor(() => expect(payloads).toHaveLength(1));
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the highlight errors switch off for has_error != true', async () => {
|
||||
commit('has_error != true');
|
||||
|
||||
await waitFor(() => expect(payloads).toHaveLength(1));
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('highlight-errors-toggle')
|
||||
.querySelector('[role="switch"]'),
|
||||
).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('does not commit an expression that fails to parse', async () => {
|
||||
commit("service.name = 'frontend'");
|
||||
await waitFor(() => expect(payloads).toHaveLength(1));
|
||||
|
||||
commit('service.name =');
|
||||
|
||||
expect(payloads).toHaveLength(1);
|
||||
expect(onFilteredSpansChange).toHaveBeenLastCalledWith(
|
||||
['span-1', 'span-2'],
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('clearing the filter drops the results without another request', async () => {
|
||||
commit("service.name = 'frontend'");
|
||||
await waitFor(() => expect(payloads).toHaveLength(1));
|
||||
|
||||
fireEvent.click(screen.getByTestId('clear-filter'));
|
||||
|
||||
expect(onFilteredSpansChange).toHaveBeenLastCalledWith([], false);
|
||||
expect(payloads).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
import { IQueryPair } from 'types/antlrQueryTypes';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
/**
|
||||
* Shared props for expression-based filter hooks.
|
||||
* Each hook reads the current expression + the pairs parsed from the last
|
||||
* committed expression, and manipulates the expression via remove/add pattern.
|
||||
* Each hook reads the current expression + derived filters,
|
||||
* and manipulates the expression via remove/add pattern.
|
||||
*/
|
||||
export interface ExpressionFilterProps {
|
||||
expression: string;
|
||||
committedPairs: IQueryPair[];
|
||||
filters: TagFilter;
|
||||
setExpression: (expr: string) => void;
|
||||
expressionRef: MutableRefObject<string>;
|
||||
runQuery: (expr: string) => void;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { unquote } from 'utils/stringUtils';
|
||||
|
||||
import { applyExpression, ExpressionFilterProps } from './types';
|
||||
|
||||
@@ -14,19 +13,17 @@ const ERROR_KEY = 'has_error';
|
||||
export function useHighlightErrors(
|
||||
props: ExpressionFilterProps,
|
||||
): UseHighlightErrorsReturn {
|
||||
const { expression, committedPairs, setExpression, expressionRef, runQuery } =
|
||||
props;
|
||||
const { expression, filters, setExpression, expressionRef, runQuery } = props;
|
||||
|
||||
// Derive from the committed pairs (only updates after runQuery, not on every keystroke)
|
||||
// Derive from filters (only updates after runQuery, not on every keystroke)
|
||||
const isHighlightErrors = useMemo(
|
||||
() =>
|
||||
committedPairs.some(
|
||||
(pair) =>
|
||||
pair.key === ERROR_KEY &&
|
||||
pair.operator === '=' &&
|
||||
unquote(pair.value ?? '').toLowerCase() === 'true',
|
||||
filters.items.some(
|
||||
(item) =>
|
||||
item.key?.key === ERROR_KEY &&
|
||||
(item.value === true || item.value === 'true'),
|
||||
),
|
||||
[committedPairs],
|
||||
[filters],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
|
||||
@@ -50,19 +50,17 @@ interface UseSpanCategoryFilterReturn {
|
||||
export function useSpanCategoryFilter(
|
||||
props: ExpressionFilterProps,
|
||||
): UseSpanCategoryFilterReturn {
|
||||
const { expression, committedPairs, setExpression, expressionRef, runQuery } =
|
||||
props;
|
||||
const { expression, filters, setExpression, expressionRef, runQuery } = props;
|
||||
|
||||
// Derive active category from the committed pairs (only updates after runQuery)
|
||||
// Derive active category from filters (only updates after runQuery)
|
||||
const selectedCategory = useMemo((): SpanCategory => {
|
||||
const committedKeys = new Set(committedPairs.map((pair) => pair.key));
|
||||
for (const [category, key] of Object.entries(CATEGORY_KEYS)) {
|
||||
if (committedKeys.has(key)) {
|
||||
if (filters.items.some((item) => item.key?.key === key)) {
|
||||
return category as SpanCategory;
|
||||
}
|
||||
}
|
||||
return 'All';
|
||||
}, [committedPairs]);
|
||||
}, [filters]);
|
||||
|
||||
const handleCategoryChange = useCallback(
|
||||
(category: SpanCategory): void => {
|
||||
|
||||
@@ -30,12 +30,8 @@
|
||||
// loads cross-module composes through the plain CSS parser, which chokes on
|
||||
// SCSS `//` comments in the target file.
|
||||
.popover {
|
||||
composes: tooltipContent from '../../../shared.module.scss';
|
||||
--tooltip-z-index: 1000;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
@@ -217,8 +213,9 @@
|
||||
.statusCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
cursor: pointer;
|
||||
padding-left: var(--spacing-2);
|
||||
|
||||
--badge-border-radius: 2px;
|
||||
--badge-padding: 3px 6px;
|
||||
@@ -408,6 +405,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.aiSpanIcon {
|
||||
flex-shrink: 0;
|
||||
margin: 0 var(--spacing-2);
|
||||
}
|
||||
|
||||
.treeLabel {
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Inter';
|
||||
|
||||
@@ -25,15 +25,20 @@ import {
|
||||
} from '@tanstack/react-table';
|
||||
import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
|
||||
import cx from 'classnames';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import TimelineV3 from 'components/TimelineV3/TimelineV3';
|
||||
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { ChevronDown, ChevronRight, Link, ListPlus } from '@signozhq/icons';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Link,
|
||||
ListPlus,
|
||||
Sparkle,
|
||||
} from '@signozhq/icons';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
import { resolveSpanColor } from 'pages/TraceDetailsV3/utils';
|
||||
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
|
||||
@@ -44,6 +49,8 @@ import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent';
|
||||
import { SpanHoverCard } from '../../../SpanHoverCard/SpanHoverCard';
|
||||
import SpanHintBadge from '../../../SpanHintBadge/SpanHintBadge';
|
||||
import { isAiSpan } from '../../../utils/aiTraceUtils';
|
||||
import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal';
|
||||
import { IInterestedSpan } from '../../types';
|
||||
|
||||
@@ -328,14 +335,23 @@ const SpanOverview = memo(function SpanOverview({
|
||||
</span>
|
||||
|
||||
{/* Colored service dot */}
|
||||
<span
|
||||
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
|
||||
style={
|
||||
{
|
||||
'--service-dot-color': effectiveColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
{isAiSpan(span) ? (
|
||||
<Sparkle
|
||||
size={10}
|
||||
color={effectiveColor}
|
||||
fill={effectiveColor}
|
||||
className={styles.aiSpanIcon}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
|
||||
style={
|
||||
{
|
||||
'--service-dot-color': effectiveColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Span name + service name */}
|
||||
<span className={styles.treeLabel}>
|
||||
@@ -680,19 +696,19 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
setSelectedSpanToAddToFunnel(span);
|
||||
}, []);
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const handleSpanClick = useCallback(
|
||||
(span: SpanV3): void => {
|
||||
setSelectedSpan(span);
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
if (span?.span_id) {
|
||||
urlQuery.set('spanId', span?.span_id);
|
||||
params.set('spanId', span?.span_id);
|
||||
}
|
||||
|
||||
safeNavigate({ search: urlQuery.toString() }, { replace: true });
|
||||
safeNavigate({ search: params.toString() }, { replace: true });
|
||||
},
|
||||
[setSelectedSpan, urlQuery, safeNavigate],
|
||||
[setSelectedSpan, safeNavigate],
|
||||
);
|
||||
|
||||
// Left side columns using TanStack React Table (extensible for future columns)
|
||||
@@ -971,9 +987,7 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
onMouseLeave={(): void => applyHoverClass(null)}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
>
|
||||
{span.response_status_code && (
|
||||
<HttpStatusBadge statusCode={span.response_status_code} />
|
||||
)}
|
||||
<SpanHintBadge span={span} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import React from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import {
|
||||
__resetSearchParamsGetter,
|
||||
__setSearchParamsGetterForTest,
|
||||
} from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
// Local identity-proxy mock for this module so `styles.foo` resolves to
|
||||
// `'foo'` in test assertions. The global `__mocks__/cssMock.ts` stays as
|
||||
@@ -226,6 +230,11 @@ describe('Span Click User Flows', () => {
|
||||
jest.clearAllMocks();
|
||||
// Clear all URL parameters
|
||||
Array.from(mockUrlQuery.keys()).forEach((key) => mockUrlQuery.delete(key));
|
||||
__setSearchParamsGetterForTest(() => new URLSearchParams(mockUrlQuery));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetSearchParamsGetter();
|
||||
});
|
||||
|
||||
it('clicking span updates URL with spanId parameter', async () => {
|
||||
@@ -260,7 +269,6 @@ describe('Span Click User Flows', () => {
|
||||
) as HTMLElement;
|
||||
await user.click(spanElement);
|
||||
|
||||
expect(mockUrlQuery.get('spanId')).toBe('span-1');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
{
|
||||
search: expect.stringContaining('spanId=span-1'),
|
||||
@@ -428,10 +436,6 @@ describe('Span Click User Flows', () => {
|
||||
await user.click(spanElement);
|
||||
|
||||
// Verify existing parameters are preserved and spanId is added
|
||||
expect(mockUrlQuery.get('existingParam')).toBe('existingValue');
|
||||
expect(mockUrlQuery.get('anotherParam')).toBe('anotherValue');
|
||||
expect(mockUrlQuery.get('spanId')).toBe('span-1');
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
{
|
||||
search: expect.stringMatching(
|
||||
|
||||
@@ -53,3 +53,8 @@ export const COLOR_BY_FIELDS: TelemetryFieldKey[] = COLOR_BY_OPTIONS.map(
|
||||
);
|
||||
|
||||
export const DEFAULT_COLOR_BY_FIELD = COLOR_BY_FIELDS[0];
|
||||
|
||||
export enum TraceDetailsTab {
|
||||
Overview = 'overview',
|
||||
Thread = 'thread',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||
|
||||
/** Span details position, persisted under the same key Overview uses. */
|
||||
export function useSpanDetailVariant(): [
|
||||
SpanDetailVariant,
|
||||
(variant: SpanDetailVariant) => void,
|
||||
] {
|
||||
const [variant, setVariant] = useState<SpanDetailVariant>(
|
||||
() =>
|
||||
(getLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
|
||||
);
|
||||
|
||||
const changeVariant = useCallback((next: SpanDetailVariant): void => {
|
||||
setLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION, next);
|
||||
setVariant(next);
|
||||
}, []);
|
||||
|
||||
return [variant, changeVariant];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { parseAsStringEnum, useQueryState, UseQueryStateReturn } from 'nuqs';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
|
||||
export function useTraceDetailsTab(): UseQueryStateReturn<
|
||||
TraceDetailsTab,
|
||||
TraceDetailsTab
|
||||
> {
|
||||
return useQueryState(
|
||||
'tab',
|
||||
parseAsStringEnum(Object.values(TraceDetailsTab)).withDefault(
|
||||
TraceDetailsTab.Overview,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,87 +1,18 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
ChartNoAxesGantt,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
} from '@signozhq/icons';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { Collapse } from 'antd';
|
||||
import { useDetailsPanel } from 'components/DetailsPanel';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
import { useTraceDetailsStripInfo } from './useTraceDetailsStripInfo';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
|
||||
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
|
||||
import NoData from './NoData/NoData';
|
||||
import TraceStoreSync from './stores/TraceStoreSync';
|
||||
import { useTraceStore } from './stores/traceStore';
|
||||
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
|
||||
import { TraceDetailsTab } from './constants';
|
||||
import { useTraceDetailsTab } from './hooks/useTraceDetailsTab';
|
||||
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
|
||||
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
|
||||
import TraceWaterfall from './TraceWaterfall/TraceWaterfall';
|
||||
import { IInterestedSpan } from './TraceWaterfall/types';
|
||||
import { getAncestorSpanIds } from './TraceWaterfall/utils';
|
||||
import { getAvailableColorByFieldNames } from './utils';
|
||||
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './TraceDetailsV3.module.scss';
|
||||
|
||||
// Lucide chevrons for the flame/waterfall accordion headers, matching the
|
||||
// span-tree chevrons in the waterfall.
|
||||
function renderPanelExpandIcon({
|
||||
isActive,
|
||||
}: {
|
||||
isActive?: boolean;
|
||||
}): JSX.Element {
|
||||
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
|
||||
}
|
||||
import TraceDetailsOverview from './TraceDetailsOverview/TraceDetailsOverview';
|
||||
import TraceDetailsThread from './TraceDetailsThread/TraceDetailsThread';
|
||||
import TraceDetailsWrapper from './TraceDetailsWrapper/TraceDetailsWrapper';
|
||||
|
||||
function TraceDetailsV3(): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
|
||||
() => ({
|
||||
spanId: urlQuery.get('spanId') || '',
|
||||
isUncollapsed: urlQuery.get('spanId') !== '',
|
||||
}),
|
||||
);
|
||||
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
|
||||
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
|
||||
const [tab] = useTraceDetailsTab();
|
||||
const isOverview = tab === TraceDetailsTab.Overview;
|
||||
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
|
||||
const [isFilterActive, setIsFilterActive] = useState(false);
|
||||
|
||||
const selectedSpanId = urlQuery.get('spanId') || undefined;
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
||||
// Tracks which traceId the load event already fired for, so navigating
|
||||
// between traces (the route component stays mounted) re-fires it once each.
|
||||
const dataLoadedFiredForRef = useRef('');
|
||||
const colorByField = useTraceStore((s) => s.colorByField);
|
||||
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
|
||||
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
|
||||
|
||||
const handleSpanDetailsClose = useCallback((): void => {
|
||||
urlQuery.delete('spanId');
|
||||
safeNavigate({ search: urlQuery.toString() }, { replace: true });
|
||||
}, [urlQuery, safeNavigate]);
|
||||
const [hasTraceData, setHasTraceData] = useState(false);
|
||||
|
||||
const handleFilteredSpansChange = useCallback(
|
||||
(spanIds: string[], isActive: boolean): void => {
|
||||
@@ -91,421 +22,30 @@ function TraceDetailsV3(): JSX.Element {
|
||||
[],
|
||||
);
|
||||
|
||||
const panelState = useDetailsPanel({
|
||||
entityId: selectedSpanId,
|
||||
onClose: handleSpanDetailsClose,
|
||||
});
|
||||
|
||||
const allSpansRef = useRef<SpanV3[]>([]);
|
||||
const deepLinkResolvedRef = useRef(false);
|
||||
|
||||
// Refetch only when the URL target isn't already loaded. Keeps row clicks
|
||||
// and other in-window URL navigation from triggering a backend window slide.
|
||||
// The filter query lives in Filters' local state, which unmounts with the
|
||||
// Overview tab; drop its result too so both start empty on return.
|
||||
useEffect(() => {
|
||||
const spanId = urlQuery.get('spanId') || '';
|
||||
if (!spanId) {
|
||||
return;
|
||||
if (!isOverview) {
|
||||
handleFilteredSpansChange([], false);
|
||||
}
|
||||
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
|
||||
if (idx !== -1) {
|
||||
setSelectedSpan(allSpansRef.current[idx]);
|
||||
return;
|
||||
}
|
||||
setInterestedSpanId({ spanId, isUncollapsed: true });
|
||||
}, [urlQuery]);
|
||||
|
||||
// Once all spans are loaded (frontend mode), freeze query params so
|
||||
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
|
||||
const fullDataLoadedRef = useRef(false);
|
||||
const frozenParamsRef = useRef({
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
});
|
||||
|
||||
const queryParams = fullDataLoadedRef.current
|
||||
? frozenParamsRef.current
|
||||
: {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
|
||||
const {
|
||||
data: traceData,
|
||||
isFetching: isFetchingTraceData,
|
||||
error: errorFetchingTraceData,
|
||||
} = useGetTraceV4({
|
||||
traceId,
|
||||
uncollapsedSpans: queryParams.uncollapsedSpans,
|
||||
selectedSpanId: queryParams.selectedSpanId,
|
||||
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
|
||||
});
|
||||
|
||||
const allSpans = traceData?.payload?.spans || [];
|
||||
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
|
||||
const totalErrorSpansCount = traceData?.payload?.totalErrorSpansCount || 0;
|
||||
|
||||
useTraceDetailsStripInfo({ totalSpansCount, totalErrorSpansCount });
|
||||
|
||||
const isFullDataLoaded =
|
||||
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
|
||||
|
||||
// Color-by options, gated on fields in loaded spans. Resource attrs are
|
||||
// trace-wide, so any window has the full set — no need to accumulate.
|
||||
const availableColorByFields = useMemo(() => {
|
||||
const spans = traceData?.payload?.spans;
|
||||
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
|
||||
}, [traceData?.payload?.spans]);
|
||||
|
||||
// Lock the ref once we confirm all data is loaded
|
||||
if (isFullDataLoaded && !fullDataLoadedRef.current) {
|
||||
fullDataLoadedRef.current = true;
|
||||
frozenParamsRef.current = {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
allSpansRef.current = allSpans;
|
||||
}, [allSpans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!traceId ||
|
||||
dataLoadedFiredForRef.current === traceId ||
|
||||
!userPrefsReady
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const payload = traceData?.payload;
|
||||
if (!payload?.spans?.length) {
|
||||
return;
|
||||
}
|
||||
dataLoadedFiredForRef.current = traceId;
|
||||
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
[TraceDetailEventKeys.NumServices]: numServices,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
payload.endTimestampMillis - payload.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
|
||||
[TraceDetailEventKeys.FlamegraphSampled]:
|
||||
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
|
||||
[TraceDetailEventKeys.SpanPanelVariant]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
|
||||
SpanDetailVariant.DOCKED_RIGHT,
|
||||
[TraceDetailEventKeys.ColorByField]: colorByField.name,
|
||||
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
|
||||
});
|
||||
}, [
|
||||
traceId,
|
||||
userPrefsReady,
|
||||
traceData,
|
||||
totalSpansCount,
|
||||
colorByField,
|
||||
previewFieldsCount,
|
||||
logTraceEvent,
|
||||
]);
|
||||
|
||||
// Frontend mode: expand all parents by default when full data arrives
|
||||
useEffect(() => {
|
||||
if (isFullDataLoaded && allSpans.length > 0) {
|
||||
const parentIds = new Set(
|
||||
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
|
||||
);
|
||||
setLocalUncollapsedNodes(parentIds);
|
||||
}
|
||||
}, [isFullDataLoaded, allSpans]);
|
||||
|
||||
// Backend mode: sync uncollapsed state from API response
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isFullDataLoaded &&
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.uncollapsedSpans
|
||||
) {
|
||||
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
|
||||
}
|
||||
}, [traceData, isFullDataLoaded]);
|
||||
|
||||
// Tracks whether we've already done the initial URL→selectedSpan handoff
|
||||
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
|
||||
useEffect(() => {
|
||||
if (deepLinkResolvedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (selectedSpanId) {
|
||||
const span = allSpans.find((s) => s.span_id === selectedSpanId);
|
||||
if (!span) {
|
||||
// Span not in the current window — wait for more data (backend
|
||||
// pagination) before marking resolved.
|
||||
return;
|
||||
}
|
||||
setSelectedSpan(span);
|
||||
} else {
|
||||
setSelectedSpan((prev) => prev ?? allSpans[0]);
|
||||
}
|
||||
deepLinkResolvedRef.current = true;
|
||||
}, [selectedSpanId, allSpans]);
|
||||
|
||||
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
|
||||
// visible. Keyed on URL `spanId`(selectedSpanId).
|
||||
useEffect(() => {
|
||||
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
|
||||
if (ancestors.size === 0) {
|
||||
return;
|
||||
}
|
||||
setLocalUncollapsedNodes((prev) => {
|
||||
// Check if all ancestors are already expanded — avoid unnecessary state update
|
||||
let allPresent = true;
|
||||
for (const id of ancestors) {
|
||||
if (!prev.has(id)) {
|
||||
allPresent = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allPresent) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = new Set(prev);
|
||||
for (const id of ancestors) {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [isFullDataLoaded, selectedSpanId, allSpans]);
|
||||
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
|
||||
|
||||
const handleCollapseChange = (key: string): void => {
|
||||
logTraceEvent(
|
||||
key === 'flame'
|
||||
? TraceDetailEvents.FlameGraphToggled
|
||||
: TraceDetailEvents.WaterfallToggled,
|
||||
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
|
||||
);
|
||||
setActiveKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
};
|
||||
|
||||
const [spanDetailVariant, setSpanDetailVariant] = useState<SpanDetailVariant>(
|
||||
() =>
|
||||
(getLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
|
||||
);
|
||||
|
||||
const RIGHT_DOCK_MIN = 480;
|
||||
const RIGHT_DOCK_MAX = 720;
|
||||
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
|
||||
|
||||
const handleVariantChange = useCallback(
|
||||
(newVariant: SpanDetailVariant): void => {
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
newVariant,
|
||||
);
|
||||
setSpanDetailVariant(newVariant);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const showNoData =
|
||||
!isFetchingTraceData &&
|
||||
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
|
||||
|
||||
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
||||
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
|
||||
const isWaterfallDocked = panelState.isOpen && isDocked;
|
||||
const showRightDock = panelState.isOpen && isRightDocked;
|
||||
|
||||
const waterfallChildren = (
|
||||
<ResizableBox
|
||||
defaultHeight={300}
|
||||
minHeight={150}
|
||||
disabled={!isWaterfallDocked}
|
||||
>
|
||||
<TraceWaterfall
|
||||
traceData={traceData}
|
||||
isFetchingTraceData={isFetchingTraceData}
|
||||
errorFetchingTraceData={errorFetchingTraceData}
|
||||
traceId={traceId || ''}
|
||||
interestedSpanId={interestedSpanId}
|
||||
setInterestedSpanId={setInterestedSpanId}
|
||||
uncollapsedNodes={uncollapsedNodes}
|
||||
isFullDataLoaded={isFullDataLoaded}
|
||||
localUncollapsedNodes={localUncollapsedNodes}
|
||||
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
</ResizableBox>
|
||||
);
|
||||
}, [isOverview, handleFilteredSpansChange]);
|
||||
|
||||
return (
|
||||
<TraceStoreSync availableColorByFields={availableColorByFields}>
|
||||
<div className={styles.root}>
|
||||
<TraceDetailsHeader
|
||||
onFilteredSpansChange={handleFilteredSpansChange}
|
||||
showTraceDetailsHeaderOptions={
|
||||
!!traceData?.payload?.spans?.length && !showNoData
|
||||
}
|
||||
<TraceDetailsWrapper>
|
||||
<TraceDetailsHeader
|
||||
onFilteredSpansChange={handleFilteredSpansChange}
|
||||
showTraceDetailsHeaderOptions={hasTraceData}
|
||||
/>
|
||||
{isOverview ? (
|
||||
<TraceDetailsOverview
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
onHasTraceDataChange={setHasTraceData}
|
||||
/>
|
||||
|
||||
{showNoData ? (
|
||||
<NoData />
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.layoutRow}>
|
||||
<div className={styles.content}>
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={styles.flameCollapse}
|
||||
items={[
|
||||
{
|
||||
key: 'flame',
|
||||
label: (
|
||||
<div className={styles.collapseLabel}>
|
||||
<span className={styles.collapseTitle}>
|
||||
Flame Graph
|
||||
{traceData?.payload?.totalSpansCount &&
|
||||
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
||||
<WarningPopover
|
||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Info
|
||||
size={16}
|
||||
color="var(--l2-foreground)"
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</WarningPopover>
|
||||
)}
|
||||
</span>
|
||||
{traceData?.payload?.totalSpansCount ? (
|
||||
<span className={styles.collapseCount}>
|
||||
<span className={styles.collapseCountItem}>
|
||||
<ChartNoAxesGantt size={13} />
|
||||
Spans: {traceData.payload.totalSpansCount}
|
||||
</span>
|
||||
<span
|
||||
className={cx(styles.collapseCountItem, {
|
||||
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
|
||||
})}
|
||||
>
|
||||
<TriangleAlert size={13} />
|
||||
Errors:{' '}
|
||||
{traceData.payload.totalErrorSpansCount ?? (
|
||||
<span className="translate-safe">{0}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
||||
<TraceFlamegraph
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
selectedSpan={selectedSpan}
|
||||
totalSpansCount={totalSpansCount}
|
||||
/>
|
||||
</ResizableBox>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={cx(styles.waterfallCollapse, {
|
||||
[styles.isDocked]: isWaterfallDocked,
|
||||
})}
|
||||
items={[
|
||||
{
|
||||
key: 'waterfall',
|
||||
label: 'Waterfall',
|
||||
children: activeKeys.includes('waterfall')
|
||||
? waterfallChildren
|
||||
: null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{panelState.isOpen && isDocked && (
|
||||
<div className={styles.dockedSpanDetails}>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRightDock && (
|
||||
<ResizableBox
|
||||
handle="left"
|
||||
defaultWidth={rightDockWidth}
|
||||
minWidth={RIGHT_DOCK_MIN}
|
||||
maxWidth={RIGHT_DOCK_MAX}
|
||||
onResize={setRightDockWidth}
|
||||
className={styles.rightDock}
|
||||
>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED_RIGHT}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</ResizableBox>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DIALOG}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TraceStoreSync>
|
||||
) : (
|
||||
<TraceDetailsThread />
|
||||
)}
|
||||
</TraceDetailsWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
35
frontend/src/pages/TraceDetailsV3/shared.module.scss
Normal file
35
frontend/src/pages/TraceDetailsV3/shared.module.scss
Normal file
@@ -0,0 +1,35 @@
|
||||
.tooltipBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
letter-spacing: var(--paragraph-small-400-letter-spacing);
|
||||
color: var(--l1-foreground-hover);
|
||||
}
|
||||
|
||||
.tooltipTitle {
|
||||
font-family: 'SF Mono', 'Geist Mono', monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tooltipSurface {
|
||||
background-color: color-mix(in srgb, var(--l3-background) 60%, transparent);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--spacing-4);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.tooltipContent {
|
||||
--tooltip-background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 60%,
|
||||
transparent
|
||||
);
|
||||
--tooltip-border-color: var(--l2-border);
|
||||
--tooltip-border-radius: var(--radius-2);
|
||||
--tooltip-padding: var(--spacing-4);
|
||||
--tooltip-box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
/* Above the docked span details FloatingPanel (z-index 999). */
|
||||
--tooltip-z-index: 1000;
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
@@ -4,36 +4,28 @@ import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import {
|
||||
setTraceStoreAvailableColorByFields,
|
||||
setTraceStoreCallbacks,
|
||||
setTraceStoreUserPreferences,
|
||||
} from './traceStore';
|
||||
|
||||
interface TraceStoreSyncProps {
|
||||
availableColorByFields: string[] | undefined;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges React-managed inputs (`availableColorByFields`, `userPreferences`
|
||||
* from AppContext, and the user-pref mutation hook) into the Zustand store.
|
||||
* Bridges React-managed inputs (`userPreferences` from AppContext and the
|
||||
* user-pref mutation hook) into the Zustand store. `availableColorByFields`
|
||||
* is pushed by TraceDetailsOverview, which owns the span data.
|
||||
*
|
||||
* Renders nothing until `userPreferences` resolves so the flamegraph never
|
||||
* paints with the default colour first and then swaps to the user's
|
||||
* persisted choice. AppProvider fires the prefs query as soon as the user
|
||||
* is logged in, so this gate is usually already settled by mount time.
|
||||
*/
|
||||
function TraceStoreSync({
|
||||
availableColorByFields,
|
||||
children,
|
||||
}: TraceStoreSyncProps): JSX.Element | null {
|
||||
function TraceStoreSync({ children }: TraceStoreSyncProps): JSX.Element | null {
|
||||
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
||||
const { mutate: mutateUserPreference } = useMutation(updateUserPreferenceAPI);
|
||||
|
||||
useEffect(() => {
|
||||
setTraceStoreAvailableColorByFields(availableColorByFields);
|
||||
}, [availableColorByFields]);
|
||||
|
||||
useEffect(() => {
|
||||
setTraceStoreUserPreferences(userPreferences ?? null);
|
||||
}, [userPreferences]);
|
||||
|
||||
@@ -37,7 +37,7 @@ interface TraceStoreState {
|
||||
availableColorByOptions: ColorByOption[];
|
||||
previewFields: TelemetryFieldKey[];
|
||||
|
||||
// --- Setters used only by TraceStoreSync ---
|
||||
// --- Setters used only by TraceStoreSync and TraceDetailsOverview ---
|
||||
setAvailableColorByFields: (fieldNames: string[] | undefined) => void;
|
||||
setUserPreferences: (userPreferences: UserPreference[] | null) => void;
|
||||
setCallbacks: (callbacks: {
|
||||
|
||||
@@ -46,6 +46,8 @@ import {
|
||||
traceDetailFieldValues,
|
||||
traceFlamegraphResponse,
|
||||
traceSummaryResponse,
|
||||
TRACE_SCENARIOS,
|
||||
type TraceScenario,
|
||||
traceWaterfallResponse,
|
||||
} from './__story_mockdata__/traceDetails';
|
||||
|
||||
@@ -103,6 +105,13 @@ const spanLogScopeOf = (body: QueryRangeRequestV5): SpanLogScope => {
|
||||
|
||||
export const traceDetailsMocks = defineStoryMocks({
|
||||
controls: {
|
||||
scenario: choiceControl<TraceScenario>('Scenario', {
|
||||
group: TRACE,
|
||||
description:
|
||||
'The spans the trace is built from: a checkout, one span per HTTP status badge, or an agent run with LLM, tool and agent spans and the AI totals in the header.',
|
||||
options: TRACE_SCENARIOS,
|
||||
value: 'checkout',
|
||||
}),
|
||||
spans: countControl('Spans', {
|
||||
group: TRACE,
|
||||
description:
|
||||
@@ -151,6 +160,7 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
},
|
||||
handlers: (values, response) => {
|
||||
const trace = {
|
||||
scenario: values.scenario,
|
||||
spans: values.spans,
|
||||
errors: values.errors,
|
||||
traceStart: traceStartOf(),
|
||||
@@ -288,7 +298,7 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
},
|
||||
config: (values) => ({
|
||||
route: values.spanPanel
|
||||
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.spans))}`
|
||||
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.scenario, values.spans))}`
|
||||
: `/trace/${STORY_TRACE_ID}`,
|
||||
}),
|
||||
effect: (values) => {
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { STORY_TRACE_ID } from './__story_mockdata__/traceDetails';
|
||||
import {
|
||||
focusSpanIndex,
|
||||
scenarioSpanCount,
|
||||
spanIdAt,
|
||||
STORY_TRACE_ID,
|
||||
} from './__story_mockdata__/traceDetails';
|
||||
import {
|
||||
openRowMenu,
|
||||
startDownload,
|
||||
@@ -96,6 +101,40 @@ export const BottomStripLoading: Story = {
|
||||
args: { bottomStrip: true, dataState: 'loading' },
|
||||
};
|
||||
|
||||
/**
|
||||
* One span per HTTP status badge the waterfall draws: informational, success,
|
||||
* redirect, client and server errors, and a span with no status code and so no
|
||||
* badge.
|
||||
*/
|
||||
export const StatusCodes: Story = {
|
||||
args: {
|
||||
scenario: 'status-codes',
|
||||
spans: scenarioSpanCount('status-codes'),
|
||||
spanPanel: false,
|
||||
},
|
||||
};
|
||||
|
||||
const AI_SPANS = scenarioSpanCount('ai');
|
||||
|
||||
/**
|
||||
* An agent run: LLM, tool and agent badges with sparkle icons in the waterfall,
|
||||
* token and cost totals in the header, the span panel on an LLM call with its
|
||||
* tokens and cost, and that span's hover card open on its usage breakdown.
|
||||
*/
|
||||
export const AiTrace: Story = {
|
||||
args: { scenario: 'ai', spans: AI_SPANS },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const row = await within(canvasElement).findByTestId(
|
||||
`cell-0-${spanIdAt(focusSpanIndex('ai', AI_SPANS))}`,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await fireEvent.mouseOver(row);
|
||||
await screen.findByText('Usage Breakdown', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns on Highlight errors, which writes `has_error = true` into the filter and
|
||||
* expands the bar to show it.
|
||||
|
||||
@@ -23,6 +23,10 @@ interface SpanTemplate {
|
||||
name: string;
|
||||
kind: string;
|
||||
failing?: boolean;
|
||||
/** The span the panel opens on when nothing fails. */
|
||||
focus?: boolean;
|
||||
/** `response_status_code`; failing spans default to 503, the rest to 200. */
|
||||
statusCode?: string;
|
||||
attributes: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
@@ -123,6 +127,145 @@ const SPAN_TEMPLATES: SpanTemplate[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** One span per badge colour, plus a span with no status code and so no badge. */
|
||||
const STATUS_CODE_TEMPLATES: SpanTemplate[] = [
|
||||
['200', 'GET /status/ok'],
|
||||
['101', 'GET /ws/upgrade'],
|
||||
['201', 'POST /status/created'],
|
||||
['204', 'DELETE /status/no-content'],
|
||||
['301', 'GET /status/moved'],
|
||||
['304', 'GET /status/not-modified'],
|
||||
['400', 'POST /status/bad-request'],
|
||||
['401', 'GET /status/unauthorized'],
|
||||
['404', 'GET /status/not-found'],
|
||||
['429', 'GET /status/rate-limited'],
|
||||
['500', 'GET /status/internal-error'],
|
||||
['502', 'GET /status/bad-gateway'],
|
||||
['503', 'GET /status/unavailable'],
|
||||
['504', 'GET /status/timeout'],
|
||||
['', 'internal.compute'],
|
||||
].map(
|
||||
([statusCode, name]): SpanTemplate => ({
|
||||
service: 'status-api',
|
||||
name,
|
||||
kind: statusCode ? 'Server' : 'Internal',
|
||||
failing: Number(statusCode) >= 500,
|
||||
statusCode,
|
||||
attributes: statusCode
|
||||
? {
|
||||
'http.method': name.split(' ')[0],
|
||||
'http.status_code': Number(statusCode),
|
||||
}
|
||||
: {},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* An agent run: agent, LLM and tool spans, each badged by kind. The LLM spans
|
||||
* cover cache counted inside input (OpenAI), cache counted beside it
|
||||
* (Anthropic), reasoning, and an embedding with input only.
|
||||
*/
|
||||
const AI_TEMPLATES: SpanTemplate[] = [
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'invoke_agent support-agent',
|
||||
kind: 'Internal',
|
||||
attributes: { 'gen_ai.agent.name': 'support-agent' },
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'chat gpt-4o',
|
||||
kind: 'Client',
|
||||
attributes: {
|
||||
'gen_ai.request.model': 'gpt-4o-2024-08-06',
|
||||
'gen_ai.usage.input_tokens': 1840,
|
||||
'gen_ai.usage.output_tokens': 212,
|
||||
'gen_ai.usage.cache_read.input_tokens': 1024,
|
||||
'gen_ai.usage.cache_creation.input_tokens': 0,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.00566,
|
||||
},
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'execute_tool search_docs',
|
||||
kind: 'Internal',
|
||||
attributes: { 'gen_ai.tool.name': 'search_docs' },
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'chat claude-sonnet-4',
|
||||
kind: 'Client',
|
||||
focus: true,
|
||||
attributes: {
|
||||
'gen_ai.request.model': 'claude-sonnet-4-20250514',
|
||||
'gen_ai.usage.input_tokens': 86,
|
||||
'gen_ai.usage.output_tokens': 640,
|
||||
'gen_ai.usage.cache_read.input_tokens': 4200,
|
||||
'gen_ai.usage.cache_creation.input_tokens': 1300,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.01853,
|
||||
},
|
||||
},
|
||||
{
|
||||
service: 'docs-api',
|
||||
name: 'GET /docs/search',
|
||||
kind: 'Server',
|
||||
attributes: {
|
||||
'http.method': 'GET',
|
||||
'http.route': '/docs/search',
|
||||
'http.status_code': 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'execute_tool fetch_ticket',
|
||||
kind: 'Internal',
|
||||
failing: true,
|
||||
attributes: { 'gen_ai.tool.name': 'fetch_ticket' },
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'invoke_agent triage-agent',
|
||||
kind: 'Internal',
|
||||
attributes: { 'gen_ai.agent.name': 'triage-agent' },
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'embeddings text-embedding-3-small',
|
||||
kind: 'Client',
|
||||
attributes: {
|
||||
'gen_ai.request.model': 'text-embedding-3-small',
|
||||
'gen_ai.usage.input_tokens': 312,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.00000624,
|
||||
},
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'chat o3-mini',
|
||||
kind: 'Client',
|
||||
attributes: {
|
||||
'gen_ai.request.model': 'o3-mini-2025-01-31',
|
||||
'gen_ai.usage.input_tokens': 920,
|
||||
'gen_ai.usage.output_tokens': 1480,
|
||||
'gen_ai.usage.reasoning.output_tokens': 1152,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.007524,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const TRACE_SCENARIOS = ['checkout', 'status-codes', 'ai'] as const;
|
||||
|
||||
export type TraceScenario = (typeof TRACE_SCENARIOS)[number];
|
||||
|
||||
const SCENARIO_TEMPLATES: Record<TraceScenario, SpanTemplate[]> = {
|
||||
checkout: SPAN_TEMPLATES,
|
||||
'status-codes': STATUS_CODE_TEMPLATES,
|
||||
ai: AI_TEMPLATES,
|
||||
};
|
||||
|
||||
/** Spans it takes for the scenario to show every template once. */
|
||||
export const scenarioSpanCount = (scenario: TraceScenario): number =>
|
||||
SCENARIO_TEMPLATES[scenario].length;
|
||||
|
||||
const TRACE_SERVICES = [
|
||||
...new Set(SPAN_TEMPLATES.map(({ service }) => service)),
|
||||
];
|
||||
@@ -146,6 +289,7 @@ interface BuiltSpan {
|
||||
}
|
||||
|
||||
interface TraceOptions {
|
||||
scenario: TraceScenario;
|
||||
spans: number;
|
||||
errors: boolean;
|
||||
/** Epoch milliseconds the root span starts at. */
|
||||
@@ -156,24 +300,33 @@ export const spanIdAt = (index: number): string =>
|
||||
`b17e${index.toString(16).padStart(12, '0')}`;
|
||||
|
||||
/**
|
||||
* The span the story opens the panel on: the first failing one, so its events
|
||||
* and error styling are on screen, and the root when nothing fails.
|
||||
* The span the story opens the panel on: the scenario's focus span, else the
|
||||
* first failing one, so its events and error styling are on screen, and the
|
||||
* root when neither is in the trace.
|
||||
*/
|
||||
export const focusSpanIndex = (spans: number): number => {
|
||||
const failing = SPAN_TEMPLATES.findIndex(({ failing }) => failing);
|
||||
export const focusSpanIndex = (
|
||||
scenario: TraceScenario,
|
||||
spans: number,
|
||||
): number => {
|
||||
const templates = SCENARIO_TEMPLATES[scenario];
|
||||
const focus = templates.findIndex((template) => template.focus);
|
||||
const failing = templates.findIndex((template) => template.failing);
|
||||
const index = focus >= 0 ? focus : failing;
|
||||
|
||||
return failing >= 0 && failing < spans ? failing : 0;
|
||||
return index >= 0 && index < spans ? index : 0;
|
||||
};
|
||||
|
||||
const buildSpans = ({
|
||||
scenario,
|
||||
spans,
|
||||
errors,
|
||||
traceStart,
|
||||
}: TraceOptions): BuiltSpan[] => {
|
||||
const built: BuiltSpan[] = [];
|
||||
const templates = SCENARIO_TEMPLATES[scenario];
|
||||
|
||||
for (let index = 0; index < spans; index += 1) {
|
||||
const template = SPAN_TEMPLATES[index % SPAN_TEMPLATES.length];
|
||||
const template = templates[index % templates.length];
|
||||
const parent = index === 0 ? undefined : built[parentIndexOf(index)];
|
||||
// Children fit inside their parent's window, second child after the first,
|
||||
// so no span outlives its parent and no two siblings overlap.
|
||||
@@ -257,7 +410,8 @@ const waterfallSpan = (span: BuiltSpan): SpantypesWaterfallSpanDTO => ({
|
||||
status_code: span.hasError ? 2 : 1,
|
||||
status_code_string: span.hasError ? 'Error' : 'Ok',
|
||||
status_message: span.hasError ? 'upstream unavailable' : '',
|
||||
response_status_code: span.hasError ? '503' : '200',
|
||||
response_status_code:
|
||||
span.template.statusCode ?? (span.hasError ? '503' : '200'),
|
||||
has_children: span.hasChildren,
|
||||
sub_tree_node_count: span.subTreeNodeCount,
|
||||
level: span.level,
|
||||
@@ -304,6 +458,36 @@ export const traceWaterfallResponse = (
|
||||
};
|
||||
};
|
||||
|
||||
const numberAttribute = (span: BuiltSpan, key: string): number =>
|
||||
Number(span.template.attributes[key] ?? 0);
|
||||
|
||||
/** The trace's AI totals, summed over its spans the way the backend does. */
|
||||
const traceAiSummary = (
|
||||
spans: BuiltSpan[],
|
||||
): GetTraceSummary200['data']['ai'] => {
|
||||
const aiSpans = spans.filter(({ template }) =>
|
||||
Object.keys(template.attributes).some((key) => key.includes('gen_ai.')),
|
||||
);
|
||||
|
||||
if (aiSpans.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sum = (key: string): number =>
|
||||
aiSpans.reduce((total, span) => total + numberAttribute(span, key), 0);
|
||||
|
||||
return {
|
||||
tokens: {
|
||||
input: sum('gen_ai.usage.input_tokens'),
|
||||
output: sum('gen_ai.usage.output_tokens'),
|
||||
cacheRead: sum('gen_ai.usage.cache_read.input_tokens'),
|
||||
cacheWrite: sum('gen_ai.usage.cache_creation.input_tokens'),
|
||||
reasoning: sum('gen_ai.usage.reasoning.output_tokens'),
|
||||
},
|
||||
totalCost: sum('signoz.gen_ai.usage.tokens.cost'),
|
||||
};
|
||||
};
|
||||
|
||||
export const traceSummaryResponse = (
|
||||
options: TraceOptions & { missingSpans: boolean },
|
||||
): GetTraceSummary200 => {
|
||||
@@ -321,6 +505,7 @@ export const traceSummaryResponse = (
|
||||
totalSpansCount: spans.length,
|
||||
totalErrorSpansCount: spans.filter(({ hasError }) => hasError).length,
|
||||
hasMissingSpans: options.missingSpans,
|
||||
ai: traceAiSummary(spans),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -472,7 +657,7 @@ export const spanLogRows = (
|
||||
): RawRow[] => {
|
||||
const step = ROOT_DURATION_MS / Math.max(count, 1);
|
||||
const spans = buildSpans(options);
|
||||
const focus = spans[focusSpanIndex(options.spans)];
|
||||
const focus = spans[focusSpanIndex(options.scenario, options.spans)];
|
||||
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const at = options.traceStart + SCOPE_OFFSET_MS[scope] + index * step;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import {
|
||||
AiSpanKind,
|
||||
getAiSpanKind,
|
||||
getTotalInputTokens,
|
||||
isAiSpan,
|
||||
} from '../aiTraceUtils';
|
||||
|
||||
const spanWith = (attributes: Record<string, string | number>): SpanV3 =>
|
||||
({ attributes, resource: {} }) as unknown as SpanV3;
|
||||
|
||||
describe('getAiSpanKind', () => {
|
||||
it.each<[Record<string, string | number>, AiSpanKind]>([
|
||||
[{ 'gen_ai.request.model': 'gpt-4o' }, AiSpanKind.Llm],
|
||||
[{ 'gen_ai.usage.input_tokens': 0 }, AiSpanKind.Llm],
|
||||
[{ 'gen_ai.usage.reasoning.output_tokens': 12 }, AiSpanKind.Llm],
|
||||
[{ 'gen_ai.tool.name': 'search' }, AiSpanKind.Tool],
|
||||
[{ 'gen_ai.agent.name': 'planner' }, AiSpanKind.Agent],
|
||||
[
|
||||
{ 'gen_ai.tool.name': 'search', 'gen_ai.request.model': 'gpt-4o' },
|
||||
AiSpanKind.Tool,
|
||||
],
|
||||
[
|
||||
{ 'gen_ai.agent.name': 'planner', 'gen_ai.usage.input_tokens': 10 },
|
||||
AiSpanKind.Agent,
|
||||
],
|
||||
])('classifies %p as %s', (attributes, kind) => {
|
||||
expect(getAiSpanKind(spanWith(attributes))).toBe(kind);
|
||||
});
|
||||
|
||||
it('returns undefined for non-AI spans and empty values', () => {
|
||||
expect(getAiSpanKind(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
|
||||
expect(getAiSpanKind(spanWith({ 'gen_ai.tool.name': '' }))).toBeUndefined();
|
||||
expect(isAiSpan(spanWith({}))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTotalInputTokens', () => {
|
||||
it.each([
|
||||
// Cache reported inside input (OpenAI): input already includes it.
|
||||
[{ input: 1000, cacheRead: 600, cacheWrite: 100 }, 1000],
|
||||
// Cache reported separately (Anthropic): add it.
|
||||
[{ input: 50, cacheRead: 600, cacheWrite: 100 }, 750],
|
||||
[{ input: 71, cacheRead: 0, cacheWrite: 0 }, 71],
|
||||
[{ input: 10 }, 10],
|
||||
[{ cacheRead: 600 }, 600],
|
||||
])('%p → %p', (tokens, expected) => {
|
||||
expect(getTotalInputTokens(tokens)).toBe(expected);
|
||||
});
|
||||
});
|
||||
176
frontend/src/pages/TraceDetailsV3/utils/aiTraceUtils.ts
Normal file
176
frontend/src/pages/TraceDetailsV3/utils/aiTraceUtils.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
export const GEN_AI_KEYS = {
|
||||
requestModel: 'gen_ai.request.model',
|
||||
toolName: 'gen_ai.tool.name',
|
||||
agentName: 'gen_ai.agent.name',
|
||||
inputTokens: 'gen_ai.usage.input_tokens',
|
||||
outputTokens: 'gen_ai.usage.output_tokens',
|
||||
cacheReadTokens: 'gen_ai.usage.cache_read.input_tokens',
|
||||
cacheCreationTokens: 'gen_ai.usage.cache_creation.input_tokens',
|
||||
reasoningTokens: 'gen_ai.usage.reasoning.output_tokens',
|
||||
cost: 'signoz.gen_ai.usage.tokens.cost',
|
||||
} as const;
|
||||
|
||||
export enum AiSpanKind {
|
||||
Llm = 'llm',
|
||||
Tool = 'tool',
|
||||
Agent = 'agent',
|
||||
}
|
||||
|
||||
const LLM_KEYS = [
|
||||
GEN_AI_KEYS.requestModel,
|
||||
GEN_AI_KEYS.inputTokens,
|
||||
GEN_AI_KEYS.outputTokens,
|
||||
GEN_AI_KEYS.cacheReadTokens,
|
||||
GEN_AI_KEYS.cacheCreationTokens,
|
||||
GEN_AI_KEYS.reasoningTokens,
|
||||
];
|
||||
|
||||
/** Keeps numeric 0, which `getSpanAttribute`'s `||` chain drops. */
|
||||
export function getGenAiValue(
|
||||
span: SpanV3,
|
||||
key: string,
|
||||
): string | number | boolean | undefined {
|
||||
const value = span.resource?.[key] ?? span.attributes?.[key];
|
||||
return value === '' ? undefined : value;
|
||||
}
|
||||
|
||||
function hasAttribute(span: SpanV3, key: string): boolean {
|
||||
return getGenAiValue(span, key) !== undefined;
|
||||
}
|
||||
|
||||
/** Tool and agent spans may also carry model or usage keys, so they win over LLM. */
|
||||
export function getAiSpanKind(span: SpanV3): AiSpanKind | undefined {
|
||||
if (hasAttribute(span, GEN_AI_KEYS.toolName)) {
|
||||
return AiSpanKind.Tool;
|
||||
}
|
||||
if (hasAttribute(span, GEN_AI_KEYS.agentName)) {
|
||||
return AiSpanKind.Agent;
|
||||
}
|
||||
if (LLM_KEYS.some((key) => hasAttribute(span, key))) {
|
||||
return AiSpanKind.Llm;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isAiSpan(span: SpanV3): boolean {
|
||||
return getAiSpanKind(span) !== undefined;
|
||||
}
|
||||
|
||||
/** Token counts; `null` or missing means the backend did not send the value. */
|
||||
export interface AiTokenCounts {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
reasoning?: number;
|
||||
}
|
||||
|
||||
export function hasValue(value: number | null | undefined): value is number {
|
||||
return value !== undefined && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input including cache read/write, counted once. Providers that report cache
|
||||
* inside `input` (OpenAI) have `input >= cache`; those that report it
|
||||
* separately (Anthropic) usually have `input < cache`.
|
||||
*/
|
||||
export function getTotalInputTokens({
|
||||
input,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
}: AiTokenCounts): number {
|
||||
const inputValue = input ?? 0;
|
||||
const cache = (cacheRead ?? 0) + (cacheWrite ?? 0);
|
||||
return inputValue < cache ? inputValue + cache : inputValue;
|
||||
}
|
||||
|
||||
/** `Input Token` for a count of 1, `Input Tokens` otherwise. */
|
||||
export function getTokenLabel(
|
||||
prefix: string,
|
||||
count: number | null | undefined,
|
||||
): string {
|
||||
return `${prefix} ${count === 1 ? 'Token' : 'Tokens'}`;
|
||||
}
|
||||
|
||||
/** Missing values render as `0`. */
|
||||
export function formatTokens(value: number | null | undefined): string {
|
||||
return hasValue(value) ? value.toLocaleString('en-US') : '0';
|
||||
}
|
||||
|
||||
export function formatCost(value: number): string {
|
||||
return `$ ${value.toLocaleString('en-US', { maximumFractionDigits: 10 })}`;
|
||||
}
|
||||
|
||||
export interface SpanAiUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheCreationTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
cost?: number;
|
||||
}
|
||||
|
||||
export interface SpanAiDetails {
|
||||
model?: string;
|
||||
toolName?: string;
|
||||
agentName?: string;
|
||||
usage?: SpanAiUsage;
|
||||
}
|
||||
|
||||
function getGenAiAttributeAsString(
|
||||
span: SpanV3,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const value = getGenAiValue(span, key);
|
||||
return value === undefined ? undefined : String(value);
|
||||
}
|
||||
|
||||
function getGenAiAttributeAsNumber(
|
||||
span: SpanV3,
|
||||
key: string,
|
||||
): number | undefined {
|
||||
const value = getGenAiValue(span, key);
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
export function getAIUsageDetails(span: SpanV3): SpanAiDetails | undefined {
|
||||
const usage: SpanAiUsage = {
|
||||
inputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.inputTokens),
|
||||
outputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.outputTokens),
|
||||
cacheReadTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cacheReadTokens),
|
||||
cacheCreationTokens: getGenAiAttributeAsNumber(
|
||||
span,
|
||||
GEN_AI_KEYS.cacheCreationTokens,
|
||||
),
|
||||
reasoningTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.reasoningTokens),
|
||||
cost: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cost),
|
||||
};
|
||||
const hasUsage = Object.values(usage).some((value) => value !== undefined);
|
||||
|
||||
const details: SpanAiDetails = {
|
||||
model: getGenAiAttributeAsString(span, GEN_AI_KEYS.requestModel),
|
||||
toolName: getGenAiAttributeAsString(span, GEN_AI_KEYS.toolName),
|
||||
agentName: getGenAiAttributeAsString(span, GEN_AI_KEYS.agentName),
|
||||
usage: hasUsage ? usage : undefined,
|
||||
};
|
||||
|
||||
return details.model || details.toolName || details.agentName || hasUsage
|
||||
? details
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function convertToAiTokens(usage: SpanAiUsage): AiTokenCounts {
|
||||
return {
|
||||
input: usage.inputTokens,
|
||||
output: usage.outputTokens,
|
||||
cacheRead: usage.cacheReadTokens,
|
||||
cacheWrite: usage.cacheCreationTokens,
|
||||
reasoning: usage.reasoningTokens,
|
||||
};
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
|
||||
ID: "GetWaterfallV4",
|
||||
Tags: []string{"tracedetail"},
|
||||
Summary: "Get waterfall view for a trace",
|
||||
Description: "Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3. A span carrying gen_ai input or output messages also has them decoded into formattedInput and formattedOutput, with formatterWarnings holding what the converter could not resolve.",
|
||||
Description: "Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3",
|
||||
Request: new(spantypes.PostableWaterfall),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(spantypes.GettableWaterfallTrace),
|
||||
@@ -90,7 +90,7 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
|
||||
ID: "GetTraceThread",
|
||||
Tags: []string{"tracedetail"},
|
||||
Summary: "Get thread view for a trace",
|
||||
Description: "Returns the gen_ai spans of a trace, model calls, tool calls and agent spans, in timestamp order. The messages a span carries are decoded into formattedInput and formattedOutput, with formatterWarnings holding what the converter could not resolve; a span without messages has both empty. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.",
|
||||
Description: "Returns the spans carrying gen_ai input or output messages, or a tool execution, in timestamp order. Messages already in the OTel GenAI shape are decoded into formatted_input and formatted_output, with formatter naming the converter and formatter_warnings what it could not resolve. 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",
|
||||
|
||||
@@ -16,8 +16,7 @@ var definitionFiles embed.FS
|
||||
|
||||
// NewSystemGroupRegistry parses every embedded definition. Definitions are
|
||||
// build-time assets validated by a test, so a failure here means the binary
|
||||
// shipped broken JSON. docs/contributing/go/spanmapper.md records where each
|
||||
// source key comes from.
|
||||
// shipped broken JSON.
|
||||
func NewSystemGroupRegistry() (spantypes.SpanMapperGroupRegistry, error) {
|
||||
entries, err := fs.ReadDir(definitionFiles, definitionsRoot)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 3,
|
||||
"version": 2,
|
||||
"definition": {
|
||||
"name": "gen_ai.agent",
|
||||
"condition": {
|
||||
@@ -17,12 +17,6 @@
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "crewai.agent.role",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "agent.name",
|
||||
"context": "attribute",
|
||||
@@ -43,18 +37,6 @@
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "agent_id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "crewai.agent.id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "agent.id",
|
||||
"context": "attribute",
|
||||
@@ -91,32 +73,6 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.operation.name",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "openinference.span.kind",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "llm.request.type",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "traceloop.span.kind",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
{
|
||||
"version": 4,
|
||||
"version": 3,
|
||||
"definition": {
|
||||
"name": "gen_ai.llm",
|
||||
"condition": {
|
||||
"attributes": [
|
||||
{
|
||||
"value": "model"
|
||||
},
|
||||
{
|
||||
"value": "llm.input_messages"
|
||||
}
|
||||
],
|
||||
"resource": []
|
||||
@@ -20,12 +17,6 @@
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "reranker.model_name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 70
|
||||
},
|
||||
{
|
||||
"key": "llm.model_name",
|
||||
"context": "attribute",
|
||||
@@ -61,12 +52,6 @@
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
},
|
||||
{
|
||||
"key": "gen_ai.response.model",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -129,43 +114,11 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.operation.name",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "openinference.span.kind",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "llm.request.type",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "traceloop.span.kind",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.usage.input_tokens",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "input_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 60
|
||||
},
|
||||
{
|
||||
"key": "gen_ai.usage.prompt_tokens",
|
||||
"context": "attribute",
|
||||
@@ -204,12 +157,6 @@
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "output_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 60
|
||||
},
|
||||
{
|
||||
"key": "gen_ai.usage.completion_tokens",
|
||||
"context": "attribute",
|
||||
@@ -248,24 +195,6 @@
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "cache_read_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 60
|
||||
},
|
||||
{
|
||||
"key": "gen_ai.usage.input_tokens.cached",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 50
|
||||
},
|
||||
{
|
||||
"key": "gen_ai.usage.prompt_token_details.cached_read_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "gen_ai.usage.cache_read_input_tokens",
|
||||
"context": "attribute",
|
||||
@@ -292,12 +221,6 @@
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "cache_creation_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "gen_ai.usage.cache_write.input_tokens",
|
||||
"context": "attribute",
|
||||
@@ -319,38 +242,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.usage.reasoning.output_tokens",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.usage.reasoning_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "gen_ai.usage.completion_token_details.reasoning_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "llm.token_count.completion_details.reasoning",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.usage.reasoningTokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.input.messages",
|
||||
"fieldContext": "attribute",
|
||||
@@ -414,12 +305,6 @@
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.client.session_id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "session.id",
|
||||
"context": "attribute",
|
||||
@@ -440,30 +325,6 @@
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "llm.finish_reason",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 50
|
||||
},
|
||||
{
|
||||
"key": "llm.response.stop_reason",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "llm.response.finish_reason",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "gen_ai.response.finish_reason",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.response.finishReason",
|
||||
"context": "attribute",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 3,
|
||||
"version": 2,
|
||||
"definition": {
|
||||
"name": "gen_ai.tool",
|
||||
"condition": {
|
||||
@@ -15,9 +15,6 @@
|
||||
},
|
||||
{
|
||||
"value": "tool_response"
|
||||
},
|
||||
{
|
||||
"value": "tool_use_id"
|
||||
}
|
||||
],
|
||||
"resource": []
|
||||
@@ -29,18 +26,6 @@
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.client.tool_name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "tool_name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "tool.name",
|
||||
"context": "attribute",
|
||||
@@ -61,12 +46,6 @@
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.client.tool_use_id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "tool.id",
|
||||
"context": "attribute",
|
||||
@@ -159,32 +138,6 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.operation.name",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "openinference.span.kind",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "llm.request.type",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "traceloop.span.kind",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -37,12 +37,6 @@ func (h *handler) GetTraceSummary(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(spantypes.PostableWaterfall)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
@@ -54,7 +48,7 @@ func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.GetWaterfallV4(r.Context(), valuer.MustNewUUID(claims.OrgID), mux.Vars(r)["traceID"], req.SelectedSpanID, req.UncollapsedSpans)
|
||||
result, err := h.module.GetWaterfallV4(r.Context(), mux.Vars(r)["traceID"], req.SelectedSpanID, req.UncollapsedSpans)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -2,7 +2,6 @@ package impltracedetail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
@@ -13,10 +12,6 @@ import (
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
)
|
||||
|
||||
// formattedSpansLogThreshold is the number of formatted gen_ai spans in one waterfall response above which
|
||||
// the response is logged, to size the memory the formatted copies of the messages take.
|
||||
const formattedSpansLogThreshold = 200
|
||||
|
||||
type module struct {
|
||||
store spantypes.TraceStore
|
||||
settings factory.ScopedProviderSettings
|
||||
@@ -64,35 +59,22 @@ func (m *module) GetTraceSummary(ctx context.Context, orgID valuer.UUID, traceID
|
||||
// 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, orgID valuer.UUID, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error) {
|
||||
func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error) {
|
||||
bounds, err := m.store.GetTraceBounds(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
windowed := bounds.NumSpans > uint64(m.config.Waterfall.MaxLimitToSelectAllSpans)
|
||||
if windowed {
|
||||
if bounds.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)
|
||||
}
|
||||
|
||||
var trace *spantypes.GettableWaterfallTrace
|
||||
if windowed {
|
||||
trace, err = m.getWindowedWaterfall(ctx, orgID, traceID, selectedSpanID, uncollapsedSpans, bounds.Start, bounds.End)
|
||||
} else {
|
||||
trace, err = m.getFullWaterfall(ctx, orgID, traceID, bounds)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if trace.FormattedSpans > formattedSpansLogThreshold {
|
||||
m.settings.Logger().WarnContext(ctx, "waterfall response carries many formatted gen_ai spans", slog.String("trace_id", traceID), slog.Int("formatted_spans", trace.FormattedSpans), slog.Int("spans", len(trace.Spans)), slog.Bool("windowed", trace.HasMore))
|
||||
}
|
||||
return trace, nil
|
||||
return m.getFullWaterfall(ctx, traceID, bounds)
|
||||
}
|
||||
|
||||
func (m *module) getFullWaterfall(ctx context.Context, orgID valuer.UUID, traceID string, bounds *spantypes.TraceBounds) (*spantypes.GettableWaterfallTrace, error) {
|
||||
spanItems, err := m.store.GetTraceSpans(ctx, orgID, traceID, bounds)
|
||||
func (m *module) getFullWaterfall(ctx context.Context, traceID string, bounds *spantypes.TraceBounds) (*spantypes.GettableWaterfallTrace, error) {
|
||||
spanItems, err := m.store.GetTraceSpans(ctx, traceID, bounds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -167,7 +149,7 @@ func (m *module) GetFlamegraph(ctx context.Context, traceID string, selectedSpan
|
||||
}
|
||||
|
||||
// getWindowedWaterfall builds the waterfall tree with minimal data and then returns only a window of full spans.
|
||||
func (m *module) getWindowedWaterfall(ctx context.Context, orgID valuer.UUID, traceID, selectedSpanID string, uncollapsedSpans []string, start, end time.Time) (*spantypes.GettableWaterfallTrace, error) {
|
||||
func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpanID string, uncollapsedSpans []string, start, end time.Time) (*spantypes.GettableWaterfallTrace, error) {
|
||||
// Step 1: minimal fetch → build full tree → select visible window
|
||||
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, start, end)
|
||||
if err != nil {
|
||||
@@ -195,7 +177,7 @@ func (m *module) getWindowedWaterfall(ctx context.Context, orgID valuer.UUID, tr
|
||||
for i, s := range selectedSpans {
|
||||
spanIDs[i] = s.SpanID
|
||||
}
|
||||
fullSpans, err := m.store.GetTraceSpansByIDs(ctx, orgID, traceID, start, end, spanIDs)
|
||||
fullSpans, err := m.store.GetTraceSpansByIDs(ctx, traceID, start, end, spanIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
@@ -25,17 +24,6 @@ import (
|
||||
|
||||
const colServiceName = `resource_string_service$$$$name` // $ gets escaped so $$$$ converts to $$.
|
||||
|
||||
// storableSpanColumns are the columns a waterfall span scans; the JSON attributes column is added per flag.
|
||||
var storableSpanColumns = []string{
|
||||
"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",
|
||||
"flags", "is_remote", "trace_state", "status_code",
|
||||
"db_name", "db_operation", "http_method", "http_url", "http_host",
|
||||
"external_http_method", "external_http_url", "response_status_code", "links as references",
|
||||
}
|
||||
|
||||
func buildFieldExpr(fieldKey telemetrytypes.TelemetryFieldKey) (string, error) {
|
||||
switch fieldKey.FieldContext {
|
||||
case telemetrytypes.FieldContextResource:
|
||||
@@ -207,22 +195,33 @@ func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bo
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceSpans(ctx context.Context, orgID valuer.UUID, traceID string, bounds *spantypes.TraceBounds) ([]spantypes.StorableSpan, error) {
|
||||
sb := s.selectStorableSpans(ctx, orgID)
|
||||
sb.Where(
|
||||
sb.E("trace_id", traceID),
|
||||
sb.GE("ts_bucket_start", bounds.Start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", bounds.End.Unix()),
|
||||
func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, bounds *spantypes.TraceBounds) ([]spantypes.StorableSpan, error) {
|
||||
// DISTINCT ON (span_id) is ClickHouse-specific syntax not supported by sqlbuilder
|
||||
query := fmt.Sprintf(`
|
||||
SELECT DISTINCT ON (span_id)
|
||||
timestamp, duration_nano, span_id, has_error, kind,
|
||||
resource_string_service$$name, name,
|
||||
attributes_string, attributes_number, attributes_bool, resources_string,
|
||||
events, status_message, status_code_string, kind_string, parent_span_id,
|
||||
flags, is_remote, trace_state, status_code,
|
||||
db_name, db_operation, http_method, http_url, http_host,
|
||||
external_http_method, external_http_url, response_status_code, links as references
|
||||
FROM %s.%s
|
||||
WHERE trace_id=? AND ts_bucket_start>=? AND ts_bucket_start<=?
|
||||
ORDER BY timestamp ASC, name ASC`,
|
||||
spantypes.TraceDB, spantypes.TraceTable,
|
||||
)
|
||||
sb.OrderByAsc("timestamp")
|
||||
sb.OrderByAsc("name")
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var spans []spantypes.StorableSpan
|
||||
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
|
||||
var spanItems []spantypes.StorableSpan
|
||||
err := s.telemetryStore.ClickhouseDB().Select(
|
||||
ctx, &spanItems, query,
|
||||
traceID,
|
||||
bounds.Start.Unix()-1800,
|
||||
bounds.End.Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace spans")
|
||||
}
|
||||
return spans, nil
|
||||
return spanItems, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetMinimalSpans(ctx context.Context, traceID string, start, end time.Time) ([]spantypes.MinimalSpan, error) {
|
||||
@@ -249,11 +248,21 @@ func (s *traceStore) GetMinimalSpans(ctx context.Context, traceID string, start,
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, orgID valuer.UUID, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
|
||||
func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
|
||||
if len(spanIDs) == 0 {
|
||||
return []spantypes.StorableSpan{}, nil
|
||||
}
|
||||
sb := s.selectStorableSpans(ctx, orgID)
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"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",
|
||||
"flags", "is_remote", "trace_state", "status_code",
|
||||
"db_name", "db_operation", "http_method", "http_url", "http_host",
|
||||
"external_http_method", "external_http_url", "response_status_code", "links as references",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
ids := make([]any, len(spanIDs))
|
||||
for i, id := range spanIDs {
|
||||
ids[i] = id
|
||||
@@ -275,18 +284,6 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, orgID valuer.UUID,
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
// selectStorableSpans reads the JSON attributes column alongside the legacy maps when the
|
||||
// use_trace_attributes_json flag is on, as the query builder does.
|
||||
func (s *traceStore) selectStorableSpans(ctx context.Context, orgID valuer.UUID) *sqlbuilder.SelectBuilder {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(storableSpanColumns...)
|
||||
if s.flagger.BooleanOrEmpty(ctx, flagger.FeatureUseTraceAttributesJSON, featuretypes.NewFlaggerEvaluationContext(orgID)) {
|
||||
sb.SelectMore("attributes")
|
||||
}
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
return sb
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -300,11 +297,11 @@ func (s *traceStore) GetThreadSpans(ctx context.Context, orgID valuer.UUID, trac
|
||||
sb.SelectMore("attributes")
|
||||
}
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
keys, err := s.genAIFieldKeys(ctx, orgID, aiobservabilitytypes.GenAISpanGateKeys)
|
||||
keys, err := s.genAIFieldKeys(ctx, orgID, aiobservabilitytypes.GenAIThreadKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
isThreadSpan, err := s.anyExistsCondition(ctx, q, aiobservabilitytypes.GenAISpanGateKeys, keys, sb)
|
||||
isThreadSpan, err := s.anyExistsCondition(ctx, q, aiobservabilitytypes.GenAIThreadKeys, keys, sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user