Compare commits

...

10 Commits

Author SHA1 Message Date
aks07
5fbce1c285 feat(saved-views): turn on saved views and the bottom strip
the flag code stays for the cleanup, old views urls redirect to their explorer
2026-10-14 15:42:58 +05:30
Tushar Vats
da556abac1 feat(logparsingpipeline): order user pipelines around signozlogsnormalizer (#13106)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- Opamp places user log pipelines around the first
`signozlogsnormalizer` in the logs pipeline, per its `opamp.position`:
the normalizer runs after them by default (`after`), before them with
`before`. Other processors keep their places.
- Without the normalizer, processor ordering is unchanged.
- The `use_json_body`-prepended normalize operator is dropped when the
normalizer already runs ahead of user pipelines (`before`); otherwise
both are kept.
- Removes the `json_body_dual_ingestion` flag: the normalize operator no
longer stashes the original body, and isn't appended after user
pipelines when only that flag is on.

| `use_json_body` | `opamp.position` | Logs pipeline | Normalize
operator |
|---|---|---|---|
| off | no normalizer | memory_limiter → user → custom → batch
(unchanged) | — |
| off | `after` (default) | … → user → normalizer → … | — |
| off | `before` | … → normalizer → user → … | — |
| on | no normalizer | memory_limiter → normalize op → user → custom →
batch | prepended |
| on | `after` (default) | … → normalize op → user → normalizer → … |
prepended (both normalize) |
| on | `before` | … → normalizer → user → … | dropped |

#### Additional Information

- Depends on SigNoz/signoz-otel-collector#953 for the `opamp.position`
config.
- The pipelines preview still prepends the operator under
`use_json_body`, since it can't see per-collector `opamp.position`.
- Configs still setting `json_body_dual_ingestion` keep working; the
unknown flag is skipped with a warning.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-13 15:32:50 +00:00
Tushar Vats
cc8440248b chore(claude): run py-fmt and py-lint after python changes (#13116)
#### Description

- Adds a `pytest.md` rule: run `make py-fmt` and `make py-lint` after
changing `.py` files, matching what integration CI enforces.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-13 13:30:17 +00:00
Nityananda Gohain
e84ddfb61e chore: delete unwanted models from pricing db (#13079)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
Cleaning out models which are now not synced by zeus.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/nerve-pod/issues/402


<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
Please check issue for more details
2026-10-13 12:13:17 +00:00
Nityananda Gohain
dc9fda2916 feat(tracedetail): normalise gen_ai messages in thread spans (#13003)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
#### Description

- Thread spans now carry `formatted_input`, `formatted_output`,
`formatter` and `formatter_warnings`, with the messages in the OTel
GenAI shape; the `genai` types mirror the semconv schema.
- Tool spans are thread rows too, gated on `gen_ai.tool.name`, since
they carry no input or output messages.
- One normalizer added: the semconv passthrough plus the OpenAI chat
format; a bare string becomes `text`, anything else `generic`, both with
a warning. Other formats follow in separate PRs.
- Unknown keys are dropped from the formatted view, the raw attribute on
the span keeps them; object attributes are read from the JSON column
only.
- Formatter tests use the message attributes as the collector stored
them for the SigNoz/scripts captures (litellm, langchain, OpenInference,
bifrost), not hand-written samples.


<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/nerve-pod/issues/266


#### Additional information.
Please note that functions in formatter.go and openaichat.go are AI
generated, and I have tested them through tests.

- Converters are per message format, not per SDK. Covered now: SDKs
writing semconv (litellm, langchain, openllmetry, portkey, traceloop LLM
spans) and the OpenAI chat format (bifrost, openrouter input,
OpenInference raw OpenAI calls). Not yet: tool
spans, OpenInference flattened messages, OpenAI Responses items,
openrouter's completion object, Vercel, traceloop legacy prompt keys,
raw Anthropic and Gemini. The full table is in the design doc.
- OpenAPI spec and the generated frontend client are in a separate
commit.

<!--Please delete paragraphs that you did not use before submitting.-->

---

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:43:34 +00:00
Naman Verma
670677bae2 feat: add ai builder query plugin kind (#12833)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Adds a new query plugin kind `signoz/AIBuilderQuery`. Its corresponding
spec is equivalent to `qb.QueryBuilderQuery[qb.TraceAggregation]`

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/334

#### Screenshots / Screen Recordings




https://github.com/user-attachments/assets/3388abc1-f079-464c-b660-7b65ef54cb68



<!--Anything reviewers should keep in mind while reviewing -->

#### Additional Information

Will need to build UI changes on top of this

<!--Please delete paragraphs that you did not use before submitting.-->

---------

Co-authored-by: Gaurav Tewari <gauravtewari111@gmail.com>
Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-10-13 11:05:23 +00:00
Abhi kumar
e4d66c5413 feat(dashboard): support variables in panel title (#12988)
#### Description

- Panel titles resolve dashboard variables (`$var`, `{{var}}`,
`[[var]]`) in the panel header, View modal and download filenames,
reusing the Text panel's `interpolateVariables`.
- Editor title input suggests dashboard variables after `$` (V1 parity,
#7898).

#### Issues closed by this PR

Closes SigNoz/pulse-pod#327
2026-10-13 10:49:14 +00:00
Abhi kumar
1371cb0beb fix(dashboard): stop panel type drawers flashing on open and close (#13114)
#### Description

- The "New panel" and "Change panel type" drawers flash for one frame
when they finish opening or closing.
- Cause: `@signozhq/ui`'s `DrawerWrapper` runs its slide on WAAPI via
`motion@11`, which drops the native animation before writing the final
style, so a frame paints with the stale start position.
- Passing an `onUpdate` to the drawer content makes motion drive the
slide in JS. On a live dashboard this went from 24 jumps over 8
open/close cycles to 0.

#### Additional Information

- Workaround only; the proper fix belongs in `@signozhq/ui` (separate
PR). Remove `utils/drawerSlide.ts` and its two usages once that lands.
2026-10-13 09:32:35 +00:00
Abhi kumar
140498cb11 fix(querier): apply the limit to scalar trace operator queries (#13091)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Scalar trace operator queries ignored `limit`: `buildScalarQuery`
ordered the groups but never added a `LIMIT`, and post-processing only
limits time series. Table, Pie and Top List panels over a trace operator
showed every group.

closes https://github.com/SigNoz/pulse-pod/issues/574

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-10-12 17:49:57 +00:00
Tushar Vats
ba01bc0418 fix(tests): use promoted_paths API in create_json_index fixture (#13105)
#### Description

- `create_json_index` still hit `/api/v1/logs/promote_paths`, removed in
#12839 → `test_indexed_paths` failed with 404.
- Fixture now posts to `/api/v1/promoted_paths` with `signal: logs` /
`context: body`; paths are bare (no `body.` prefix) per #13021.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-12 12:41:14 +00:00
129 changed files with 5191 additions and 1016 deletions

View File

@@ -17,3 +17,4 @@ For the Python integration suite under `tests/`. Setup, running, and suite layou
- **snake_case parametrize ids.** `ids=["fill_gaps", "fill_zero"]`, not camelCase.
- **Name suite files with the two-digit prefix (`NN_*.py`).** `pyproject.toml` restricts collection to `[0-9][0-9]_*.py` (plus the bootstrap `setup.py` / `run.py`) — a file that doesn't match is silently never collected.
- **Always run pytest from `tests/`.** `--import-mode=importlib` is what allows same-basename files across suites (`querier/01_logs.py` vs `rawexportdata/01_logs.py`), but it disables pytest's implicit `sys.path` injection — `import fixtures` only resolves via `pythonpath = ["."]` from that rootdir.
- **After changing any `.py` file, run `make py-fmt` and `make py-lint`** from the repo root and fix what they report.

View File

@@ -1,5 +1,219 @@
components:
schemas:
AiobservabilitytypesBlobPart:
properties:
content:
type: string
mime_type:
nullable: true
type: string
modality:
type: string
type:
type: string
required:
- type
- modality
- content
type: object
AiobservabilitytypesChatMessage:
properties:
name:
nullable: true
type: string
parts:
$ref: '#/components/schemas/AiobservabilitytypesParts'
role:
type: string
required:
- role
- parts
type: object
AiobservabilitytypesCompactionPart:
properties:
content:
nullable: true
type: string
id:
nullable: true
type: string
type:
type: string
required:
- type
type: object
AiobservabilitytypesFilePart:
properties:
file_id:
type: string
mime_type:
nullable: true
type: string
modality:
type: string
type:
type: string
required:
- type
- modality
- file_id
type: object
AiobservabilitytypesGenericPart:
additionalProperties: {}
type: object
AiobservabilitytypesGenericServerToolCall:
additionalProperties: {}
type: object
AiobservabilitytypesGenericServerToolCallResponse:
additionalProperties: {}
type: object
AiobservabilitytypesInputMessages:
items:
$ref: '#/components/schemas/AiobservabilitytypesChatMessage'
type: array
AiobservabilitytypesOutputMessage:
properties:
finish_reason:
nullable: true
type: string
name:
nullable: true
type: string
parts:
$ref: '#/components/schemas/AiobservabilitytypesParts'
role:
type: string
required:
- role
- parts
type: object
AiobservabilitytypesOutputMessages:
items:
$ref: '#/components/schemas/AiobservabilitytypesOutputMessage'
type: array
AiobservabilitytypesPart:
discriminator:
mapping:
blob: '#/components/schemas/AiobservabilitytypesBlobPart'
compaction: '#/components/schemas/AiobservabilitytypesCompactionPart'
file: '#/components/schemas/AiobservabilitytypesFilePart'
reasoning: '#/components/schemas/AiobservabilitytypesReasoningPart'
server_tool_call: '#/components/schemas/AiobservabilitytypesServerToolCallPart'
server_tool_call_response: '#/components/schemas/AiobservabilitytypesServerToolCallResponsePart'
text: '#/components/schemas/AiobservabilitytypesTextPart'
tool_call: '#/components/schemas/AiobservabilitytypesToolCallRequestPart'
tool_call_response: '#/components/schemas/AiobservabilitytypesToolCallResponsePart'
uri: '#/components/schemas/AiobservabilitytypesUriPart'
propertyName: type
oneOf:
- $ref: '#/components/schemas/AiobservabilitytypesTextPart'
- $ref: '#/components/schemas/AiobservabilitytypesToolCallRequestPart'
- $ref: '#/components/schemas/AiobservabilitytypesToolCallResponsePart'
- $ref: '#/components/schemas/AiobservabilitytypesServerToolCallPart'
- $ref: '#/components/schemas/AiobservabilitytypesServerToolCallResponsePart'
- $ref: '#/components/schemas/AiobservabilitytypesBlobPart'
- $ref: '#/components/schemas/AiobservabilitytypesFilePart'
- $ref: '#/components/schemas/AiobservabilitytypesUriPart'
- $ref: '#/components/schemas/AiobservabilitytypesReasoningPart'
- $ref: '#/components/schemas/AiobservabilitytypesCompactionPart'
- $ref: '#/components/schemas/AiobservabilitytypesGenericPart'
type: object
AiobservabilitytypesParts:
items:
$ref: '#/components/schemas/AiobservabilitytypesPart'
type: array
AiobservabilitytypesReasoningPart:
properties:
content:
type: string
type:
type: string
required:
- type
- content
type: object
AiobservabilitytypesServerToolCallPart:
properties:
id:
nullable: true
type: string
name:
type: string
server_tool_call:
$ref: '#/components/schemas/AiobservabilitytypesGenericServerToolCall'
type:
type: string
required:
- type
- name
- server_tool_call
type: object
AiobservabilitytypesServerToolCallResponsePart:
properties:
id:
nullable: true
type: string
server_tool_call_response:
$ref: '#/components/schemas/AiobservabilitytypesGenericServerToolCallResponse'
type:
type: string
required:
- type
- server_tool_call_response
type: object
AiobservabilitytypesTextPart:
properties:
content:
type: string
type:
type: string
required:
- type
- content
type: object
AiobservabilitytypesToolCallRequestPart:
properties:
arguments: {}
id:
nullable: true
type: string
name:
type: string
type:
type: string
required:
- type
- name
type: object
AiobservabilitytypesToolCallResponsePart:
properties:
id:
nullable: true
type: string
response:
nullable: true
type:
type: string
required:
- type
- response
type: object
AiobservabilitytypesUriPart:
properties:
mime_type:
nullable: true
type: string
modality:
type: string
type:
type: string
uri:
type: string
required:
- type
- modality
- uri
type: object
AlertmanagertypesChannel:
properties:
createdAt:
@@ -3367,6 +3581,69 @@ components:
repeatVariable:
type: string
type: object
DashboardtypesAIBuilderQuerySpec:
properties:
aggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
nullable: true
type: array
bucketOptions:
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
cursor:
type: string
disabled:
type: boolean
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
functions:
items:
$ref: '#/components/schemas/Querybuildertypesv5Function'
nullable: true
type: array
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
having:
$ref: '#/components/schemas/Querybuildertypesv5Having'
legend:
type: string
limit:
type: integer
limitBy:
$ref: '#/components/schemas/Querybuildertypesv5LimitBy'
name:
type: string
offset:
type: integer
order:
items:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
nullable: true
type: array
secondaryAggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5SecondaryAggregation'
nullable: true
type: array
selectFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
nullable: true
type: array
signal:
enum:
- traces
type: string
source:
$ref: '#/components/schemas/TelemetrytypesSource'
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
required:
- signal
type: object
DashboardtypesAreaChartAppearance:
properties:
fillMode:
@@ -4420,6 +4697,7 @@ components:
DashboardtypesQueryPlugin:
discriminator:
mapping:
signoz/AIBuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
signoz/BuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
signoz/ClickHouseSQL: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
signoz/CompositeQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
@@ -4429,6 +4707,7 @@ components:
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
@@ -4438,12 +4717,25 @@ components:
DashboardtypesQueryPluginKind:
enum:
- signoz/BuilderQuery
- signoz/AIBuilderQuery
- signoz/CompositeQuery
- signoz/Formula
- signoz/PromQLQuery
- signoz/ClickHouseSQL
- signoz/TraceOperator
type: string
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec:
properties:
kind:
enum:
- signoz/AIBuilderQuery
type: string
spec:
$ref: '#/components/schemas/DashboardtypesAIBuilderQuerySpec'
required:
- kind
- spec
type: object
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec:
properties:
kind:
@@ -10225,6 +10517,16 @@ components:
items:
$ref: '#/components/schemas/SpantypesEvent'
type: array
formatted_input:
$ref: '#/components/schemas/AiobservabilitytypesInputMessages'
formatted_output:
$ref: '#/components/schemas/AiobservabilitytypesOutputMessages'
formatter:
type: string
formatter_warnings:
items:
type: string
type: array
has_error:
type: boolean
kind_string:
@@ -10267,6 +10569,10 @@ components:
- attributes
- events
- references
- formatted_input
- formatted_output
- formatter
- formatter_warnings
type: object
SpantypesTraceAISummary:
properties:
@@ -16134,9 +16440,12 @@ paths:
/api/v1/traces/{traceID}/thread:
get:
deprecated: false
description: Returns the spans carrying gen_ai input or output messages in timestamp
order. Pass nextCursor as after or prevCursor as before to page, or spanId
to open the page around a span.
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.

View File

@@ -300,20 +300,17 @@ describe('PrivateRoute', () => {
);
});
it('should redirect /logs-save-views to /logs/saved-views', () => {
renderPrivateRoute({ initialRoute: '/logs-save-views' });
it.each([
['/logs-save-views', ROUTES.LOGS_EXPLORER],
['/traces-save-views', ROUTES.TRACES_EXPLORER],
[ROUTES.LOGS_SAVE_VIEWS, ROUTES.LOGS_EXPLORER],
[ROUTES.TRACES_SAVE_VIEWS, ROUTES.TRACES_EXPLORER],
[ROUTES.METRICS_EXPLORER_VIEWS, ROUTES.METRICS_EXPLORER_EXPLORER],
[ROUTES.METER_EXPLORER_VIEWS, ROUTES.METER_EXPLORER],
])('should redirect the old views route %s to %s', (from, to) => {
renderPrivateRoute({ initialRoute: from });
expect(screen.getByTestId('location-display')).toHaveTextContent(
'/logs/saved-views',
);
});
it('should redirect /traces-save-views to /traces/saved-views', () => {
renderPrivateRoute({ initialRoute: '/traces-save-views' });
expect(screen.getByTestId('location-display')).toHaveTextContent(
'/traces/saved-views',
);
expect(screen.getByTestId('location-display').textContent).toBe(to);
});
it('should redirect /settings/access-tokens to /settings/service-accounts', () => {
@@ -1635,11 +1632,6 @@ describe('PrivateRoute', () => {
['LIST_LICENSES', ROUTES.LIST_LICENSES, USER_ROLES.EDITOR as ROLES],
['ONBOARDING', ROUTES.ONBOARDING, USER_ROLES.VIEWER as ROLES],
['APPLICATION', ROUTES.APPLICATION, USER_ROLES.ANONYMOUS as ROLES],
[
'METRICS_EXPLORER_VIEWS',
ROUTES.METRICS_EXPLORER_VIEWS,
USER_ROLES.ANONYMOUS as ROLES,
],
];
it.each(authzRouteRolePairs)(

View File

@@ -513,8 +513,12 @@ export const LIST_LICENSES: AppRoutes = {
export const oldNewRoutesMapping: Record<string, string> = {
'/pipelines': '/logs/pipelines',
'/logs-explorer': '/logs/logs-explorer',
'/logs-save-views': '/logs/saved-views',
'/traces-save-views': '/traces/saved-views',
'/logs-save-views': '/logs/logs-explorer',
'/traces-save-views': '/traces-explorer',
'/logs/saved-views': '/logs/logs-explorer',
'/traces/saved-views': '/traces-explorer',
'/metrics-explorer/views': '/metrics-explorer/explorer',
'/meter/explorer/views': '/meter/explorer',
'/settings/access-tokens': '/settings/service-accounts',
'/settings/api-keys': '/settings/service-accounts',
'/messaging-queues': '/messaging-queues/overview',

View File

@@ -4,6 +4,264 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
export enum AiobservabilitytypesBlobPartDTOType {
blob = 'blob',
}
export interface AiobservabilitytypesBlobPartDTO {
/**
* @type string
*/
content: string;
/**
* @type string,null
*/
mime_type?: string | null;
/**
* @type string
*/
modality: string;
/**
* @type string
* @enum blob
*/
type: AiobservabilitytypesBlobPartDTOType;
}
export enum AiobservabilitytypesTextPartDTOType {
text = 'text',
}
export interface AiobservabilitytypesTextPartDTO {
/**
* @type string
*/
content: string;
/**
* @type string
* @enum text
*/
type: AiobservabilitytypesTextPartDTOType;
}
export enum AiobservabilitytypesToolCallRequestPartDTOType {
tool_call = 'tool_call',
}
export interface AiobservabilitytypesToolCallRequestPartDTO {
arguments?: unknown;
/**
* @type string,null
*/
id?: string | null;
/**
* @type string
*/
name: string;
/**
* @type string
* @enum tool_call
*/
type: AiobservabilitytypesToolCallRequestPartDTOType;
}
export enum AiobservabilitytypesToolCallResponsePartDTOType {
tool_call_response = 'tool_call_response',
}
export type AiobservabilitytypesToolCallResponsePartDTOResponse =
| unknown
| null;
export interface AiobservabilitytypesToolCallResponsePartDTO {
/**
* @type string,null
*/
id?: string | null;
/**
* @nullable true
*/
response: AiobservabilitytypesToolCallResponsePartDTOResponse;
/**
* @type string
* @enum tool_call_response
*/
type: AiobservabilitytypesToolCallResponsePartDTOType;
}
export interface AiobservabilitytypesGenericServerToolCallDTO {
[key: string]: unknown;
}
export enum AiobservabilitytypesServerToolCallPartDTOType {
server_tool_call = 'server_tool_call',
}
export interface AiobservabilitytypesServerToolCallPartDTO {
/**
* @type string,null
*/
id?: string | null;
/**
* @type string
*/
name: string;
server_tool_call: AiobservabilitytypesGenericServerToolCallDTO;
/**
* @type string
* @enum server_tool_call
*/
type: AiobservabilitytypesServerToolCallPartDTOType;
}
export interface AiobservabilitytypesGenericServerToolCallResponseDTO {
[key: string]: unknown;
}
export enum AiobservabilitytypesServerToolCallResponsePartDTOType {
server_tool_call_response = 'server_tool_call_response',
}
export interface AiobservabilitytypesServerToolCallResponsePartDTO {
/**
* @type string,null
*/
id?: string | null;
server_tool_call_response: AiobservabilitytypesGenericServerToolCallResponseDTO;
/**
* @type string
* @enum server_tool_call_response
*/
type: AiobservabilitytypesServerToolCallResponsePartDTOType;
}
export enum AiobservabilitytypesFilePartDTOType {
file = 'file',
}
export interface AiobservabilitytypesFilePartDTO {
/**
* @type string
*/
file_id: string;
/**
* @type string,null
*/
mime_type?: string | null;
/**
* @type string
*/
modality: string;
/**
* @type string
* @enum file
*/
type: AiobservabilitytypesFilePartDTOType;
}
export enum AiobservabilitytypesUriPartDTOType {
uri = 'uri',
}
export interface AiobservabilitytypesUriPartDTO {
/**
* @type string,null
*/
mime_type?: string | null;
/**
* @type string
*/
modality: string;
/**
* @type string
* @enum uri
*/
type: AiobservabilitytypesUriPartDTOType;
/**
* @type string
*/
uri: string;
}
export enum AiobservabilitytypesReasoningPartDTOType {
reasoning = 'reasoning',
}
export interface AiobservabilitytypesReasoningPartDTO {
/**
* @type string
*/
content: string;
/**
* @type string
* @enum reasoning
*/
type: AiobservabilitytypesReasoningPartDTOType;
}
export enum AiobservabilitytypesCompactionPartDTOType {
compaction = 'compaction',
}
export interface AiobservabilitytypesCompactionPartDTO {
/**
* @type string,null
*/
content?: string | null;
/**
* @type string,null
*/
id?: string | null;
/**
* @type string
* @enum compaction
*/
type: AiobservabilitytypesCompactionPartDTOType;
}
export interface AiobservabilitytypesGenericPartDTO {
[key: string]: unknown;
}
export type AiobservabilitytypesPartDTO =
| AiobservabilitytypesTextPartDTO
| AiobservabilitytypesToolCallRequestPartDTO
| AiobservabilitytypesToolCallResponsePartDTO
| AiobservabilitytypesServerToolCallPartDTO
| AiobservabilitytypesServerToolCallResponsePartDTO
| AiobservabilitytypesBlobPartDTO
| AiobservabilitytypesFilePartDTO
| AiobservabilitytypesUriPartDTO
| AiobservabilitytypesReasoningPartDTO
| AiobservabilitytypesCompactionPartDTO
| AiobservabilitytypesGenericPartDTO;
export type AiobservabilitytypesPartsDTO = AiobservabilitytypesPartDTO[];
export interface AiobservabilitytypesChatMessageDTO {
/**
* @type string,null
*/
name?: string | null;
parts: AiobservabilitytypesPartsDTO;
/**
* @type string
*/
role: string;
}
export type AiobservabilitytypesInputMessagesDTO =
AiobservabilitytypesChatMessageDTO[];
export interface AiobservabilitytypesOutputMessageDTO {
/**
* @type string,null
*/
finish_reason?: string | null;
/**
* @type string,null
*/
name?: string | null;
parts: AiobservabilitytypesPartsDTO;
/**
* @type string
*/
role: string;
}
export type AiobservabilitytypesOutputMessagesDTO =
AiobservabilitytypesOutputMessageDTO[];
export interface AlertmanagertypesChannelDTO {
/**
* @type string
@@ -4211,195 +4469,10 @@ export interface DashboardGridLayoutSpecDTO {
repeatVariable?: string;
}
export enum DashboardtypesAreaFillModeDTO {
solid = 'solid',
gradient = 'gradient',
export enum DashboardtypesAIBuilderQuerySpecDTOSignal {
traces = 'traces',
}
/**
* @minimum 0
* @maximum 1
* @nullable
*/
export type DashboardtypesFillOpacityDTO = number | null;
export enum DashboardtypesLineInterpolationDTO {
linear = 'linear',
spline = 'spline',
step_after = 'step_after',
step_before = 'step_before',
}
export enum DashboardtypesLineStyleDTO {
solid = 'solid',
dashed = 'dashed',
}
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}
export interface DashboardtypesAreaChartAppearanceDTO {
fillMode?: DashboardtypesAreaFillModeDTO;
fillOpacity?: DashboardtypesFillOpacityDTO | null;
lineInterpolation?: DashboardtypesLineInterpolationDTO;
lineStyle?: DashboardtypesLineStyleDTO;
/**
* @type boolean
*/
showPoints?: boolean;
spanGaps?: DashboardtypesSpanGapsDTO;
}
export interface DashboardtypesAxesDTO {
/**
* @type boolean
*/
isLogScale?: boolean;
/**
* @type number,null
*/
softMax?: number | null;
/**
* @type number,null
*/
softMin?: number | null;
}
export enum DashboardtypesPrecisionOptionDTO {
NUMBER_0 = '0',
NUMBER_1 = '1',
NUMBER_2 = '2',
NUMBER_3 = '3',
NUMBER_4 = '4',
full = 'full',
}
export interface DashboardtypesPanelFormattingDTO {
decimalPrecision?: DashboardtypesPrecisionOptionDTO;
/**
* @type string
*/
unit?: string;
}
export enum DashboardtypesLegendModeDTO {
list = 'list',
}
export enum DashboardtypesLegendPositionDTO {
bottom = 'bottom',
right = 'right',
}
export type DashboardtypesLegendDTOCustomColorsAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type DashboardtypesLegendDTOCustomColors =
DashboardtypesLegendDTOCustomColorsAnyOf | null;
export interface DashboardtypesLegendDTO {
/**
* @type object,null
*/
customColors?: DashboardtypesLegendDTOCustomColors;
mode?: DashboardtypesLegendModeDTO;
position?: DashboardtypesLegendPositionDTO;
}
export interface DashboardtypesThresholdWithLabelDTO {
/**
* @type string
*/
color: string;
/**
* @type string
*/
label?: string;
/**
* @type string
*/
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
export enum DashboardtypesStackModeDTO {
none = 'none',
normal = 'normal',
percent = 'percent',
}
export enum DashboardtypesTimePreferenceDTO {
global_time = 'global_time',
last_5_min = 'last_5_min',
last_15_min = 'last_15_min',
last_30_min = 'last_30_min',
last_1_hr = 'last_1_hr',
last_6_hr = 'last_6_hr',
last_1_day = 'last_1_day',
last_3_days = 'last_3_days',
last_1_week = 'last_1_week',
last_1_month = 'last_1_month',
}
export interface DashboardtypesAreaChartVisualizationDTO {
/**
* @type boolean
*/
fillSpans?: boolean;
stack?: DashboardtypesStackModeDTO;
timePreference?: DashboardtypesTimePreferenceDTO;
}
export interface DashboardtypesAreaChartPanelSpecDTO {
axes?: DashboardtypesAxesDTO;
chartAppearance?: DashboardtypesAreaChartAppearanceDTO;
formatting?: DashboardtypesPanelFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
*/
fillSpans?: boolean;
/**
* @type boolean
*/
stackedBarChart?: boolean;
timePreference?: DashboardtypesTimePreferenceDTO;
}
export interface DashboardtypesBarChartPanelSpecDTO {
axes?: DashboardtypesAxesDTO;
formatting?: DashboardtypesPanelFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
visualization?: DashboardtypesBarChartVisualizationDTO;
}
export interface DashboardtypesBasicVisualizationDTO {
timePreference?: DashboardtypesTimePreferenceDTO;
}
export interface Querybuildertypesv5LogAggregationDTO {
export interface Querybuildertypesv5TraceAggregationDTO {
/**
* @type string
*/
@@ -4638,13 +4711,274 @@ export interface TelemetrytypesTelemetryFieldKeyDTO {
unit?: string;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal {
logs = 'logs',
}
export enum TelemetrytypesSourceDTO {
meter = 'meter',
'' = '',
}
export interface DashboardtypesAIBuilderQuerySpecDTO {
/**
* @type array,null
*/
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
*/
cursor?: string;
/**
* @type boolean
*/
disabled?: boolean;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
*/
functions?: Querybuildertypesv5FunctionDTO[] | null;
/**
* @type array,null
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
having?: Querybuildertypesv5HavingDTO;
/**
* @type string
*/
legend?: string;
/**
* @type integer
*/
limit?: number;
limitBy?: Querybuildertypesv5LimitByDTO;
/**
* @type string
*/
name?: string;
/**
* @type integer
*/
offset?: number;
/**
* @type array,null
*/
order?: Querybuildertypesv5OrderByDTO[] | null;
/**
* @type array,null
*/
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[] | null;
/**
* @type array,null
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
/**
* @enum traces
* @type string
*/
signal: DashboardtypesAIBuilderQuerySpecDTOSignal;
source?: TelemetrytypesSourceDTO;
stepInterval?: Querybuildertypesv5StepDTO;
}
export enum DashboardtypesAreaFillModeDTO {
solid = 'solid',
gradient = 'gradient',
}
/**
* @minimum 0
* @maximum 1
* @nullable
*/
export type DashboardtypesFillOpacityDTO = number | null;
export enum DashboardtypesLineInterpolationDTO {
linear = 'linear',
spline = 'spline',
step_after = 'step_after',
step_before = 'step_before',
}
export enum DashboardtypesLineStyleDTO {
solid = 'solid',
dashed = 'dashed',
}
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}
export interface DashboardtypesAreaChartAppearanceDTO {
fillMode?: DashboardtypesAreaFillModeDTO;
fillOpacity?: DashboardtypesFillOpacityDTO | null;
lineInterpolation?: DashboardtypesLineInterpolationDTO;
lineStyle?: DashboardtypesLineStyleDTO;
/**
* @type boolean
*/
showPoints?: boolean;
spanGaps?: DashboardtypesSpanGapsDTO;
}
export interface DashboardtypesAxesDTO {
/**
* @type boolean
*/
isLogScale?: boolean;
/**
* @type number,null
*/
softMax?: number | null;
/**
* @type number,null
*/
softMin?: number | null;
}
export enum DashboardtypesPrecisionOptionDTO {
NUMBER_0 = '0',
NUMBER_1 = '1',
NUMBER_2 = '2',
NUMBER_3 = '3',
NUMBER_4 = '4',
full = 'full',
}
export interface DashboardtypesPanelFormattingDTO {
decimalPrecision?: DashboardtypesPrecisionOptionDTO;
/**
* @type string
*/
unit?: string;
}
export enum DashboardtypesLegendModeDTO {
list = 'list',
}
export enum DashboardtypesLegendPositionDTO {
bottom = 'bottom',
right = 'right',
}
export type DashboardtypesLegendDTOCustomColorsAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type DashboardtypesLegendDTOCustomColors =
DashboardtypesLegendDTOCustomColorsAnyOf | null;
export interface DashboardtypesLegendDTO {
/**
* @type object,null
*/
customColors?: DashboardtypesLegendDTOCustomColors;
mode?: DashboardtypesLegendModeDTO;
position?: DashboardtypesLegendPositionDTO;
}
export interface DashboardtypesThresholdWithLabelDTO {
/**
* @type string
*/
color: string;
/**
* @type string
*/
label?: string;
/**
* @type string
*/
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
export enum DashboardtypesStackModeDTO {
none = 'none',
normal = 'normal',
percent = 'percent',
}
export enum DashboardtypesTimePreferenceDTO {
global_time = 'global_time',
last_5_min = 'last_5_min',
last_15_min = 'last_15_min',
last_30_min = 'last_30_min',
last_1_hr = 'last_1_hr',
last_6_hr = 'last_6_hr',
last_1_day = 'last_1_day',
last_3_days = 'last_3_days',
last_1_week = 'last_1_week',
last_1_month = 'last_1_month',
}
export interface DashboardtypesAreaChartVisualizationDTO {
/**
* @type boolean
*/
fillSpans?: boolean;
stack?: DashboardtypesStackModeDTO;
timePreference?: DashboardtypesTimePreferenceDTO;
}
export interface DashboardtypesAreaChartPanelSpecDTO {
axes?: DashboardtypesAxesDTO;
chartAppearance?: DashboardtypesAreaChartAppearanceDTO;
formatting?: DashboardtypesPanelFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
*/
fillSpans?: boolean;
/**
* @type boolean
*/
stackedBarChart?: boolean;
timePreference?: DashboardtypesTimePreferenceDTO;
}
export interface DashboardtypesBarChartPanelSpecDTO {
axes?: DashboardtypesAxesDTO;
formatting?: DashboardtypesPanelFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
visualization?: DashboardtypesBarChartVisualizationDTO;
}
export interface DashboardtypesBasicVisualizationDTO {
timePreference?: DashboardtypesTimePreferenceDTO;
}
export interface Querybuildertypesv5LogAggregationDTO {
/**
* @type string
*/
alias?: string;
/**
* @type string
*/
expression?: string;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal {
logs = 'logs',
}
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTO {
/**
* @type array,null
@@ -4835,17 +5169,6 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
stepInterval?: Querybuildertypesv5StepDTO;
}
export interface Querybuildertypesv5TraceAggregationDTO {
/**
* @type string
*/
alias?: string;
/**
* @type string
*/
expression?: string;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTOSignal {
traces = 'traces',
}
@@ -5404,6 +5727,18 @@ export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesBuilderQuerySpecDTO;
}
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTOKind {
'signoz/AIBuilderQuery' = 'signoz/AIBuilderQuery',
}
export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTO {
/**
* @enum signoz/AIBuilderQuery
* @type string
*/
kind: DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTOKind;
spec: DashboardtypesAIBuilderQuerySpecDTO;
}
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTOKind {
'signoz/CompositeQuery' = 'signoz/CompositeQuery',
}
@@ -5695,6 +6030,7 @@ export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQu
export type DashboardtypesQueryPluginDTO =
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormulaDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQueryDTO
@@ -6373,6 +6709,7 @@ export interface DashboardtypesPostablePublicDashboardDTO {
export enum DashboardtypesQueryPluginKindDTO {
'signoz/BuilderQuery' = 'signoz/BuilderQuery',
'signoz/AIBuilderQuery' = 'signoz/AIBuilderQuery',
'signoz/CompositeQuery' = 'signoz/CompositeQuery',
'signoz/Formula' = 'signoz/Formula',
'signoz/PromQLQuery' = 'signoz/PromQLQuery',
@@ -11452,6 +11789,16 @@ export interface SpantypesThreadSpanDTO {
* @type array
*/
events: SpantypesEventDTO[];
formatted_input: AiobservabilitytypesInputMessagesDTO;
formatted_output: AiobservabilitytypesOutputMessagesDTO;
/**
* @type string
*/
formatter: string;
/**
* @type array
*/
formatter_warnings: string[];
/**
* @type boolean
*/

View File

@@ -261,7 +261,7 @@ export const invalidateGetTraceSummary = async (
};
/**
* Returns the spans carrying gen_ai input or output messages in timestamp order. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
* 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 = (

View File

@@ -4,7 +4,11 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { Formula } from 'container/QueryBuilder/components/Formula';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
import { getBuilderVariant } from 'lib/query/queryMode';
import {
BuilderVariant,
IBuilderTraceOperator,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderField } from './queryBuilderFields.types';
@@ -60,8 +64,16 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
[config],
);
const isAIQuery =
getBuilderVariant(currentQuery.builder) === BuilderVariant.AI;
useEffect(() => {
if (currentDataSource !== initialDataSource || newPanelType !== panelType) {
// AI queries are traces-only; BAR must not force metrics on them.
if (isAIQuery) {
handleSetConfig(newPanelType, DataSource.TRACES);
return;
}
if (newPanelType === PANEL_TYPES.BAR) {
handleSetConfig(PANEL_TYPES.BAR, DataSource.METRICS);
return;
@@ -74,15 +86,20 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
initialDataSource,
currentDataSource,
newPanelType,
isAIQuery,
]);
useEffect(() => {
// always clear on mount and unmount to avoid stale data
// hosts opting into savePreviousQuery own the clear, as they may remount this builder mid-edit
if (savePreviousQuery) {
return undefined;
}
clearPreviousQuery();
return (): void => {
clearPreviousQuery();
};
}, []);
}, [savePreviousQuery]);
const resolvedConfig = useMemo(
() =>

View File

@@ -15,6 +15,7 @@ import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
import * as useHandleExplorerTabChangeHooks from 'hooks/useHandleExplorerTabChange';
import * as appContextHooks from 'providers/App/App';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import * as timezoneHooks from 'providers/Timezone';
import store from 'store';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
@@ -149,7 +150,9 @@ function renderExplorer(): void {
<Provider store={store}>
<TooltipProvider>
<ErrorModalProvider>
<Explorer />
<PreferenceContextProvider>
<Explorer />
</PreferenceContextProvider>
</ErrorModalProvider>
</TooltipProvider>
</Provider>

View File

@@ -1,11 +1,3 @@
import getLocalStorageKey from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useState } from 'react';
export function useSavedViewEnabled(): boolean {
const [isEnabled] = useState(
() => getLocalStorageKey(LOCALSTORAGE.SAVED_VIEW_ENABLED) === 'true',
);
return isEnabled;
return true;
}

View File

@@ -30,7 +30,10 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
> = {};
const builderQueryTypes: Record<
string,
'builder_query' | 'builder_formula' | 'builder_trace_operator'
| 'builder_query'
| 'builder_ai_query'
| 'builder_formula'
| 'builder_trace_operator'
> = {};
const promQueries: IPromQLQuery[] = [];
const clickhouseQueries: IClickHouseQuery[] = [];
@@ -44,6 +47,14 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
);
builderQueryTypes[spec.name] = 'builder_query';
}
} else if (q.type === 'builder_ai_query') {
if (spec.name) {
builderQueries[spec.name] = {
...convertBuilderQueryToIBuilderQuery(spec as BuilderQuery),
builderQueryType: 'builder_ai_query',
};
builderQueryTypes[spec.name] = 'builder_ai_query';
}
} else if (q.type === 'builder_formula') {
if (spec.name) {
builderQueries[spec.name] = convertQueryBuilderFormulaToIBuilderFormula(

View File

@@ -15,7 +15,10 @@ export const transformQueryBuilderDataModel = (
data: BuilderQueryDataResourse,
queryTypes?: Record<
string,
'builder_query' | 'builder_formula' | 'builder_trace_operator'
| 'builder_query'
| 'builder_ai_query'
| 'builder_formula'
| 'builder_trace_operator'
>,
): QueryBuilderData => {
const queryData: QueryBuilderData['queryData'] = [];

View File

@@ -0,0 +1,53 @@
import type { Querybuildertypesv5QueryEnvelopeDTO } from 'api/generated/services/sigNoz.schemas';
import {
isAIBuilderEnvelope,
isBuilderEnvelope,
isBuilderPluginKind,
} from '../builderEnvelope';
const envelope = (type: string): Querybuildertypesv5QueryEnvelopeDTO =>
({ type, spec: {} }) as unknown as Querybuildertypesv5QueryEnvelopeDTO;
describe('builder envelope predicates', () => {
describe('isBuilderEnvelope', () => {
it.each(['builder_query', 'builder_ai_query'])('accepts %s', (type) => {
expect(isBuilderEnvelope(envelope(type))).toBe(true);
});
it.each([
'builder_formula',
'builder_trace_operator',
'promql',
'clickhouse_sql',
])('rejects %s', (type) => {
expect(isBuilderEnvelope(envelope(type))).toBe(false);
});
});
describe('isAIBuilderEnvelope', () => {
it('accepts builder_ai_query', () => {
expect(isAIBuilderEnvelope(envelope('builder_ai_query'))).toBe(true);
});
it('rejects builder_query', () => {
expect(isAIBuilderEnvelope(envelope('builder_query'))).toBe(false);
});
});
describe('isBuilderPluginKind', () => {
it.each(['signoz/BuilderQuery', 'signoz/AIBuilderQuery'])(
'accepts %s',
(kind) => {
expect(isBuilderPluginKind(kind)).toBe(true);
},
);
it.each(['signoz/CompositeQuery', 'signoz/PromQLQuery'])(
'rejects %s',
(kind) => {
expect(isBuilderPluginKind(kind)).toBe(false);
},
);
});
});

View File

@@ -61,6 +61,16 @@ describe('panelTypeDataSourceFormValuesMap', () => {
expect(uncovered).toStrictEqual([]);
});
// The AI tab is derived from this tag, so a panel that drops it on a switch falls
// back to plain Query Builder mid-edit.
it('carries the AI query tag into every panel type', () => {
Object.values(panelTypeDataSourceFormValuesMap).forEach((sources) => {
expect(sources[DataSource.TRACES].builder.queryData).toContain(
'builderQueryType',
);
});
});
it('shares one builder surface between logs and traces', () => {
Object.values(panelTypeDataSourceFormValuesMap).forEach((sources) => {
expect(sources[DataSource.LOGS].builder.queryData).toStrictEqual(

View File

@@ -0,0 +1,47 @@
import type {
Querybuildertypesv5QueryEnvelopeBuilderAIDTO,
Querybuildertypesv5QueryEnvelopeBuilderDTO,
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesQueryPluginKindDTO,
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
} from 'api/generated/services/sigNoz.schemas';
const BUILDER_ENVELOPE_TYPES: string[] = [
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query,
];
/** True for `builder_query` and `builder_ai_query`. Formulas and trace operators are excluded. */
export function isBuilderEnvelope(
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): envelope is
| Querybuildertypesv5QueryEnvelopeBuilderDTO
| Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
return BUILDER_ENVELOPE_TYPES.includes(envelope.type);
}
export function isAIBuilderEnvelope(
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): envelope is Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
return (
envelope.type ===
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query
);
}
const BUILDER_PLUGIN_KINDS: string[] = [
DashboardtypesQueryPluginKindDTO['signoz/BuilderQuery'],
DashboardtypesQueryPluginKindDTO['signoz/AIBuilderQuery'],
];
/**
* True for a bare `signoz/BuilderQuery` or `signoz/AIBuilderQuery` plugin (the form List
* panels use). Inside a CompositeQuery an AI query is a `builder_ai_query` envelope, matched
* by `isBuilderEnvelope`.
*/
export function isBuilderPluginKind(kind: string): boolean {
return BUILDER_PLUGIN_KINDS.includes(kind);
}

View File

@@ -44,6 +44,8 @@ const IS_CARRIED = {
orderBy: true,
reduceTo: true,
legend: true,
// Identifies an AI query, so the panel keeps authoring in AI mode after the switch.
builderQueryType: true,
// `dataSource` is appended by the provider; the rest drive surfaces this switch
// does not reach.
// Heatmap only; listed for it below.
@@ -54,7 +56,6 @@ const IS_CARRIED = {
offset: false,
selectColumns: false,
source: false,
builderQueryType: false,
} satisfies Record<BuilderField, boolean>;
function omit(

View File

@@ -0,0 +1,51 @@
import {
BuilderVariant,
type Query,
QueryMode,
} from 'types/api/queryBuilder/queryBuilderData';
/** Which variant the `builder` slot holds, whatever query type is active. */
export function getBuilderVariant(builder: Query['builder']): BuilderVariant {
return builder.queryData[0]?.builderQueryType === QueryMode.AI_QUERY_BUILDER
? BuilderVariant.AI
: BuilderVariant.MAIN;
}
/** The variant a tab authors; `undefined` for tabs that own their own slot. */
export function toBuilderVariant(mode: QueryMode): BuilderVariant | undefined {
if (mode === QueryMode.AI_QUERY_BUILDER) {
return BuilderVariant.AI;
}
return mode === QueryMode.QUERY_BUILDER ? BuilderVariant.MAIN : undefined;
}
/** The tab a query belongs to: its query type, or AI when a builder query carries the tag. */
export function getQueryMode(query: Query): QueryMode {
if (query.queryType !== QueryMode.QUERY_BUILDER) {
return query.queryType;
}
return getBuilderVariant(query.builder) === BuilderVariant.AI
? QueryMode.AI_QUERY_BUILDER
: QueryMode.QUERY_BUILDER;
}
/** Query Builder and AI both author `Query.builder`; the other modes own their own slot. */
export function isBuilderMode(mode: QueryMode): boolean {
return toBuilderVariant(mode) !== undefined;
}
/** Drops the AI tag, leaving the same query readable as a plain builder query. */
export function withoutAIQueryTag(query: Query): Query {
if (getBuilderVariant(query.builder) !== BuilderVariant.AI) {
return query;
}
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map(
({ builderQueryType: _tag, ...queryData }) => queryData,
),
},
};
}

View File

@@ -80,4 +80,31 @@ describe('getPanelIdsReferencingVariable', () => {
);
expect(ids.sort()).toStrictEqual(['p1', 'p3']);
});
it('includes an AI list panel and an AI composite panel', () => {
const aiList = listPanel('service.name IN $svc');
(
aiList as {
spec: { queries: { spec: { plugin: { kind: string } } }[] };
}
).spec.queries[0].spec.plugin.kind = 'signoz/AIBuilderQuery';
const aiComposite = compositePanel('service.name IN $svc');
(
aiComposite as {
spec: {
queries: {
spec: { plugin: { spec: { queries: { type: string }[] } } };
}[];
};
}
).spec.queries[0].spec.plugin.spec.queries[0].type = 'builder_ai_query';
const ids = getPanelIdsReferencingVariable(
panels({ aiList, aiComposite }),
'service.name',
'svc',
);
expect(ids.sort()).toStrictEqual(['aiComposite', 'aiList']);
});
});

View File

@@ -6,6 +6,11 @@ import type {
Querybuildertypesv5QueryEnvelopeBuilderDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
isBuilderEnvelope,
isBuilderPluginKind,
} from 'lib/query/builderEnvelope';
function clauseFor(attribute: string, variableName: string): string {
return `${attribute} IN $${variableName}`;
}
@@ -20,15 +25,13 @@ function forEachBuilderSpec(
}
if (plugin.kind === 'signoz/CompositeQuery') {
const composite = plugin.spec as Querybuildertypesv5CompositeQueryDTO;
(composite.queries ?? [])
.filter((envelope) => envelope.type === 'builder_query')
.forEach((envelope) => {
const { spec } = envelope as Querybuildertypesv5QueryEnvelopeBuilderDTO;
if (spec) {
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
});
} else if (plugin.kind === 'signoz/BuilderQuery') {
(composite.queries ?? []).filter(isBuilderEnvelope).forEach((envelope) => {
const { spec } = envelope as Querybuildertypesv5QueryEnvelopeBuilderDTO;
if (spec) {
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
});
} else if (isBuilderPluginKind(plugin.kind)) {
fn(plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
}

View File

@@ -11,6 +11,7 @@ import {
textContainsVariableReference,
} from 'lib/dashboardVariables/variableReference';
import { isBuilderEnvelope } from 'lib/query/builderEnvelope';
import { toQueryEnvelopes } from '../../../queryV5/buildQueryRangeRequest';
import { getTextPanelBody } from './getTextPanelBody';
import { dtoToFormModel } from '../variableAdapters';
@@ -54,7 +55,7 @@ function envelopeReferenceText(
const spec = envelope.spec as
| { query?: string; filter?: { expression?: string } }
| undefined;
if (envelope.type === 'builder_query') {
if (isBuilderEnvelope(envelope)) {
const text = spec?.filter?.expression;
return typeof text === 'string' ? { kind: 'builder', text } : null;
}
@@ -232,7 +233,7 @@ export function findApplyUsages(
});
};
if (envelope.type === 'builder_query') {
if (isBuilderEnvelope(envelope)) {
const spec = envelope.spec as
| { filter?: { expression?: string } }
| undefined;
@@ -294,7 +295,7 @@ export function isVariableAppliedToAllPanels(
return true;
}
return toQueryEnvelopes(queries).every((envelope) => {
if (envelope.type === 'builder_query') {
if (isBuilderEnvelope(envelope)) {
const spec = envelope.spec as
| { filter?: { expression?: string } }
| undefined;

View File

@@ -15,10 +15,10 @@ import type { EQueryType } from 'types/common/dashboard';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import ConfigActions from './ConfigActions/ConfigActions';
import PanelTitleInput from './PanelTitleInput/PanelTitleInput';
import SectionSlot from './SectionSlot/SectionSlot';
import styles from './ConfigPane.module.scss';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
import { PanelKind } from '../../Panels/types/panelKind';
interface ConfigPaneProps {
@@ -106,12 +106,9 @@ function ConfigPane({
<div className={styles.group}>
<div className={styles.field}>
<Typography.Text>Title</Typography.Text>
<Input
data-testid="panel-editor-v2-title"
<PanelTitleInput
value={spec.display.name}
placeholder="Panel title"
maxLength={DASHBOARD_NAME_MAX_LENGTH}
onChange={(e): void => setDisplayField('name', e.target.value)}
onChange={(value): void => setDisplayField('name', value)}
/>
</div>

View File

@@ -0,0 +1,107 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent } from 'react';
import { Input } from '@signozhq/ui/input';
import { AutoComplete } from 'antd';
import { useDashboardVariableNames } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../constants';
import { findVariableToken, insertVariable } from './variableToken';
import styles from './PanelTitleInput.module.scss';
interface PanelTitleInputProps {
value: string;
onChange: (value: string) => void;
}
interface VariableOption {
/** Whole title after insertion, so antd's change event carries it. */
value: string;
label: string;
cursor: number;
}
function PanelTitleInput({
value,
onChange,
}: PanelTitleInputProps): JSX.Element {
const variableNames = useDashboardVariableNames();
const inputRef = useRef<HTMLInputElement>(null);
const pendingCursor = useRef<number | null>(null);
const [cursor, setCursor] = useState(0);
const [focused, setFocused] = useState(false);
const [dismissed, setDismissed] = useState(false);
const options = useMemo<VariableOption[]>(() => {
const token = findVariableToken(value, cursor);
if (!token) {
return [];
}
const query = token.query.toLowerCase();
return variableNames
.filter((name) => name.toLowerCase().startsWith(query))
.map((name) => {
const next = insertVariable(value, token, name);
return { value: next.text, label: name, cursor: next.cursor };
});
}, [value, cursor, variableNames]);
useLayoutEffect(() => {
if (pendingCursor.current === null) {
return;
}
inputRef.current?.setSelectionRange(
pendingCursor.current,
pendingCursor.current,
);
setCursor(pendingCursor.current);
pendingCursor.current = null;
}, [value]);
const syncCursor = (): void => {
setCursor(inputRef.current?.selectionStart ?? 0);
};
const handleChange = (next: string): void => {
const picked = options.find((option) => option.value === next);
if (picked) {
pendingCursor.current = picked.cursor;
setDismissed(true);
} else {
setDismissed(false);
syncCursor();
}
onChange(next);
};
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Escape') {
setDismissed(true);
}
};
return (
<AutoComplete
className={styles.autoComplete}
value={value}
maxLength={DASHBOARD_NAME_MAX_LENGTH}
options={options}
open={focused && !dismissed && options.length > 0}
filterOption={false}
onChange={handleChange}
>
<Input
ref={inputRef}
testId="panel-editor-v2-title"
placeholder="Panel title"
onKeyDown={handleKeyDown}
onKeyUp={syncCursor}
onClick={syncCursor}
onFocus={(): void => setFocused(true)}
onBlur={(): void => setFocused(false)}
/>
</AutoComplete>
);
}
export default PanelTitleInput;

View File

@@ -0,0 +1,51 @@
import { useState } from 'react';
import { render, screen, userEvent } from 'tests/test-utils';
import PanelTitleInput from '../PanelTitleInput';
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames',
() => ({
useDashboardVariableNames: (): string[] => ['service.name', 'env'],
}),
);
function Harness({ initial = '' }: { initial?: string }): JSX.Element {
const [value, setValue] = useState(initial);
return <PanelTitleInput value={value} onChange={setValue} />;
}
describe('PanelTitleInput', () => {
it('suggests no variables until a `$` is typed', async () => {
const user = userEvent.setup();
render(<Harness />);
await user.type(screen.getByTestId('panel-editor-v2-title'), 'Latency');
expect(screen.queryByText('env')).not.toBeInTheDocument();
});
it('suggests variables matching the text after `$`', async () => {
const user = userEvent.setup();
render(<Harness />);
await user.type(
screen.getByTestId('panel-editor-v2-title'),
'Latency of $se',
);
await expect(screen.findByText('service.name')).resolves.toBeInTheDocument();
expect(screen.queryByText('env')).not.toBeInTheDocument();
});
it('inserts the picked variable into the title', async () => {
const user = userEvent.setup();
render(<Harness />);
const input = screen.getByTestId('panel-editor-v2-title');
await user.type(input, 'Latency of $se');
await user.click(await screen.findByText('service.name'));
expect(input).toHaveValue('Latency of $service.name');
});
});

View File

@@ -0,0 +1,30 @@
import { findVariableToken, insertVariable } from '../variableToken';
describe('findVariableToken', () => {
it('finds the token being typed before the cursor', () => {
expect(findVariableToken('Latency of $ser', 15)).toStrictEqual({
start: 11,
query: 'ser',
});
});
it('matches a bare `$`', () => {
expect(findVariableToken('by $', 4)).toStrictEqual({ start: 3, query: '' });
});
it('ignores a token the cursor has moved past', () => {
expect(findVariableToken('$env in prod', 12)).toBeNull();
});
});
describe('insertVariable', () => {
it('replaces the whole token, including text after the cursor', () => {
const token = { start: 4, query: 'ser' };
expect(
insertVariable('p99 $service_x for', token, 'service.name'),
).toStrictEqual({
text: 'p99 $service.name for',
cursor: 17,
});
});
});

View File

@@ -0,0 +1,30 @@
const TOKEN_BEFORE_CURSOR = /\$([\w.]*)$/;
const IDENTIFIER_PREFIX = /^[\w.]*/;
export interface VariableToken {
start: number;
query: string;
}
export function findVariableToken(
text: string,
cursor: number,
): VariableToken | null {
const match = TOKEN_BEFORE_CURSOR.exec(text.slice(0, cursor));
if (!match) {
return null;
}
return { start: match.index, query: match[1] };
}
/** Also replaces identifier chars after the cursor: `$ser|vice` leaves no `vice`. */
export function insertVariable(
text: string,
token: VariableToken,
name: string,
): { text: string; cursor: number } {
const before = text.slice(0, token.start);
const after = text.slice(token.start + 1).replace(IDENTIFIER_PREFIX, '');
const inserted = `${before}$${name}`;
return { text: `${inserted}${after}`, cursor: inserted.length };
}

View File

@@ -8,6 +8,7 @@ import type { EQueryType } from 'types/common/dashboard';
import PanelTypeBrowser from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeBrowser';
import { getPanelDefinition } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { JS_DRIVEN_SLIDE } from '../../../utils/drawerSlide';
import ConfigField from '../controls/ConfigField/ConfigField';
import styles from './PanelTypeSwitcher.module.scss';
@@ -102,6 +103,7 @@ function PanelTypeSwitcher({
direction="right"
width="wide"
testId="panel-type-switcher-drawer"
drawerContentProps={JS_DRIVEN_SLIDE}
drawerDescriptionProps={{ className: styles.drawerBody }}
>
<PanelTypeBrowser

View File

@@ -71,19 +71,29 @@ describe('PanelTypeSwitcher', () => {
jest.clearAllMocks();
// List supports only logs/traces; every other kind also supports metrics.
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
mockGetPanelDefinition.mockImplementation((kind: string) => ({
...OPTIONS.find((option) => option.kind === kind),
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
supportedSignals:
mockGetPanelDefinition.mockImplementation((kind: string) => {
const signals =
kind === 'signoz/ListPanel'
? ['logs', 'traces']
: ['metrics', 'logs', 'traces'],
supportedQueryTypes: SUPPORTED_QUERY_TYPES[kind] ?? [
: ['metrics', 'logs', 'traces'];
const queryTypes = SUPPORTED_QUERY_TYPES[kind] ?? [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
}));
];
return {
...OPTIONS.find((option) => option.kind === kind),
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
supportedQueryModesKindMap: Object.fromEntries(
queryTypes.map((queryType) => [
queryType,
queryType === EQueryType.QUERY_BUILDER
? { kind: 'signal', signals }
: { kind: 'signal-less' },
]),
),
};
});
});
it('shows the current type and switches to the chosen one', async () => {

View File

@@ -23,6 +23,11 @@ jest.mock(
}),
);
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames',
() => ({ useDashboardVariableNames: (): string[] => [] }),
);
function textSpec(): DashboardtypesPanelSpecDTO {
return {
display: { name: 'Runbook', description: 'steps' },

View File

@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { dtoToFormModel } from 'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters';
import { useDashboardFetchRequired } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardFetchRequired';
import { useDashboardVariableNames } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames';
import type { VariableItem } from './types';
@@ -22,15 +21,7 @@ const GLOBAL_TIMESTAMP_VARIABLES: VariableItem[] = [
export function useContextLinkVariables(): VariableItem[] {
const { currentQuery } = useQueryBuilder();
const { variables: variableDtos } = useDashboardFetchRequired();
const dashboardVariableNames = useMemo(
() =>
variableDtos
.map((dto) => dtoToFormModel(dto).name)
.filter((name): name is string => !!name),
[variableDtos],
);
const dashboardVariableNames = useDashboardVariableNames();
// `_`-prefixed to match V1 and avoid colliding with dashboard-variable names.
const fieldVariableNames = useMemo(() => {

View File

@@ -14,8 +14,11 @@ jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
displayName: 'Time Series',
icon: (): null => null,
mode: 'query',
supportedSignals: ['metrics', 'logs', 'traces'],
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
supportedQueryModesKindMap: {
builder: { kind: 'signal', signals: ['metrics', 'logs', 'traces'] },
clickhouse_sql: { kind: 'signal-less' },
promql: { kind: 'signal-less' },
},
})),
PANEL_OPTIONS: [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },

View File

@@ -2,26 +2,33 @@ import {
type KeyboardEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Atom, Terminal } from '@signozhq/icons';
import { Atom, Brain, Terminal } from '@signozhq/icons';
import { Tabs } from 'antd';
import cx from 'classnames';
import { Typography } from '@signozhq/ui/typography';
import PromQLIcon from 'assets/Dashboard/PromQl';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { clearPreviousQuery } from 'components/QueryBuilderV2/QueryV2/previousQuery.utils';
import TextToolTip from 'components/TextToolTip';
import ClickHouseQueryContainer from 'container/QueryBuilder/rawQueryEditors/ClickHouse';
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 { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { isRawRequest } from '../../Panels/types/panelCapabilities';
import {
getQueryModeSignals,
isRawRequest,
} from '../../Panels/types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
import { toPanelType } from '../../Panels/types/panelKind';
import { getQueryMode, isBuilderMode } from 'lib/query/queryMode';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import styles from './PanelEditorQueryBuilder.module.scss';
@@ -29,6 +36,8 @@ interface PanelEditorQueryBuilderProps {
/** The edited kind's definition — drives supported query types, the signals the
* builder may be pointed at, and how it narrows the builder's fields. */
panelDefinition: RenderableQueryPanelDefinition;
/** Switch authoring tab; owned by the host, which remembers the Query Builder query. */
onChangeQueryMode: (key: string) => void;
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
isLoadingQueries: boolean;
/** Run the current query (Stage & Run button / ⌘↵). Always re-runs. */
@@ -48,6 +57,7 @@ interface PanelEditorQueryBuilderProps {
*/
function PanelEditorQueryBuilder({
panelDefinition,
onChangeQueryMode,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
@@ -60,18 +70,16 @@ function PanelEditorQueryBuilder({
// Raw rows: the builder drops its aggregation controls, and with them the trace
// operator that combines aggregated trace queries (V1 parity).
const isRawQuery = isRawRequest(panelDefinition.queryCapabilities);
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const { currentQuery } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
const handleQueryCategoryChange = useCallback(
(queryType: string): void => {
redirectWithQueryBuilderData({
...currentQuery,
queryType: queryType as EQueryType,
});
},
[currentQuery, redirectWithQueryBuilderData],
);
// Cleared here, not in QueryBuilderV2: builder panes unmount on every tab switch.
useEffect(() => {
clearPreviousQuery();
return (): void => {
clearPreviousQuery();
};
}, []);
// ⌘↵ / Ctrl+↵ stages and runs the query. Handled locally because the global
// hotkeys provider ignores keydowns from inputs / the query editor, and on the
@@ -88,10 +96,11 @@ function PanelEditorQueryBuilder({
);
const items = useMemo(() => {
const { supportedQueryTypes } = panelDefinition;
const queryTypeComponents = {
[EQueryType.QUERY_BUILDER]: {
const queryTypeComponents: Record<
QueryMode,
{ icon: ReactNode; label: string; component: ReactNode }
> = {
[QueryMode.QUERY_BUILDER]: {
icon: <Atom size={14} />,
label: 'Query Builder',
component: (
@@ -99,7 +108,10 @@ function PanelEditorQueryBuilder({
<QueryBuilderV2
panelType={panelType}
fieldsConfig={panelDefinition.queryBuilderFields}
allowedDataSources={panelDefinition.supportedSignals}
allowedDataSources={getQueryModeSignals(
panelDefinition.supportedQueryModesKindMap,
QueryMode.QUERY_BUILDER,
)}
showTraceOperator={!isRawQuery}
version="v3"
isRawQuery={isRawQuery}
@@ -109,12 +121,12 @@ function PanelEditorQueryBuilder({
</div>
),
},
[EQueryType.CLICKHOUSE]: {
[QueryMode.CLICKHOUSE]: {
icon: <Terminal size={14} />,
label: 'ClickHouse Query',
component: <ClickHouseQueryContainer />,
},
[EQueryType.PROM]: {
[QueryMode.PROM]: {
icon: (
<PromQLIcon
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
@@ -123,9 +135,33 @@ function PanelEditorQueryBuilder({
label: 'PromQL',
component: <PromQLQueryContainer />,
},
[QueryMode.AI_QUERY_BUILDER]: {
icon: <Brain size={14} />,
label: 'AI Query Builder',
component: (
<div className="query-builder-v2-container">
<QueryBuilderV2
panelType={panelType}
fieldsConfig={panelDefinition.queryBuilderFields}
showTraceOperator={false}
version="v3"
isRawQuery={isRawQuery}
config={{
initialDataSource: DataSource.TRACES,
queryVariant: 'static',
}}
savePreviousQuery
/>
</div>
),
},
};
return supportedQueryTypes.map((queryType) => ({
const modes = Object.keys(
panelDefinition.supportedQueryModesKindMap,
) as QueryMode[];
return modes.map((queryType) => ({
key: queryType,
label: (
<div className={styles.queryTypeTab}>
@@ -134,6 +170,8 @@ function PanelEditorQueryBuilder({
</div>
),
children: queryTypeComponents[queryType].component,
// QB wants a null data source and AI pins traces: both mounted, they overwrite each other's provider config in a loop.
destroyInactiveTabPane: isBuilderMode(queryType),
}));
}, [panelDefinition, panelType, isDarkMode, isRawQuery]);
@@ -150,8 +188,8 @@ function PanelEditorQueryBuilder({
className={cx(styles.tabsContainer, {
[styles.stickyNav]: stickyHeader,
})}
activeKey={currentQuery.queryType}
onChange={handleQueryCategoryChange}
activeKey={getQueryMode(currentQuery)}
onChange={onChangeQueryMode}
tabBarExtraContent={
<span className={styles.runQueryBtnContainer}>
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />

View File

@@ -8,6 +8,7 @@ import PanelEditorQueryBuilder from './PanelEditorQueryBuilder';
function QueryBuilderEditorPane({
panelDefinition,
isLoadingQueries,
onChangeQueryMode,
onStageRunQuery,
onCancelQuery,
stickyHeader,
@@ -16,6 +17,7 @@ function QueryBuilderEditorPane({
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
isLoadingQueries={isLoadingQueries}
onChangeQueryMode={onChangeQueryMode}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}
stickyHeader={stickyHeader}

View File

@@ -1,11 +1,17 @@
import { render, screen } from '@testing-library/react';
import { useRef } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
toPanelType,
type PanelKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { BuildersByVariant } from 'types/api/queryBuilder/queryBuilderData';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
import { useQueryModeChange } from '../../hooks/useQueryModeChange';
// Capture the props the (real-guard-fed) QueryBuilderV2 receives without rendering it.
const mockQueryBuilderV2 = jest.fn();
@@ -43,17 +49,41 @@ jest.mock('assets/Dashboard/PromQl', () => ({
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
function renderBuilder(panelKind: string): void {
render(
const BUILDER_QUERY = {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [{ dataSource: 'traces' }] },
};
const AI_QUERY = {
queryType: EQueryType.QUERY_BUILDER,
builder: {
queryData: [{ dataSource: 'traces', builderQueryType: 'builder_ai_query' }],
},
};
/** Stands in for the editor shell: owns the tab switch and its parked Query Builder query. */
function BuilderHost({ panelKind }: { panelKind: PanelKind }): JSX.Element {
const panelDefinition = requireQueryPanelDefinition(panelKind);
const parkedBuilders = useRef<BuildersByVariant>({});
const onChangeQueryMode = useQueryModeChange({
panelType: toPanelType(panelKind),
supportedQueryModesKindMap: panelDefinition.supportedQueryModesKindMap,
parkedBuilders,
});
return (
<PanelEditorQueryBuilder
panelDefinition={requireQueryPanelDefinition(panelKind as PanelKind)}
panelDefinition={panelDefinition}
onChangeQueryMode={onChangeQueryMode}
isLoadingQueries={false}
onStageRunQuery={jest.fn()}
onCancelQuery={jest.fn()}
/>,
/>
);
}
function renderBuilder(panelKind: string): void {
render(<BuilderHost panelKind={panelKind as PanelKind} />);
}
function lastQueryBuilderProps(): {
panelType: string;
isRawQuery: boolean;
@@ -69,12 +99,12 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryBuilder.mockReturnValue({
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
currentQuery: BUILDER_QUERY,
redirectWithQueryBuilderData: jest.fn(),
});
});
it('shows only the Query Builder tab for the List kind', () => {
it('shows no ClickHouse or PromQL tab for the List kind', () => {
renderBuilder('signoz/ListPanel');
expect(screen.getByText('Query Builder')).toBeInTheDocument();
@@ -90,12 +120,126 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
});
it('shows all three tabs for the Time Series kind', () => {
it('shows all four tabs for the Time Series kind', () => {
renderBuilder('signoz/TimeSeriesPanel');
expect(screen.getByText('Query Builder')).toBeInTheDocument();
expect(screen.getByText('ClickHouse Query')).toBeInTheDocument();
expect(screen.getByText('PromQL')).toBeInTheDocument();
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
});
it('shows the AI tab for the Table kind', () => {
renderBuilder('signoz/TablePanel');
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
});
it('shows the AI tab for the List kind', () => {
renderBuilder('signoz/ListPanel');
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
});
});
describe('PanelEditorQueryBuilder AI tab', () => {
const redirectWithQueryBuilderData = jest.fn();
const updateAllQueriesOperators = jest.fn((query: unknown) => query);
function mockBuilder(currentQuery: unknown): void {
mockUseQueryBuilder.mockReturnValue({
currentQuery,
redirectWithQueryBuilderData,
updateAllQueriesOperators,
});
}
beforeEach(() => {
jest.clearAllMocks();
});
it('activates the AI tab for an AI query and pins the builder to traces', () => {
mockBuilder(AI_QUERY);
renderBuilder('signoz/TimeSeriesPanel');
expect(
screen.getByRole('tab', { name: 'AI Query Builder', selected: true }),
).toBeInTheDocument();
expect(lastQueryBuilderProps()).toMatchObject({
config: { initialDataSource: 'traces', queryVariant: 'static' },
showTraceOperator: false,
});
});
it('seeds an AI traces query when switching to the AI tab', () => {
mockBuilder(BUILDER_QUERY);
renderBuilder('signoz/TimeSeriesPanel');
fireEvent.click(screen.getByText('AI Query Builder'));
expect(updateAllQueriesOperators).toHaveBeenCalledWith(
expect.anything(),
'graph',
'traces',
);
const [seeded] = redirectWithQueryBuilderData.mock.calls[0];
expect(seeded.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(seeded.builder.queryData[0].builderQueryType).toBe('builder_ai_query');
});
it('keeps the builder query when returning from ClickHouse', () => {
const logsQuery = {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [{ dataSource: 'logs', legend: 'kept' }] },
};
mockBuilder(logsQuery);
renderBuilder('signoz/TimeSeriesPanel');
fireEvent.click(screen.getByText('ClickHouse Query'));
const [onClickHouse] = redirectWithQueryBuilderData.mock.calls[0];
expect(onClickHouse.queryType).toBe(EQueryType.CLICKHOUSE);
// The builder slot already holds the Query Builder query — nothing to swap.
expect(onClickHouse.builder).toBe(logsQuery.builder);
});
it('shows a fresh default query when leaving AI', () => {
mockBuilder(AI_QUERY);
renderBuilder('signoz/TimeSeriesPanel');
fireEvent.click(screen.getByText('Query Builder'));
const [next] = redirectWithQueryBuilderData.mock.calls[0];
expect(next.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(next.builder.queryData[0].builderQueryType).toBeUndefined();
expect(next.builder.queryData[0].dataSource).toBe('metrics');
});
it("keeps each tab's own query across Query Builder ↔ AI round trips", () => {
const logsQuery = {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [{ dataSource: 'logs' }] },
};
const host = (): JSX.Element => (
<BuilderHost panelKind="signoz/TimeSeriesPanel" />
);
mockBuilder(logsQuery);
const { rerender } = render(host());
fireEvent.click(screen.getByText('AI Query Builder'));
const [aiQuery] = redirectWithQueryBuilderData.mock.calls[0];
// The user writes an AI query, then goes back to Query Builder.
const editedAIBuilder = {
queryData: [{ dataSource: 'traces', builderQueryType: 'builder_ai_query' }],
};
mockBuilder({ ...aiQuery, builder: editedAIBuilder });
rerender(host());
fireEvent.click(screen.getByText('Query Builder'));
const [backToQB] = redirectWithQueryBuilderData.mock.calls[1];
expect(backToQB.builder).toBe(logsQuery.builder);
mockBuilder(backToQB);
rerender(host());
fireEvent.click(screen.getByText('AI Query Builder'));
const [backToAI] = redirectWithQueryBuilderData.mock.calls[2];
expect(backToAI.builder).toBe(editedAIBuilder);
});
});
@@ -103,7 +247,7 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryBuilder.mockReturnValue({
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
currentQuery: BUILDER_QUERY,
redirectWithQueryBuilderData: jest.fn(),
});
});
@@ -144,7 +288,7 @@ describe('PanelEditorQueryBuilder signal dropdown (driven by the capabilities gu
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryBuilder.mockReturnValue({
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
currentQuery: BUILDER_QUERY,
redirectWithQueryBuilderData: jest.fn(),
});
});

View File

@@ -71,6 +71,8 @@ interface QueryEditorBodyProps {
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
onChangePanelKind: (kind: PanelKind) => void;
originalPanelKind?: PanelKind;
/** Tab switch, owned by the shell alongside the kind switch it shares memory with. */
onChangeQueryMode: (key: string) => void;
}
/**
@@ -92,6 +94,7 @@ function QueryEditorBody({
panelDefinition,
onChangePanelKind,
originalPanelKind,
onChangeQueryMode,
}: QueryEditorBodyProps): JSX.Element {
// Read here rather than taken as props: this renders inside a loaded dashboard
// subtree, so it resolves the same context every other consumer does.
@@ -307,6 +310,7 @@ function QueryEditorBody({
panelDefinition={panelDefinition}
signal={listSignal}
isLoadingQueries={isFetching}
onChangeQueryMode={onChangeQueryMode}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
spec={spec}

View File

@@ -60,8 +60,9 @@ jest.mock('../hooks/usePanelEditorQuerySync', () => ({
}));
const mockUseTypeSwitch = jest.fn();
jest.mock('../hooks/usePanelTypeSwitch', () => ({
usePanelTypeSwitch: (args: unknown): unknown => mockUseTypeSwitch(args),
jest.mock('../hooks/usePanelKindAndQueryModeSwitch', () => ({
usePanelKindAndQueryModeSwitch: (args: unknown): unknown =>
mockUseTypeSwitch(args),
}));
jest.mock('../hooks/usePanelEditorSave', () => ({

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { useRef, useState } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider as ReduxProvider } from 'react-redux';
@@ -21,6 +21,8 @@ import { usePanelEditorQuerySync } from '../hooks/usePanelEditorQuerySync';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import { useQueryModeChange } from '../hooks/useQueryModeChange';
import type { BuildersByVariant } from 'types/api/queryBuilder/queryBuilderData';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () => {
@@ -82,10 +84,18 @@ function EditorRoute(): JSX.Element {
signal: TelemetrytypesSignalDTO.metrics,
savedQueries: panel.spec.queries,
});
const panelDefinition = requireQueryPanelDefinition('signoz/TimeSeriesPanel');
const parkedBuilders = useRef<BuildersByVariant>({});
const onChangeQueryMode = useQueryModeChange({
panelType: PANEL_TYPES.TIME_SERIES,
supportedQueryModesKindMap: panelDefinition.supportedQueryModesKindMap,
parkedBuilders,
});
return (
<PanelEditorQueryBuilder
panelDefinition={requireQueryPanelDefinition('signoz/TimeSeriesPanel')}
panelDefinition={panelDefinition}
onChangeQueryMode={onChangeQueryMode}
isLoadingQueries={false}
onStageRunQuery={noop}
onCancelQuery={noop}

View File

@@ -45,8 +45,16 @@ const SAVED_QUERIES = [{ id: 'saved' }] as unknown as NonNullable<
const CONVERTED_QUERIES = [{ id: 'converted' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const SEED_V1 = { id: 'seed', queryType: 'builder' } as unknown as Query;
const STAGED_V1 = { id: 'staged', queryType: 'builder' } as unknown as Query;
const SEED_V1 = {
id: 'seed',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
const STAGED_V1 = {
id: 'staged',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
function makeDraft(
queries = SAVED_QUERIES,
@@ -74,7 +82,11 @@ function builderState(
handleRunQuery: jest.Mock;
} {
return {
currentQuery: { id: 'current', queryType: 'builder' } as unknown as Query,
currentQuery: {
id: 'current',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query,
stagedQuery: STAGED_V1,
handleRunQuery: jest.fn(),
...overrides,
@@ -396,8 +408,16 @@ describe('usePanelEditorQuerySync', () => {
const EDITED_ENVELOPES = [
{ id: 'edited-envelopes' },
] as unknown as NonNullable<DashboardtypesPanelSpecDTO['queries']>;
const editedQuery = { id: 'edited', queryType: 'builder' } as Query;
const unchangedQuery = { id: 'unchanged', queryType: 'builder' } as Query;
const editedQuery = {
id: 'edited',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
const unchangedQuery = {
id: 'unchanged',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
beforeEach(() => {
mockToPerses.mockImplementation((query: Query) =>
@@ -468,4 +488,66 @@ describe('usePanelEditorQuerySync', () => {
expect(result.current.isQueryDirty).toBe(false);
});
});
describe('AI queries are saved like any builder query', () => {
const aiQuery = {
id: 'ai',
queryType: 'builder',
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
} as unknown as Query;
it('keeps the AI tag when seeding, so a saved AI panel reopens on the AI tab', () => {
const savedAiQuery = {
id: 'saved-ai',
queryType: 'builder',
builder: {
queryData: [
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
],
},
} as unknown as Query;
mockFromPerses.mockReturnValue(savedAiQuery);
setup({ savedQueries: SAVED_QUERIES });
const [seeded] = mockUseShareBuilderUrl.mock.calls[0];
expect(seeded.defaultValue).toBe(savedAiQuery);
});
it('serializes the live AI query, not the seed', () => {
// Input-sensitive so the envelope compare reads dirty and names what was serialized.
mockToPerses.mockImplementation((query: Query) =>
query?.id === 'ai' ? CONVERTED_QUERIES : SAVED_QUERIES,
);
mockUseQueryBuilder.mockReturnValue(builderState({ currentQuery: aiQuery }));
const { result } = setup({ savedQueries: SAVED_QUERIES });
const { spec } = makeDraft();
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
...spec,
queries: CONVERTED_QUERIES,
});
});
it('still saves a ClickHouse query while an AI query sits in the builder slot', () => {
const clickHouseQuery = {
...aiQuery,
id: 'ch',
queryType: 'clickhouse_sql',
} as unknown as Query;
// Input-sensitive so the envelope compare reads dirty and the normal path runs.
mockToPerses.mockImplementation((query: Query) =>
query?.id === 'ch' ? CONVERTED_QUERIES : SAVED_QUERIES,
);
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: clickHouseQuery }),
);
const { result } = setup({ savedQueries: SAVED_QUERIES });
const { spec } = makeDraft();
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
...spec,
queries: CONVERTED_QUERIES,
});
});
});
});

View File

@@ -5,11 +5,11 @@ import { handleQueryChange } from 'lib/query/panelQuery';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../../Panels/capabilities';
import { resolveQueryMode } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
import { usePanelTypeSwitch } from '../usePanelTypeSwitch';
import { usePanelKindAndQueryModeSwitch } from '../usePanelKindAndQueryModeSwitch';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
@@ -18,7 +18,10 @@ jest.mock('lib/query/panelQuery', () => ({
handleQueryChange: jest.fn(),
}));
jest.mock('../../../Panels/capabilities', () => ({
resolveQueryType: jest.fn(),
resolveQueryMode: jest.fn(),
getSupportedSignals: jest.fn(() => ['metrics']),
getQueryPanelDefinition: jest.requireActual('../../../Panels/capabilities')
.getQueryPanelDefinition,
// Real predicate: these specs use real (query) kinds and the static path is
// exercised through its own cases below.
isStaticPanelKind: jest.requireActual('../../../Panels/capabilities')
@@ -36,7 +39,7 @@ jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
const mockResolveQueryType = resolveQueryType as unknown as jest.Mock;
const mockResolveQueryMode = resolveQueryMode as unknown as jest.Mock;
const mockToPerses = toPerses as unknown as jest.Mock;
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
@@ -79,14 +82,27 @@ const tableSpec = makeSpec(
);
const listSpec = makeSpec('signoz/ListPanel', LIST_PLUGIN_SPEC, LIST_QUERIES);
function fakeQuery(
id: string,
queryType: string,
queryData: Record<string, unknown>[] = [],
): Query {
return { id, queryType, builder: { queryData } } as unknown as Query;
}
function builderState(currentQuery: Query): {
currentQuery: Query;
redirectWithQueryBuilderData: jest.Mock;
updateAllQueriesOperators: jest.Mock;
} {
return { currentQuery, redirectWithQueryBuilderData: jest.fn() };
return {
currentQuery,
redirectWithQueryBuilderData: jest.fn(),
updateAllQueriesOperators: jest.fn((query: Query) => query),
};
}
describe('usePanelTypeSwitch', () => {
describe('usePanelKindAndQueryModeSwitch', () => {
beforeEach(() => {
jest.clearAllMocks();
mockHandleQueryChange.mockReturnValue(TRANSFORMED);
@@ -95,16 +111,16 @@ describe('usePanelTypeSwitch', () => {
mockGetBuilderQueries.mockReturnValue([{ signal: 'logs' }]);
// The guard owns coercion (tested in capabilities.test.ts); here it always
// resolves to Query Builder so the coerced type flows into handleQueryChange.
mockResolveQueryType.mockReturnValue('builder');
mockResolveQueryMode.mockReturnValue('builder');
});
it('does nothing when switching to the current kind', () => {
const setSpec = jest.fn();
const state = builderState({ id: 'q', queryType: 'builder' } as Query);
const state = builderState(fakeQuery('q', 'builder'));
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelTypeSwitch({
usePanelKindAndQueryModeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
@@ -118,12 +134,12 @@ describe('usePanelTypeSwitch', () => {
it('on first visit: transforms the query and resets the spec to the new kind', () => {
const setSpec = jest.fn();
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const tableQuery = fakeQuery('table-current', 'builder');
const state = builderState(tableQuery);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelTypeSwitch({
usePanelKindAndQueryModeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
@@ -146,7 +162,7 @@ describe('usePanelTypeSwitch', () => {
it('seeds timestamp-desc Order By on every query when switching to a List panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
builderState(fakeQuery('ts-current', 'builder')),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
@@ -155,7 +171,7 @@ describe('usePanelTypeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelTypeSwitch({
usePanelKindAndQueryModeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -174,7 +190,7 @@ describe('usePanelTypeSwitch', () => {
it('keeps one query and drops the formulas when switching to a Heatmap panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
builderState(fakeQuery('ts-current', 'builder')),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
@@ -186,7 +202,7 @@ describe('usePanelTypeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelTypeSwitch({
usePanelKindAndQueryModeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -204,7 +220,7 @@ describe('usePanelTypeSwitch', () => {
it('swaps a percentile spatial aggregation for count when switching to a Heatmap panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
builderState(fakeQuery('ts-current', 'builder')),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
@@ -222,7 +238,7 @@ describe('usePanelTypeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelTypeSwitch({
usePanelKindAndQueryModeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -242,7 +258,7 @@ describe('usePanelTypeSwitch', () => {
it('swaps count back for a percentile when a histogram leaves a Heatmap panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'heatmap-current', queryType: 'builder' } as Query),
builderState(fakeQuery('heatmap-current', 'builder')),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
@@ -263,7 +279,7 @@ describe('usePanelTypeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelTypeSwitch({
usePanelKindAndQueryModeSwitch({
spec: makeSpec('signoz/HeatmapPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.HEATMAP,
setSpec,
@@ -283,7 +299,7 @@ describe('usePanelTypeSwitch', () => {
it('leaves a non-histogram metric on sum when it leaves a Heatmap panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'heatmap-current', queryType: 'builder' } as Query),
builderState(fakeQuery('heatmap-current', 'builder')),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
@@ -304,7 +320,7 @@ describe('usePanelTypeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelTypeSwitch({
usePanelKindAndQueryModeSwitch({
spec: makeSpec('signoz/HeatmapPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.HEATMAP,
setSpec,
@@ -318,11 +334,11 @@ describe('usePanelTypeSwitch', () => {
it('coerces the query type when the new kind disallows it (promql → List)', () => {
const setSpec = jest.fn();
const promQuery = { id: 'prom', queryType: 'promql' } as Query;
const promQuery = fakeQuery('prom', 'promql');
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
const { result } = renderHook(() =>
usePanelTypeSwitch({
usePanelKindAndQueryModeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -331,7 +347,7 @@ describe('usePanelTypeSwitch', () => {
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// The hook asks the guard to resolve the active query type against the new kind…
expect(mockResolveQueryType).toHaveBeenCalledWith(
expect(mockResolveQueryMode).toHaveBeenCalledWith(
'signoz/ListPanel',
'promql',
);
@@ -340,16 +356,224 @@ describe('usePanelTypeSwitch', () => {
expect((queryArg as Query).queryType).toBe('builder');
});
it('keeps the AI tag when the new kind supports AI', () => {
const setSpec = jest.fn();
const aiQuery = fakeQuery('ai', 'builder', [
{ builderQueryType: 'builder_ai_query' },
]);
mockUseQueryBuilder.mockReturnValue(builderState(aiQuery));
mockResolveQueryMode.mockReturnValue('builder_ai_query');
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/TimeSeriesPanel'));
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
expect((queryArg as Query).builder).toBe(aiQuery.builder);
expect((queryArg as Query).builder.queryData[0].builderQueryType).toBe(
'builder_ai_query',
);
});
it('drops the AI tag when the new kind does not support AI', () => {
const setSpec = jest.fn();
const aiQuery = fakeQuery('ai', 'builder', [
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
]);
const state = builderState(aiQuery);
mockUseQueryBuilder.mockReturnValue(state);
mockHandleQueryChange.mockImplementation((_type, query) => query);
// The guard coerces to Query Builder; the tag must not survive it.
mockResolveQueryMode.mockReturnValue('builder');
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/TimeSeriesPanel'));
const redirected = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(redirected.builder.queryData[0].builderQueryType).toBeUndefined();
expect(redirected.builder.queryData[0].dataSource).toBe('traces');
});
it('stays on the AI tab when returning to a kind last left on Query Builder', () => {
const setSpec = jest.fn();
const qbQuery = fakeQuery('qb', 'builder', [{ dataSource: 'logs' }]);
const aiQuery = fakeQuery('ai', 'builder', [
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
]);
let state = builderState(qbQuery);
mockUseQueryBuilder.mockImplementation(() => state);
mockHandleQueryChange.mockImplementation((_type, query) => query);
const { result, rerender } = renderHook(
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
usePanelKindAndQueryModeSwitch({ ...props, setSpec }),
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
);
// Leave Table on Query Builder — its stash holds the QB query.
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// On List the user moves to the AI tab, then returns to Table.
state = builderState(aiQuery);
mockResolveQueryMode.mockReturnValue('builder_ai_query');
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
const redirected = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(redirected.builder.queryData[0].builderQueryType).toBe(
'builder_ai_query',
);
const restored = setSpec.mock.calls[
setSpec.mock.calls.length - 1
][0] as DashboardtypesPanelSpecDTO;
// The kind's own display spec still comes back; only the query follows the tab.
expect(restored.plugin.spec).toBe(TABLE_PLUGIN_SPEC);
expect(restored.queries).toBe(CONVERTED);
// Table's Query Builder query waited behind the AI tab — the tab switch gives it back.
act(() => result.current.onChangeQueryMode('builder'));
const backToQB = state.redirectWithQueryBuilderData.mock.calls[1][0] as Query;
expect(backToQB.builder).toBe(qbQuery.builder);
});
describe('both builder tabs, per kind', () => {
const qbQuery = fakeQuery('qb', 'builder', [{ dataSource: 'logs' }]);
const aiQuery = fakeQuery('ai', 'builder', [
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
]);
// Table gets a Query Builder query and an AI one; the user leaves it on AI for List.
function leaveTableOnAI(): {
result: { current: ReturnType<typeof usePanelKindAndQueryModeSwitch> };
rerender: (props: {
spec: DashboardtypesPanelSpecDTO;
panelType: PANEL_TYPES;
}) => void;
setSpec: jest.Mock;
setState: (query: Query) => ReturnType<typeof builderState>;
} {
const setSpec = jest.fn();
let state = builderState(qbQuery);
mockUseQueryBuilder.mockImplementation(() => state);
mockHandleQueryChange.mockImplementation((_type, query) => query);
const { result, rerender } = renderHook(
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
usePanelKindAndQueryModeSwitch({ ...props, setSpec }),
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
);
const setState = (query: Query): ReturnType<typeof builderState> => {
state = builderState(query);
return state;
};
act(() => result.current.onChangeQueryMode('builder_ai_query'));
setState(aiQuery);
rerender({ spec: tableSpec, panelType: PANEL_TYPES.TABLE });
mockResolveQueryMode.mockReturnValue('builder_ai_query');
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
return { result, rerender, setSpec, setState };
}
it('rebuilds the parked query for the new kind on a first visit', () => {
const { result } = leaveTableOnAI();
// Table's Query Builder query was rebuilt for List, and is one tab away there.
expect(mockHandleQueryChange).toHaveBeenCalledWith(
'list',
expect.objectContaining({ builder: qbQuery.builder }),
PANEL_TYPES.TABLE,
);
act(() => result.current.onChangeQueryMode('builder'));
const { redirectWithQueryBuilderData } =
mockUseQueryBuilder.mock.results[
mockUseQueryBuilder.mock.results.length - 1
].value;
const backToQB = redirectWithQueryBuilderData.mock.calls[
redirectWithQueryBuilderData.mock.calls.length - 1
][0] as Query;
expect(backToQB.builder.queryData[0].dataSource).toBe('logs');
expect(backToQB.builder.queryData[0].builderQueryType).toBeUndefined();
});
it("brings back both tabs' queries when revisiting a kind", () => {
const { result, rerender, setSpec, setState } = leaveTableOnAI();
// Back to Table from List, still on the AI tab.
const state = setState(
fakeQuery('list-ai', 'builder', [
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
]),
);
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
const restored = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(restored.builder).toBe(aiQuery.builder);
const restoredSpec = setSpec.mock.calls[
setSpec.mock.calls.length - 1
][0] as DashboardtypesPanelSpecDTO;
expect(restoredSpec.plugin.spec).toBe(TABLE_PLUGIN_SPEC);
expect(restoredSpec.queries).toBe(TABLE_QUERIES);
// Table's Query Builder query is one tab away.
act(() => result.current.onChangeQueryMode('builder'));
const backToQB = state.redirectWithQueryBuilderData.mock
.calls[1][0] as Query;
expect(backToQB.builder).toBe(qbQuery.builder);
});
it('swaps in the Query Builder query when revisiting a kind left on AI', () => {
const { result, rerender, setSpec, setState } = leaveTableOnAI();
// On List the user is back on Query Builder, then returns to Table.
const state = setState(
fakeQuery('list-qb', 'builder', [{ dataSource: 'logs' }]),
);
mockResolveQueryMode.mockReturnValue('builder');
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
const restored = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(restored.builder).toBe(qbQuery.builder);
// The stashed preview queries were the AI query's; they're rebuilt for the swap.
const restoredSpec = setSpec.mock.calls[
setSpec.mock.calls.length - 1
][0] as DashboardtypesPanelSpecDTO;
expect(restoredSpec.queries).toBe(CONVERTED);
// Table's AI query is one tab away.
act(() => result.current.onChangeQueryMode('builder_ai_query'));
const backToAI = state.redirectWithQueryBuilderData.mock
.calls[1][0] as Query;
expect(backToAI.builder).toBe(aiQuery.builder);
});
});
it('restores the original kind verbatim on switch-back (reversibility)', () => {
const setSpec = jest.fn();
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const listQuery = { id: 'list-current', queryType: 'builder' } as Query;
const tableQuery = fakeQuery('table-current', 'builder');
const listQuery = fakeQuery('list-current', 'builder');
let state = builderState(tableQuery);
mockUseQueryBuilder.mockImplementation(() => state);
const { result, rerender } = renderHook(
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
usePanelTypeSwitch({ ...props, setSpec }),
usePanelKindAndQueryModeSwitch({ ...props, setSpec }),
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
);

View File

@@ -4,7 +4,10 @@ import type {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import {
getSupportedSignals,
requireQueryPanelDefinition,
} from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { isPanelKindSupported } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
@@ -91,7 +94,7 @@ export function usePanelEditSession({
// View modal shells) — asserted rather than assumed.
const panelDefinition = requireQueryPanelDefinition(panelKind);
const panelType = toPanelType(panelKind);
const defaultSignal = panelDefinition.supportedSignals[0];
const defaultSignal = getSupportedSignals(panelKind)[0];
const query = usePanelQuery({
panel: draft,

View File

@@ -0,0 +1,289 @@
import { useCallback, useRef } from 'react';
import logEvent from 'api/common/logEvent';
import type {
DashboardtypesPanelPluginDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { handleQueryChange } from 'lib/query/panelQuery';
import type { PartialPanelTypes } from 'lib/query/panelTypeDataSourceFormValuesMap';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import {
type BuilderVariant,
type BuildersByVariant,
type OrderByPayload,
type Query,
QueryMode,
} from 'types/api/queryBuilder/queryBuilderData';
import {
getQueryPanelDefinition,
isStaticPanelKind,
resolveQueryMode,
} from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { getQueryModeSignals } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities';
import {
getBuilderVariant,
getQueryMode,
toBuilderVariant,
withoutAIQueryTag,
} from 'lib/query/queryMode';
import { seedBuilderForMode } from 'pages/DashboardPage/DashboardContainer/Panels/utils/seedBuilderForMode';
import { toPanelType, type PanelKind } from '../../Panels/types/panelKind';
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../queryV5/persesQueryAdapters';
import {
getSwitchedPluginSpec,
type SwitchedPluginSpec,
} from '../getSwitchedPluginSpec';
import {
withPercentileHistogramAggregation,
withSingleHeatmapQuery,
} from '../utils/heatmapQuery';
import { useQueryModeChange } from './useQueryModeChange';
// V1's handleQueryChange clears orderBy for lists; re-seed the fresh-list default (timestamp desc).
const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
{ columnName: 'timestamp', order: 'desc' },
];
function withDefaultListOrder(query: Query): Query {
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((qd) =>
qd.orderBy && qd.orderBy.length > 0
? qd
: { ...qd, orderBy: DEFAULT_LIST_ORDER_BY },
),
},
};
}
/** What a kind looks like when you leave it; restored verbatim if you return. */
interface KindState {
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
queries: DashboardtypesQueryDTO[];
query: Query;
/** Both builder variants' queries as the kind was left. */
builders: BuildersByVariant;
}
interface UsePanelKindAndQueryModeSwitchArgs {
spec: DashboardtypesPanelSpecDTO;
panelType: PANEL_TYPES;
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
}
interface UsePanelKindAndQueryModeSwitchApi {
/** Switch the panel to `newKind`, transforming/restoring its query + spec. */
onChangePanelKind: (newKind: PanelKind) => void;
/** Switch authoring tab; shares the off-screen builder stash with the kind switch. */
onChangeQueryMode: (key: string) => void;
}
/**
* Switches the edited panel's visualization kind. Mutating `plugin.kind` re-derives the
* renderer, config sections, query-builder tabs and request type for free; this hook adds
* the two things that don't: a per-kind session cache that makes switching reversible
* (`Table → List → Table` restores the original query + spec), and, on first visit to a
* kind, a query rebuild (`handleQueryChange`) + spec reset (`getSwitchedPluginSpec`).
* It also owns the tab switch, stashing the off-screen builder variant per kind.
*/
export function usePanelKindAndQueryModeSwitch({
spec,
panelType,
setSpec,
}: UsePanelKindAndQueryModeSwitchArgs): UsePanelKindAndQueryModeSwitchApi {
const {
currentQuery,
redirectWithQueryBuilderData,
updateAllQueriesOperators,
} = useQueryBuilder();
const cacheRef = useRef<Map<PanelKind, KindState>>(new Map());
// Builder queries for the tabs not on screen; the live one is `currentQuery.builder`.
const parkedBuilders = useRef<BuildersByVariant>({});
// Latest spec/query/type, read inside the stable callback without re-subscribing.
const specRef = useRef(spec);
specRef.current = spec;
const queryRef = useRef(currentQuery);
queryRef.current = currentQuery;
const panelTypeRef = useRef(panelType);
panelTypeRef.current = panelType;
const onChangeQueryMode = useQueryModeChange({
panelType,
supportedQueryModesKindMap:
getQueryPanelDefinition(spec.plugin.kind as PanelKind)
?.supportedQueryModesKindMap ?? {},
parkedBuilders,
});
const onChangePanelKind = useCallback(
(newKind: PanelKind): void => {
const currentSpec = specRef.current;
const oldKind = currentSpec.plugin.kind as PanelKind;
if (newKind === oldKind) {
return;
}
void logEvent(DashboardDetailEvents.PanelTypeChanged, {
from: oldKind,
to: newKind,
});
const query = queryRef.current;
cacheRef.current.set(oldKind, {
pluginSpec: currentSpec.plugin.spec,
queries: currentSpec.queries,
query,
builders: {
...parkedBuilders.current,
[getBuilderVariant(query.builder)]: query.builder,
},
});
const newPanelType = toPanelType(newKind);
const targetMode = resolveQueryMode(newKind, getQueryMode(query));
const targetVariant = toBuilderVariant(targetMode);
// An AI query is a builder query carrying the tag, so it rides on `builder`.
const targetQueryType =
targetMode === QueryMode.AI_QUERY_BUILDER
? QueryMode.QUERY_BUILDER
: targetMode;
// Only `plugin` needs a cast: it's a discriminated union over `kind`, and a
// dynamically-chosen kind can't be correlated with its spec statically (as in
// `createDefaultPanel`). The surrounding spec stays fully typed.
const buildSpec = (
pluginSpec: DashboardtypesPanelPluginDTO['spec'] | SwitchedPluginSpec,
queries: DashboardtypesQueryDTO[],
): DashboardtypesPanelSpecDTO => ({
...currentSpec,
plugin: {
...currentSpec.plugin,
kind: newKind,
spec: pluginSpec,
} as DashboardtypesPanelPluginDTO,
queries,
});
const rebuildForNewKind = (source: Query): Query => {
const transformed = handleQueryChange(
newPanelType as keyof PartialPanelTypes,
{ ...source, queryType: targetQueryType },
panelTypeRef.current,
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
const ordered =
newKind === 'signoz/ListPanel'
? withDefaultListOrder(transformed)
: transformed;
return newKind === 'signoz/HeatmapPanel'
? withSingleHeatmapQuery(ordered)
: withPercentileHistogramAggregation(ordered);
};
// Tab unused on this kind: AI gets a fresh seed, Query Builder reuses `source` untagged.
const seedTargetTab = (source: Query): Query =>
targetMode === QueryMode.AI_QUERY_BUILDER
? {
...source,
builder: seedBuilderForMode({
mode: targetMode,
defaultSignal: getQueryModeSignals(
getQueryPanelDefinition(newKind)?.supportedQueryModesKindMap,
QueryMode.QUERY_BUILDER,
)[0],
panelType: newPanelType,
updateAllQueriesOperators,
}),
}
: withoutAIQueryTag(source);
// Target tab's query comes from the stash; the rest stay parked.
const unparkTargetTab = (
source: Query,
builders: BuildersByVariant,
): { query: Query; parked: BuildersByVariant } => {
const stored = targetVariant ? builders[targetVariant] : source.builder;
const next = stored
? { ...source, builder: stored }
: seedTargetTab(source);
const parked: BuildersByVariant = { ...builders };
delete parked[getBuilderVariant(next.builder)];
return { query: next, parked };
};
// Revisit → restore the stash in the active mode; a static kind has no builder to re-seed.
const cached = cacheRef.current.get(newKind);
if (cached) {
if (isStaticPanelKind(newKind)) {
setSpec(buildSpec(cached.pluginSpec, cached.queries));
return;
}
const restored = unparkTargetTab(cached.query, cached.builders);
parkedBuilders.current = restored.parked;
// The stashed preview queries belong to the stashed query, not a swapped-in one.
setSpec(
buildSpec(
cached.pluginSpec,
restored.query.builder === cached.query.builder
? cached.queries
: toPerses(restored.query, newPanelType),
),
);
redirectWithQueryBuilderData({
...restored.query,
queryType: targetQueryType,
});
return;
}
// First visit to a static kind → fresh spec from its sections, queries
// emptied (the API accepts nothing else), and the query builder left as-is:
// the stash above keeps the old kind's query for the return trip.
if (isStaticPanelKind(newKind)) {
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(buildSpec(getSwitchedPluginSpec(currentSpec, newKind, signal), []));
return;
}
// First visit → rebuild both builder tabs' queries for the new panel type.
const transformed = rebuildForNewKind(query);
const rebuiltBuilders: BuildersByVariant = {};
(Object.keys(parkedBuilders.current) as BuilderVariant[]).forEach(
(variant) => {
const parked = parkedBuilders.current[variant];
if (parked) {
rebuiltBuilders[variant] = rebuildForNewKind({
...query,
builder: parked,
}).builder;
}
},
);
rebuiltBuilders[getBuilderVariant(transformed.builder)] =
transformed.builder;
const picked = unparkTargetTab(transformed, rebuiltBuilders);
parkedBuilders.current = picked.parked;
const nextQuery = picked.query;
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(
buildSpec(
getSwitchedPluginSpec(currentSpec, newKind, signal),
toPerses(nextQuery, newPanelType),
),
);
redirectWithQueryBuilderData(nextQuery);
},
[setSpec, redirectWithQueryBuilderData, updateAllQueriesOperators],
);
return { onChangePanelKind, onChangeQueryMode };
}

View File

@@ -1,290 +0,0 @@
import { useCallback, useRef } from 'react';
import logEvent from 'api/common/logEvent';
import type {
DashboardtypesPanelPluginDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ATTRIBUTE_TYPES, type PANEL_TYPES } from 'constants/queryBuilder';
import { handleQueryChange } from 'lib/query/panelQuery';
import type { PartialPanelTypes } from 'lib/query/panelTypeDataSourceFormValuesMap';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import type {
IBuilderQuery,
OrderByPayload,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import type {
MetricAggregation,
SpaceAggregation,
} from 'types/api/v5/queryRange';
import { isStaticPanelKind, resolveQueryType } from '../../Panels/capabilities';
import { toPanelType, type PanelKind } from '../../Panels/types/panelKind';
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../queryV5/persesQueryAdapters';
import {
getSwitchedPluginSpec,
type SwitchedPluginSpec,
} from '../getSwitchedPluginSpec';
// V1's handleQueryChange clears orderBy for lists; re-seed the fresh-list default (timestamp desc).
const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
{ columnName: 'timestamp', order: 'desc' },
];
const PERCENTILE_SPACE_AGGREGATIONS = new Set<SpaceAggregation>([
'p50',
'p75',
'p90',
'p95',
'p99',
]);
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
ATTRIBUTE_TYPES.HISTOGRAM,
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
]);
const DEFAULT_HISTOGRAM_SPACE_AGGREGATION = 'p90';
/** What a heatmap cell holds, and the one space aggregation the kind offers. */
const HEATMAP_HISTOGRAM_SPACE_AGGREGATION = 'count';
function withDefaultListOrder(query: Query): Query {
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((qd) =>
qd.orderBy && qd.orderBy.length > 0
? qd
: { ...qd, orderBy: DEFAULT_LIST_ORDER_BY },
),
},
};
}
/**
* A heatmap draws against one bucket axis, so the request takes exactly one enabled
* query and refuses a formula over the cells. The per-kind cache restores whatever is
* dropped here if the panel switches back.
*
* Percentiles go the same way: a histogram heatmap takes its axis from the `le` labels,
* so a percentile draws the grid a count already draws, and the statement builder sums
* the `le` counts either way.
*/
function withSingleHeatmapQuery(query: Query): Query {
const [first] = query.builder.queryData;
if (!first) {
return query;
}
return {
...query,
builder: {
...query.builder,
queryData: [
{
...first,
spaceAggregation: withoutPercentile(first.spaceAggregation),
aggregations: first.aggregations?.map((aggregation) => ({
...aggregation,
spaceAggregation: withoutPercentile(
(aggregation as MetricAggregation).spaceAggregation,
),
})) as IBuilderQuery['aggregations'],
},
],
queryFormulas: [],
},
};
}
/** Cast because the substitute is a `SpaceAggregation` whichever of its callers `T` came from. */
function withoutPercentile<T extends string | undefined>(
spaceAggregation: T,
): T {
return (
PERCENTILE_SPACE_AGGREGATIONS.has(spaceAggregation as SpaceAggregation)
? HEATMAP_HISTOGRAM_SPACE_AGGREGATION
: spaceAggregation
) as T;
}
/**
* Undoes the count above: every other kind offers a histogram metric percentiles alone,
* so a count carried out of a heatmap would sit in the selector with no option behind
* it. Other metric types are left alone — count is a real choice for a sum or a gauge.
*/
function withPercentileHistogramAggregation(query: Query): Query {
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((queryData) => {
if (
!HISTOGRAM_ATTRIBUTE_TYPES.has(queryData.aggregateAttribute?.type ?? '')
) {
return queryData;
}
return {
...queryData,
spaceAggregation: withoutCount(queryData.spaceAggregation),
aggregations: queryData.aggregations?.map((aggregation) => ({
...aggregation,
spaceAggregation: withoutCount(
(aggregation as MetricAggregation).spaceAggregation,
),
})) as IBuilderQuery['aggregations'],
};
}),
},
};
}
function withoutCount<T extends string | undefined>(spaceAggregation: T): T {
return (
spaceAggregation === HEATMAP_HISTOGRAM_SPACE_AGGREGATION
? DEFAULT_HISTOGRAM_SPACE_AGGREGATION
: spaceAggregation
) as T;
}
/** What a kind looks like when you leave it; restored verbatim if you return. */
interface KindState {
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
queries: DashboardtypesQueryDTO[];
builderQuery: Query;
}
interface UsePanelTypeSwitchArgs {
spec: DashboardtypesPanelSpecDTO;
panelType: PANEL_TYPES;
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
}
interface UsePanelTypeSwitchApi {
/** Switch the panel to `newKind`, transforming/restoring its query + spec. */
onChangePanelKind: (newKind: PanelKind) => void;
}
/**
* Switches the edited panel's visualization kind. Mutating `plugin.kind` re-derives the
* renderer, config sections, query-builder tabs and request type for free; this hook adds
* the two things that don't: a per-kind session cache that makes switching reversible
* (`Table → List → Table` restores the original query + spec), and, on first visit to a
* kind, a query rebuild (`handleQueryChange`) + spec reset (`getSwitchedPluginSpec`).
*/
export function usePanelTypeSwitch({
spec,
panelType,
setSpec,
}: UsePanelTypeSwitchArgs): UsePanelTypeSwitchApi {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const cacheRef = useRef<Map<PanelKind, KindState>>(new Map());
// Latest spec/query/type, read inside the stable callback without re-subscribing.
const specRef = useRef(spec);
specRef.current = spec;
const queryRef = useRef(currentQuery);
queryRef.current = currentQuery;
const panelTypeRef = useRef(panelType);
panelTypeRef.current = panelType;
const onChangePanelKind = useCallback(
(newKind: PanelKind): void => {
const currentSpec = specRef.current;
const oldKind = currentSpec.plugin.kind as PanelKind;
if (newKind === oldKind) {
return;
}
void logEvent(DashboardDetailEvents.PanelTypeChanged, {
from: oldKind,
to: newKind,
});
const query = queryRef.current;
cacheRef.current.set(oldKind, {
pluginSpec: currentSpec.plugin.spec,
queries: currentSpec.queries,
builderQuery: query,
});
const newPanelType = toPanelType(newKind);
// Only `plugin` needs a cast: it's a discriminated union over `kind`, and a
// dynamically-chosen kind can't be correlated with its spec statically (as in
// `createDefaultPanel`). The surrounding spec stays fully typed.
const buildSpec = (
pluginSpec: DashboardtypesPanelPluginDTO['spec'] | SwitchedPluginSpec,
queries: DashboardtypesQueryDTO[],
): DashboardtypesPanelSpecDTO => ({
...currentSpec,
plugin: {
...currentSpec.plugin,
kind: newKind,
spec: pluginSpec,
} as DashboardtypesPanelPluginDTO,
queries,
});
// Revisit → restore the stash verbatim (the reversibility path). A static
// kind's stash carries `queries: []` and its builder query is untouched —
// there is no builder to re-seed for it.
const cached = cacheRef.current.get(newKind);
if (cached) {
setSpec(buildSpec(cached.pluginSpec, cached.queries));
if (!isStaticPanelKind(newKind)) {
redirectWithQueryBuilderData(cached.builderQuery);
}
return;
}
// First visit to a static kind → fresh spec from its sections, queries
// emptied (the API accepts nothing else), and the query builder left as-is:
// the stash above keeps the old kind's query for the return trip.
if (isStaticPanelKind(newKind)) {
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(buildSpec(getSwitchedPluginSpec(currentSpec, newKind, signal), []));
return;
}
// First visit → coerce the query type if the new kind disallows it, then
// rebuild the builder query for the new type.
const queryType = resolveQueryType(newKind, query.queryType);
const transformed = handleQueryChange(
newPanelType as keyof PartialPanelTypes,
{ ...query, queryType },
panelTypeRef.current,
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
let nextQuery =
newKind === 'signoz/ListPanel'
? withDefaultListOrder(transformed)
: transformed;
nextQuery =
newKind === 'signoz/HeatmapPanel'
? withSingleHeatmapQuery(nextQuery)
: withPercentileHistogramAggregation(nextQuery);
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(
buildSpec(
getSwitchedPluginSpec(currentSpec, newKind, signal),
toPerses(nextQuery, newPanelType),
),
);
redirectWithQueryBuilderData(nextQuery);
},
[setSpec, redirectWithQueryBuilderData],
);
return { onChangePanelKind };
}

View File

@@ -0,0 +1,83 @@
import { type MutableRefObject, useCallback } from 'react';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
getQueryModeSignals,
type SupportedQueryModesKindMap,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities';
import {
type BuildersByVariant,
type Query,
QueryMode,
} from 'types/api/queryBuilder/queryBuilderData';
import { getBuilderVariant, toBuilderVariant } from 'lib/query/queryMode';
import { seedBuilderForMode } from 'pages/DashboardPage/DashboardContainer/Panels/utils/seedBuilderForMode';
interface UseQueryModeChangeArgs {
panelType: PANEL_TYPES;
supportedQueryModesKindMap: SupportedQueryModesKindMap;
/** Builder queries for the variant not on screen. */
parkedBuilders: MutableRefObject<BuildersByVariant>;
}
/**
* Tab switching. Query Builder and AI share `currentQuery.builder`, so crossing between
* them swaps in the parked query, seeding a first visit; ClickHouse and PromQL pass through.
*/
export function useQueryModeChange({
panelType,
supportedQueryModesKindMap,
parkedBuilders,
}: UseQueryModeChangeArgs): (key: string) => void {
const {
currentQuery,
redirectWithQueryBuilderData,
updateAllQueriesOperators,
} = useQueryBuilder();
return useCallback(
(key: string): void => {
const targetMode = key as QueryMode;
const targetVariant = toBuilderVariant(targetMode);
const heldVariant = getBuilderVariant(currentQuery.builder);
const seedFor = (mode: QueryMode): Query['builder'] =>
seedBuilderForMode({
mode,
defaultSignal: getQueryModeSignals(
supportedQueryModesKindMap,
QueryMode.QUERY_BUILDER,
)[0],
panelType,
updateAllQueriesOperators,
});
// Keyed on what `builder` holds, not the active tab: ClickHouse → QB must not reseed.
let { builder } = currentQuery;
if (targetVariant && targetVariant !== heldVariant) {
parkedBuilders.current[heldVariant] = currentQuery.builder;
builder = parkedBuilders.current[targetVariant] ?? seedFor(targetMode);
delete parkedBuilders.current[targetVariant];
}
redirectWithQueryBuilderData({
...currentQuery,
// An AI query is a builder query carrying the AI tag; its query type stays `builder`.
queryType:
targetMode === QueryMode.AI_QUERY_BUILDER
? QueryMode.QUERY_BUILDER
: targetMode,
builder,
});
},
[
currentQuery,
panelType,
parkedBuilders,
redirectWithQueryBuilderData,
supportedQueryModesKindMap,
updateAllQueriesOperators,
],
);
}

View File

@@ -6,7 +6,7 @@ import type { NewPanelTarget } from '../patchOps';
import QueryEditorBody from './QueryEditorBody';
import StaticEditorBody from './StaticEditorBody';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
import { usePanelKindAndQueryModeSwitch } from './hooks/usePanelKindAndQueryModeSwitch';
export interface PanelEditorContainerProps {
dashboardId: string;
@@ -40,11 +40,12 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
const panelDefinition = getPanelDefinition(panelKind);
const originalPanelKind = (savedPanel ?? panel).spec.plugin.kind;
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
panelType: toPanelType(panelKind),
setSpec: draftApi.setSpec,
});
const { onChangePanelKind, onChangeQueryMode } =
usePanelKindAndQueryModeSwitch({
spec: draftApi.draft.spec,
panelType: toPanelType(panelKind),
setSpec: draftApi.setSpec,
});
if (panelDefinition.mode === 'static') {
return (
@@ -65,6 +66,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
onChangeQueryMode={onChangeQueryMode}
/>
);
}

View File

@@ -0,0 +1,114 @@
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
import type {
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import type {
MetricAggregation,
SpaceAggregation,
} from 'types/api/v5/queryRange';
const PERCENTILE_SPACE_AGGREGATIONS = new Set<SpaceAggregation>([
'p50',
'p75',
'p90',
'p95',
'p99',
]);
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
ATTRIBUTE_TYPES.HISTOGRAM,
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
]);
const DEFAULT_HISTOGRAM_SPACE_AGGREGATION = 'p90';
/** What a heatmap cell holds, and the one space aggregation the kind offers. */
const HEATMAP_HISTOGRAM_SPACE_AGGREGATION = 'count';
/**
* A heatmap draws against one bucket axis, so the request takes exactly one enabled
* query and refuses a formula over the cells. The per-kind cache restores whatever is
* dropped here if the panel switches back.
*
* Percentiles go the same way: a histogram heatmap takes its axis from the `le` labels,
* so a percentile draws the grid a count already draws, and the statement builder sums
* the `le` counts either way.
*/
export function withSingleHeatmapQuery(query: Query): Query {
const [first] = query.builder.queryData;
if (!first) {
return query;
}
return {
...query,
builder: {
...query.builder,
queryData: [
{
...first,
spaceAggregation: withoutPercentile(first.spaceAggregation),
aggregations: first.aggregations?.map((aggregation) => ({
...aggregation,
spaceAggregation: withoutPercentile(
(aggregation as MetricAggregation).spaceAggregation,
),
})) as IBuilderQuery['aggregations'],
},
],
queryFormulas: [],
},
};
}
/** Cast because the substitute is a `SpaceAggregation` whichever of its callers `T` came from. */
function withoutPercentile<T extends string | undefined>(
spaceAggregation: T,
): T {
return (
PERCENTILE_SPACE_AGGREGATIONS.has(spaceAggregation as SpaceAggregation)
? HEATMAP_HISTOGRAM_SPACE_AGGREGATION
: spaceAggregation
) as T;
}
/**
* Undoes the count above: every other kind offers a histogram metric percentiles alone,
* so a count carried out of a heatmap would sit in the selector with no option behind
* it. Other metric types are left alone — count is a real choice for a sum or a gauge.
*/
export function withPercentileHistogramAggregation(query: Query): Query {
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((queryData) => {
if (
!HISTOGRAM_ATTRIBUTE_TYPES.has(queryData.aggregateAttribute?.type ?? '')
) {
return queryData;
}
return {
...queryData,
spaceAggregation: withoutCount(queryData.spaceAggregation),
aggregations: queryData.aggregations?.map((aggregation) => ({
...aggregation,
spaceAggregation: withoutCount(
(aggregation as MetricAggregation).spaceAggregation,
),
})) as IBuilderQuery['aggregations'],
};
}),
},
};
}
function withoutCount<T extends string | undefined>(spaceAggregation: T): T {
return (
spaceAggregation === HEATMAP_HISTOGRAM_SPACE_AGGREGATION
? DEFAULT_HISTOGRAM_SPACE_AGGREGATION
: spaceAggregation
) as T;
}

View File

@@ -3,6 +3,7 @@ import {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import { UNSUPPORTED_PANEL } from '../kinds/UnsupportedPanel/definition';
import { getPanelDefinition, isPanelKindSupported } from '../registry';
@@ -11,12 +12,13 @@ import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
import {
getQueryPanelDefinition,
requireQueryPanelDefinition,
getSupportedQueryModes,
getSupportedQueryTypes,
getSupportedSignals,
isPanelCombinationValid,
isQueryTypeSupportedByPanelKind,
isSignalSupported,
resolveQueryType,
resolveQueryMode,
} from '../capabilities';
import type { PanelKind } from '../types/panelKind';
@@ -38,6 +40,48 @@ const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
'signoz/TextPanel': [],
};
const EXPECTED_QUERY_MODES: Record<PanelKind, QueryMode[]> = {
'signoz/TimeSeriesPanel': [
QUERY_BUILDER,
CLICKHOUSE,
PROM,
QueryMode.AI_QUERY_BUILDER,
],
'signoz/BarChartPanel': [
QUERY_BUILDER,
CLICKHOUSE,
PROM,
QueryMode.AI_QUERY_BUILDER,
],
'signoz/AreaChartPanel': [
QUERY_BUILDER,
CLICKHOUSE,
PROM,
QueryMode.AI_QUERY_BUILDER,
],
'signoz/NumberPanel': [
QUERY_BUILDER,
CLICKHOUSE,
PROM,
QueryMode.AI_QUERY_BUILDER,
],
'signoz/HistogramPanel': [
QUERY_BUILDER,
CLICKHOUSE,
PROM,
QueryMode.AI_QUERY_BUILDER,
],
'signoz/HeatmapPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/PieChartPanel': [
QUERY_BUILDER,
CLICKHOUSE,
QueryMode.AI_QUERY_BUILDER,
],
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE, QueryMode.AI_QUERY_BUILDER],
'signoz/ListPanel': [QUERY_BUILDER, QueryMode.AI_QUERY_BUILDER],
'signoz/TextPanel': [],
};
const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
'signoz/TimeSeriesPanel': [metrics, logs, traces],
'signoz/BarChartPanel': [metrics, logs, traces],
@@ -223,6 +267,15 @@ describe('panel capabilities guard', () => {
expect(isSignalSupported('signoz/ListPanel', logs)).toBe(true);
expect(isSignalSupported('signoz/ListPanel', traces)).toBe(true);
});
it('reads a specific mode when one is given', () => {
expect(
getSupportedSignals('signoz/TablePanel', QueryMode.AI_QUERY_BUILDER),
).toStrictEqual([traces]);
expect(
getSupportedSignals('signoz/TablePanel', QueryMode.CLICKHOUSE),
).toStrictEqual([]);
});
});
describe('isPanelCombinationValid', () => {
@@ -271,18 +324,36 @@ describe('panel capabilities guard', () => {
});
});
describe('resolveQueryType', () => {
it('keeps a supported query type', () => {
expect(resolveQueryType('signoz/TimeSeriesPanel', PROM)).toBe(PROM);
expect(resolveQueryType('signoz/ListPanel', QUERY_BUILDER)).toBe(
QUERY_BUILDER,
describe('getSupportedQueryModes', () => {
it.each(Object.entries(EXPECTED_QUERY_MODES))(
'%s supports %j',
(kind, modes) => {
expect(getSupportedQueryModes(kind as PanelKind)).toStrictEqual(modes);
},
);
it('keeps AI out of the query types', () => {
expect(getSupportedQueryTypes('signoz/TimeSeriesPanel')).not.toContain(
QueryMode.AI_QUERY_BUILDER,
);
});
});
it('coerces an unsupported query type to the first supported one', () => {
describe('resolveQueryMode', () => {
it('keeps a supported mode', () => {
expect(resolveQueryMode('signoz/TimeSeriesPanel', PROM)).toBe(PROM);
expect(
resolveQueryMode('signoz/TablePanel', QueryMode.AI_QUERY_BUILDER),
).toBe(QueryMode.AI_QUERY_BUILDER);
expect(
resolveQueryMode('signoz/ListPanel', QueryMode.AI_QUERY_BUILDER),
).toBe(QueryMode.AI_QUERY_BUILDER);
});
it('coerces an unsupported mode to the first supported one', () => {
// PromQL → List has no PromQL, falls back to its first (and only) type.
expect(resolveQueryType('signoz/ListPanel', PROM)).toBe(QUERY_BUILDER);
expect(resolveQueryType('signoz/TablePanel', PROM)).toBe(QUERY_BUILDER);
expect(resolveQueryMode('signoz/ListPanel', PROM)).toBe(QUERY_BUILDER);
expect(resolveQueryMode('signoz/TablePanel', PROM)).toBe(QUERY_BUILDER);
});
});
});

View File

@@ -1,7 +1,9 @@
import { type TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import { getPanelDefinition } from './registry';
import { getQueryModeSignals } from './types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from './types/panelDefinition';
import type { PanelKind } from './types/panelKind';
@@ -48,11 +50,17 @@ export function requireQueryPanelDefinition(
return definition;
}
/** Signals a kind can visualize. */
const DEFAULT_QUERY_MODE = QueryMode.QUERY_BUILDER;
/** Signals a kind can visualize in `mode`. */
export function getSupportedSignals(
kind: PanelKind,
mode: QueryMode = DEFAULT_QUERY_MODE,
): TelemetrytypesSignalDTO[] {
return getQueryPanelDefinition(kind)?.supportedSignals ?? [];
return getQueryModeSignals(
getQueryPanelDefinition(kind)?.supportedQueryModesKindMap,
mode,
);
}
export function isSignalSupported(
@@ -62,9 +70,16 @@ export function isSignalSupported(
return getSupportedSignals(kind).includes(signal);
}
/** Authoring modes a kind supports, in tab order. */
export function getSupportedQueryModes(kind: PanelKind): QueryMode[] {
const modes = getQueryPanelDefinition(kind)?.supportedQueryModesKindMap ?? {};
return Object.keys(modes) as QueryMode[];
}
/** Query languages a kind supports (Query Builder / ClickHouse / PromQL). */
export function getSupportedQueryTypes(kind: PanelKind): EQueryType[] {
return getQueryPanelDefinition(kind)?.supportedQueryTypes ?? [];
return getSupportedQueryModes(kind).filter(
(mode): mode is EQueryType => mode !== QueryMode.AI_QUERY_BUILDER,
);
}
export function isQueryTypeSupportedByPanelKind(
@@ -101,20 +116,15 @@ export function isPanelCombinationValid({
return true;
}
/**
* The query type to use for a kind given a `preferred` one: keep it if the kind
* supports it, otherwise fall back to the kind's first supported type. Used when
* switching panel kinds to coerce an unsupported active query type (e.g. PromQL → a
* List panel coerces to Query Builder).
*/
export function resolveQueryType(
/** `preferred` if the kind supports it, else its first supported mode (e.g. PromQL → List gives Query Builder). */
export function resolveQueryMode(
kind: PanelKind,
preferred: EQueryType,
): EQueryType {
const supported = getSupportedQueryTypes(kind);
preferred: QueryMode,
): QueryMode {
const supported = getSupportedQueryModes(kind);
if (supported.includes(preferred)) {
return preferred;
}
// A query-less kind has no supported types; the builder is the neutral answer.
return supported[0] ?? EQueryType.QUERY_BUILDER;
// A query-less kind has no supported modes; the builder is the neutral answer.
return supported[0] ?? DEFAULT_QUERY_MODE;
}

View File

@@ -1,6 +1,7 @@
import { ChartArea } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import { QueryModeKind } from '../../types/panelCapabilities';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -8,7 +9,7 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
export const definition: PanelDefinition<'signoz/AreaChartPanel'> = {
kind: 'signoz/AreaChartPanel',
@@ -18,16 +19,22 @@ export const definition: PanelDefinition<'signoz/AreaChartPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportedQueryModesKindMap: {
[QueryMode.QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.PROM]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.AI_QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.traces],
},
},
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,

View File

@@ -1,6 +1,7 @@
import { BarChart } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import { QueryModeKind } from '../../types/panelCapabilities';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -8,7 +9,7 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
@@ -18,16 +19,22 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportedQueryModesKindMap: {
[QueryMode.QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.PROM]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.AI_QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.traces],
},
},
queryBuilderFields: {},
// Bars are binned client-side from a raw time series, so the request asks for a
// step interval wide enough to keep the bar count readable (V1 parity).

View File

@@ -3,6 +3,7 @@ import { LayoutGrid } from '@signozhq/icons';
import { QueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
import type { PanelDefinition } from '../../types/panelDefinition';
import { QueryModeKind } from '../../types/panelCapabilities';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -10,7 +11,7 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
export const definition: PanelDefinition<'signoz/HeatmapPanel'> = {
kind: 'signoz/HeatmapPanel',
@@ -20,13 +21,15 @@ export const definition: PanelDefinition<'signoz/HeatmapPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
// Only metrics carry a bucket axis; the request rejects the other signals.
supportedSignals: [TelemetrytypesSignalDTO.metrics],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportedQueryModesKindMap: {
// Only metrics carry a bucket axis; the request rejects the other signals.
[QueryMode.QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.metrics],
},
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.PROM]: { kind: QueryModeKind.SIGNAL_LESS },
},
// The request rejects both: a point is a count per bucket, not a single value.
queryBuilderFields: {
[QueryBuilderField.Functions]: { state: 'hidden' },

View File

@@ -1,6 +1,7 @@
import { BarChart } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import { QueryModeKind } from '../../types/panelCapabilities';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -8,7 +9,7 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
@@ -18,16 +19,22 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportedQueryModesKindMap: {
[QueryMode.QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.PROM]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.AI_QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.traces],
},
},
queryBuilderFields: {},
// Buckets are computed client-side from the raw series, so the request is a plain
// time series — the bucket count is a display concern, not a query one.

View File

@@ -10,6 +10,7 @@ function ListEditorPane({
panelDefinition,
signal,
isLoadingQueries,
onChangeQueryMode,
onStageRunQuery,
onCancelQuery,
stickyHeader,
@@ -20,6 +21,7 @@ function ListEditorPane({
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
isLoadingQueries={isLoadingQueries}
onChangeQueryMode={onChangeQueryMode}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}
stickyHeader={stickyHeader}

View File

@@ -1,6 +1,7 @@
import { List } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import { QueryModeKind } from '../../types/panelCapabilities';
import ListEditorPane from './ListEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -8,7 +9,7 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
export const definition: PanelDefinition<'signoz/ListPanel'> = {
kind: 'signoz/ListPanel',
@@ -18,11 +19,16 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
Renderer,
EditorPane: ListEditorPane,
// Raw records come from logs and traces; metrics don't produce row data.
supportedSignals: [
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER],
supportedQueryModesKindMap: {
[QueryMode.QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.logs, TelemetrytypesSignalDTO.traces],
},
[QueryMode.AI_QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.traces],
},
},
// No deviation from the baseline the raw request type below already implies.
queryBuilderFields: {},
sections,

View File

@@ -1,6 +1,7 @@
import { Hash } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import { QueryModeKind } from '../../types/panelCapabilities';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -8,7 +9,7 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
kind: 'signoz/NumberPanel',
@@ -18,16 +19,22 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportedQueryModesKindMap: {
[QueryMode.QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.PROM]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.AI_QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.traces],
},
},
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,

View File

@@ -1,6 +1,7 @@
import { ChartPie } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import { QueryModeKind } from '../../types/panelCapabilities';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -8,7 +9,7 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
@@ -18,12 +19,21 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
supportedQueryModesKindMap: {
[QueryMode.QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.AI_QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.traces],
},
},
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,

View File

@@ -1,6 +1,7 @@
import { Table } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import { QueryModeKind } from '../../types/panelCapabilities';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -8,7 +9,7 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
export const definition: PanelDefinition<'signoz/TablePanel'> = {
kind: 'signoz/TablePanel',
@@ -18,12 +19,21 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
supportedQueryModesKindMap: {
[QueryMode.QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.AI_QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.traces],
},
},
queryBuilderFields: {},
// The only kind that asks the server to transpose its scalar result into UI rows.
queryCapabilities: {

View File

@@ -1,6 +1,7 @@
import { ChartLine } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import { QueryModeKind } from '../../types/panelCapabilities';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -8,7 +9,7 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
kind: 'signoz/TimeSeriesPanel',
@@ -18,16 +19,22 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportedQueryModesKindMap: {
[QueryMode.QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.PROM]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.AI_QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.traces],
},
},
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,

View File

@@ -25,8 +25,7 @@ export const UNSUPPORTED_PANEL: RenderablePanelDefinition = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections: [],
supportedSignals: [],
supportedQueryTypes: [],
supportedQueryModesKindMap: {},
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,

View File

@@ -1,4 +1,8 @@
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
type TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
export type { QueryBuilderFieldsConfig } from 'components/QueryBuilderV2/queryBuilderFields.types';
@@ -34,3 +38,25 @@ export interface PanelQueryCapabilities {
export function isRawRequest(capabilities: PanelQueryCapabilities): boolean {
return capabilities.requestType === Querybuildertypesv5RequestTypeDTO.raw;
}
export enum QueryModeKind {
SIGNAL = 'signal',
SIGNAL_LESS = 'signal-less',
}
export type QueryModeCapability =
| { kind: QueryModeKind.SIGNAL; signals: TelemetrytypesSignalDTO[] }
| { kind: QueryModeKind.SIGNAL_LESS };
export type SupportedQueryModesKindMap = Partial<
Record<QueryMode, QueryModeCapability>
>;
/** Signals `mode` accepts; empty for a signal-less or undeclared mode. */
export function getQueryModeSignals(
modes: SupportedQueryModesKindMap | undefined,
mode: QueryMode,
): TelemetrytypesSignalDTO[] {
const capability = modes?.[mode];
return capability?.kind === QueryModeKind.SIGNAL ? capability.signals : [];
}

View File

@@ -4,7 +4,6 @@ import {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ChartLine } from '@signozhq/icons';
import type { EQueryType } from 'types/common/dashboard';
import type { SectionConfig } from './sections';
import type { AnyPanelInteractionProps } from './interactions';
@@ -12,6 +11,7 @@ import type { PanelKind } from './panelKind';
import type {
PanelQueryCapabilities,
QueryBuilderFieldsConfig,
SupportedQueryModesKindMap,
} from './panelCapabilities';
import type {
BaseRendererProps,
@@ -94,6 +94,8 @@ export interface QueryEditorPaneProps {
panelDefinition: RenderableQueryPanelDefinition;
signal: TelemetrytypesSignalDTO;
isLoadingQueries: boolean;
/** Switch authoring tab (Query Builder / AI / ClickHouse / PromQL), owned by the host. */
onChangeQueryMode: (key: string) => void;
onStageRunQuery: () => void;
onCancelQuery: () => void;
/** Pin the tabs row to the pane top; the View modal opts out. */
@@ -114,10 +116,8 @@ export interface QueryPanelDefinition<
Renderer: ComponentType<PanelRendererProps<K>>;
/** Lower editor pane — the shared query-builder pane, or a kind wrapper of it. */
EditorPane: ComponentType<QueryEditorPaneProps>;
/** Signals this kind can visualize. */
supportedSignals: TelemetrytypesSignalDTO[];
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
supportedQueryTypes: EQueryType[];
/** Authoring modes (Query Builder / ClickHouse / PromQL / AI) and the signals each accepts. */
supportedQueryModesKindMap: SupportedQueryModesKindMap;
queryBuilderFields: QueryBuilderFieldsConfig;
/** How this kind's query-range request is shaped (request type, paging, result formatting). */
queryCapabilities: PanelQueryCapabilities;

View File

@@ -4,6 +4,11 @@ import type {
} from 'api/generated/services/sigNoz.schemas';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import {
isBuilderEnvelope,
isBuilderPluginKind,
} from 'lib/query/builderEnvelope';
/**
* Flattens a panel's queries into its builder queries, unwrapping
* `CompositeQuery` envelopes. Non-builder kinds (PromQL, ClickHouseSQL, Formula,
@@ -16,13 +21,13 @@ export function getBuilderQueries(
const flattened: BuilderQuery[] = [];
queries.forEach((envelope) => {
const plugin = envelope.spec.plugin;
if (plugin.kind === 'signoz/BuilderQuery') {
if (isBuilderPluginKind(plugin.kind)) {
flattened.push(plugin.spec as BuilderQuery);
return;
}
if (plugin.kind === 'signoz/CompositeQuery') {
(plugin.spec.queries || []).forEach((sub) => {
if (sub.type === 'builder_query') {
if (isBuilderEnvelope(sub)) {
flattened.push(sub.spec as BuilderQuery);
}
});

View File

@@ -2,7 +2,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { initialQueriesMap } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getQueryPanelDefinition } from '../capabilities';
import { getQueryPanelDefinition, getSupportedSignals } from '../capabilities';
import { toPanelType } from '../types/panelKind';
import { fromPerses } from '../../queryV5/persesQueryAdapters';
@@ -22,7 +22,7 @@ export function getPanelBuilderQuery(
if (!definition) {
return null;
}
const [defaultSignal] = definition.supportedSignals;
const [defaultSignal] = getSupportedSignals(kind);
// A query-less panel seeds from the kind's first supported signal — `fromPerses`'s
// metrics default isn't authorable in every kind (e.g. List).
if (panel.spec.queries.length === 0 && defaultSignal) {

View File

@@ -0,0 +1,43 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
initialQueriesMap,
initialQueryAIWithType,
type PANEL_TYPES,
} from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import {
DataSource,
type QueryBuilderContextType,
} from 'types/common/queryBuilder';
interface SeedBuilderForModeArgs {
mode: QueryMode;
/** The kind's first Query Builder signal; what a fresh panel of this kind starts on. */
defaultSignal?: TelemetrytypesSignalDTO;
panelType: PANEL_TYPES;
updateAllQueriesOperators: QueryBuilderContextType['updateAllQueriesOperators'];
}
/** A fresh builder query for `mode`: AI is traces-only, everything else follows the kind. */
export function seedBuilderForMode({
mode,
defaultSignal,
panelType,
updateAllQueriesOperators,
}: SeedBuilderForModeArgs): Query['builder'] {
if (mode === QueryMode.AI_QUERY_BUILDER) {
return updateAllQueriesOperators(
initialQueryAIWithType,
panelType,
DataSource.TRACES,
).builder;
}
const dataSource = (defaultSignal ??
TelemetrytypesSignalDTO.metrics) as unknown as DataSource;
return updateAllQueriesOperators(
initialQueriesMap[dataSource],
panelType,
dataSource,
).builder;
}

View File

@@ -416,6 +416,42 @@ describe('usePanelActionItems', () => {
expect(mockOpenView).toHaveBeenCalledWith('panel-1', baseArgs.panel);
});
it('disables only Create Alerts on an AI query panel', () => {
const aiPanel = {
...mockPanel,
spec: {
...mockPanel.spec,
queries: [
{
kind: 'time_series',
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: [
{
type: 'builder_ai_query',
spec: { name: 'A', signal: 'traces' },
},
],
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
const { result } = renderHook(() =>
usePanelActionItems({ ...baseArgs, panel: aiPanel }),
);
const disabled = disabledKeys(result.current);
expect(disabled).toContain('create-alert');
expect(disabled).not.toContain('view-panel');
expect(disabled).not.toContain('edit-panel');
});
it('create-alert seeds an alert from this panel', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const createAlert = result.current.items.find(

View File

@@ -28,6 +28,7 @@ import { useViewPanel } from '../hooks/useViewPanel';
import { buildMoveItems } from '../utils/buildMoveItems';
import MenuActionItem from '../../../components/MenuActionItem/MenuActionItem';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import { getBlockedPanelActions } from '../utils/getBlockedPanelActions';
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
// Stable fallback so renders without layout context don't churn the mutation
@@ -63,6 +64,7 @@ export function usePanelActionItems({
panelActions,
}: UsePanelActionItemsArgs): PanelActionItems {
const panelKind = panel.spec.plugin.kind;
const blocked = useMemo(() => getBlockedPanelActions(panel), [panel]);
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
const openPanelEditor = useOpenPanelEditor();
@@ -103,13 +105,17 @@ export function usePanelActionItems({
const row = (
text: string,
icon: ReactElement,
opts: { checks?: BrandedPermission[]; destructive?: boolean } = {},
opts: {
checks?: BrandedPermission[];
destructive?: boolean;
disabledTooltip?: string;
} = {},
): ReactNode => (
<MenuActionItem
label={text}
icon={icon}
checks={opts.checks ?? editChecks}
disabledTooltip={editDisabledTooltip}
disabledTooltip={opts.disabledTooltip ?? editDisabledTooltip}
destructive={opts.destructive}
/>
);
@@ -157,7 +163,11 @@ export function usePanelActionItems({
if (panelCapabilities.createAlert) {
dataGroup.push({
key: 'create-alert',
label: row('Create Alerts', <Bell size={14} />, { checks: [] }),
label: row('Create Alerts', <Bell size={14} />, {
checks: [],
disabledTooltip: blocked.createAlert,
}),
disabled: !!blocked.createAlert,
onClick: (): void => createAlert(panel, panelId),
});
}
@@ -197,6 +207,7 @@ export function usePanelActionItems({
editChecks,
editDisabledTooltip,
panelCapabilities,
blocked,
panel,
panelActions,
sections,

View File

@@ -12,6 +12,7 @@ import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/quer
import type { PanelActionsConfig } from '../Panel';
import PanelActionsMenu from '../PanelActionsMenu/PanelActionsMenu';
import { EMPTY_PANEL_QUERY_DATA } from '../utils/emptyPanelQueryData';
import { usePanelTitle } from '../hooks/usePanelTitle';
import PanelHeaderSearch from './PanelHeaderSearch';
import PanelStatusPopover from '../PanelStatus/PanelStatusPopover';
import {
@@ -67,7 +68,7 @@ function PanelHeader(props: PanelHeaderProps): JSX.Element {
const { panelId, panel, panelActions, hideActions } = props;
const query = props.mode === 'query' ? props : null;
const name = panel.spec.display.name;
const name = usePanelTitle(panel);
const description = panel.spec.display.description;
const errorDetail = useMemo(
() => panelStatusFromError(query?.error),

View File

@@ -6,6 +6,7 @@ import { useDashboardSections } from '../../../hooks/useDashboardSections';
import { releasePanelPickerTarget } from '../../../store/usePanelPickerTargetStore';
import type { NewPanelTarget } from '../../../patchOps';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { JS_DRIVEN_SLIDE } from '../../../utils/drawerSlide';
import AddPanelSplitButton from './AddPanelSplitButton';
import NewSectionNameInput from './NewSectionNameInput';
import PanelTypeBrowser from './PanelTypeBrowser';
@@ -106,6 +107,7 @@ function PanelTypeSelectionModal({
direction="right"
width="wide"
testId="panel-type-drawer"
drawerContentProps={JS_DRIVEN_SLIDE}
drawerDescriptionProps={{ className: styles.body }}
footer={
<div className={styles.footer}>

View File

@@ -31,6 +31,8 @@ interface QueryViewModalBodyProps {
draftApi: PanelEditorDraftApi;
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
onChangePanelKind: (kind: PanelKind) => void;
/** Tab switch, owned by the shell alongside the kind switch it shares memory with. */
onChangeQueryMode: (key: string) => void;
}
/**
@@ -45,6 +47,7 @@ function QueryViewModalBody({
onClose,
draftApi,
onChangePanelKind,
onChangeQueryMode,
}: QueryViewModalBodyProps): JSX.Element | null {
const {
timeOverride,
@@ -162,6 +165,7 @@ function QueryViewModalBody({
panelDefinition={panelDefinition}
signal={signal}
isLoadingQueries={isFetching}
onChangeQueryMode={onChangeQueryMode}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
stickyHeader={false}

View File

@@ -5,13 +5,13 @@ import {
DialogHeader,
DialogTitle,
} from '@signozhq/ui/dialog';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { ConfigProvider } from 'antd';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { useRef } from 'react';
import ViewPanelModalContent from './ViewPanelModalContent';
import ViewPanelModalTitle from './ViewPanelModalTitle';
import styles from './ViewPanelModal.module.scss';
interface ViewPanelModalProps {
@@ -31,8 +31,6 @@ function ViewPanelModal({
open,
onClose,
}: ViewPanelModalProps): JSX.Element {
const name = panel?.spec.display.name ?? '';
// Render antd popups into the dialog (not document.body) so they stay inside the
// modal's interactive, focus-trapped layer instead of being blocked by Radix.
const contentRef = useRef<HTMLDivElement>(null);
@@ -54,11 +52,11 @@ function ViewPanelModal({
>
<DialogHeader>
<DialogTitle>
<TooltipSimple title={name} arrow>
<Typography.Text className={styles.title}>
{name ? `${name} - (View mode)` : 'View mode'}
</Typography.Text>
</TooltipSimple>
{panel ? (
<ViewPanelModalTitle panel={panel} />
) : (
<Typography.Text className={styles.title}>View mode</Typography.Text>
)}
</DialogTitle>
</DialogHeader>
<DialogCloseButton />

View File

@@ -8,7 +8,7 @@ import { QueryParams } from 'constants/query';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import useUrlQuery from 'hooks/useUrlQuery';
import { usePanelEditorDraft } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelEditorDraft';
import { usePanelTypeSwitch } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelTypeSwitch';
import { usePanelKindAndQueryModeSwitch } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelKindAndQueryModeSwitch';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { buildViewPanelSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/drilldown/buildViewPanelSpec';
@@ -73,11 +73,12 @@ function ViewPanelModalContent({
const draftKind = draftApi.draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(draftKind);
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
panelType: toPanelType(draftKind),
setSpec: draftApi.setSpec,
});
const { onChangePanelKind, onChangeQueryMode } =
usePanelKindAndQueryModeSwitch({
spec: draftApi.draft.spec,
panelType: toPanelType(draftKind),
setSpec: draftApi.setSpec,
});
if (panelDefinition.mode === 'static') {
return (
@@ -97,6 +98,7 @@ function ViewPanelModalContent({
onClose={onClose}
draftApi={draftApi}
onChangePanelKind={onChangePanelKind}
onChangeQueryMode={onChangeQueryMode}
/>
);
}

View File

@@ -0,0 +1,24 @@
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { usePanelTitle } from '../hooks/usePanelTitle';
import styles from './ViewPanelModal.module.scss';
interface ViewPanelModalTitleProps {
panel: DashboardtypesPanelDTO;
}
function ViewPanelModalTitle({ panel }: ViewPanelModalTitleProps): JSX.Element {
const name = usePanelTitle(panel);
return (
<TooltipSimple title={name} arrow>
<Typography.Text className={styles.title}>
{name ? `${name} - (View mode)` : 'View mode'}
</Typography.Text>
</TooltipSimple>
);
}
export default ViewPanelModalTitle;

View File

@@ -1,8 +1,12 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import {
type DashboardtypesPanelDTO,
Querybuildertypesv5VariableTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import type { ReactElement } from 'react';
import type { Warning } from 'types/api';
@@ -100,6 +104,30 @@ describe('PanelHeader title and description', () => {
expect(screen.getByText('My panel')).toBeInTheDocument();
});
it('substitutes dashboard variables into the panel name', () => {
useDashboardStore.setState({
dashboardId: 'dash-1',
resolvedVariables: {
'dash-1': {
service: {
type: Querybuildertypesv5VariableTypeDTO.query,
value: ['cart', 'api'],
},
},
},
});
renderWithProvider(
<PanelHeader
{...baseProps}
panel={makePanel({ name: 'Latency of $service ({{missing}})' })}
/>,
);
expect(
screen.getByText('Latency of cart, api ({{missing}})'),
).toBeInTheDocument();
useDashboardStore.setState({ dashboardId: '', resolvedVariables: {} });
});
it('shows the description info icon when a description is provided', () => {
renderWithProvider(
<PanelHeader

View File

@@ -40,9 +40,11 @@ jest.mock('hooks/useUrlQuery', () => ({
default: (): URLSearchParams => new URLSearchParams(),
}));
jest.mock(
'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelTypeSwitch',
'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelKindAndQueryModeSwitch',
() => ({
usePanelTypeSwitch: (): unknown => ({ onChangePanelKind: jest.fn() }),
usePanelKindAndQueryModeSwitch: (): unknown => ({
onChangePanelKind: jest.fn(),
}),
}),
);

View File

@@ -89,7 +89,7 @@ jest.mock(
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
selector({ dashboardId: 'dash-1', resolvedVariables: {} }),
}),
);

View File

@@ -38,7 +38,7 @@ jest.mock(
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
selector({ dashboardId: 'dash-1', resolvedVariables: {} }),
}),
);

View File

@@ -8,6 +8,8 @@ import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/
import { downloadCsv } from 'pages/DashboardPage/DashboardContainer/Panels/utils/downloadCsv';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { usePanelTitle } from './usePanelTitle';
interface UseDownloadPanelCsvArgs {
panel: DashboardtypesPanelDTO;
data: PanelQueryData;
@@ -29,7 +31,7 @@ export function useDownloadPanelCsv({
data,
canDownloadCsv,
}: UseDownloadPanelCsvArgs): () => void {
const fileName = panel.spec.display.name;
const fileName = usePanelTitle(panel);
return useCallback((): void => {
if (!canDownloadCsv) {

View File

@@ -10,6 +10,7 @@ import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/quer
import { buildDownloadMenuItem } from '../utils/buildDownloadMenuItem';
import { useDownloadPanelCsv } from './useDownloadPanelCsv';
import { useDownloadPanelImage } from './useDownloadPanelImage';
import { usePanelTitle } from './usePanelTitle';
interface UseDownloadPanelMenuItemArgs {
panelId: string;
@@ -28,7 +29,7 @@ export function useDownloadPanelMenuItem({
data,
actions,
}: UseDownloadPanelMenuItemArgs): MenuItem | null {
const panelName = panel.spec.display.name;
const panelName = usePanelTitle(panel);
const downloadPanelCsv = useDownloadPanelCsv({
panel,
data,

View File

@@ -0,0 +1,13 @@
import { useMemo } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { interpolateVariables } from 'pages/DashboardPage/DashboardContainer/Panels/utils/interpolateVariables';
import { selectResolvedVariables } from 'pages/DashboardPage/DashboardContainer/store/slices/variableSelectionSlice';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
export function usePanelTitle(panel: DashboardtypesPanelDTO): string {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
const name = panel.spec.display.name;
return useMemo(() => interpolateVariables(name, variables), [name, variables]);
}

View File

@@ -0,0 +1,21 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { isAIBuilderEnvelope } from 'lib/query/builderEnvelope';
import { toQueryEnvelopes } from '../../../queryV5/buildQueryRangeRequest';
import type { PanelActionId } from '../PanelActionsMenu/panelActionMeta';
//TODO: @tewarig will enable these actions for AI Observability Panels
const AI_PANEL_REASON = 'Coming Soon For AI Observability Panels';
export type BlockedPanelActions = Partial<Record<PanelActionId, string>>;
/** Actions the panel's queries don't support yet, mapped to the disabled reason. */
export function getBlockedPanelActions(
panel: DashboardtypesPanelDTO,
): BlockedPanelActions {
// The alert builder has no AI query mode. View in Logs/Traces stays on.
if (toQueryEnvelopes(panel.spec.queries).some(isAIBuilderEnvelope)) {
return { createAlert: AI_PANEL_REASON };
}
return {};
}

View File

@@ -0,0 +1,16 @@
import { useMemo } from 'react';
import { dtoToFormModel } from 'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters';
import { useDashboardFetchRequired } from './useDashboardFetchRequired';
export function useDashboardVariableNames(): string[] {
const { variables } = useDashboardFetchRequired();
return useMemo(
() =>
variables
.map((dto) => dtoToFormModel(dto).name)
.filter((name): name is string => !!name),
[variables],
);
}

View File

@@ -99,6 +99,23 @@ describe('toQueryEnvelopes', () => {
]);
});
it('wraps a bare AI builder plugin as a builder_ai_query envelope', () => {
const ai = [
{
kind: 'TimeSeriesQuery',
spec: {
plugin: {
kind: 'signoz/AIBuilderQuery',
spec: { name: 'A', signal: 'traces' },
},
},
},
] as unknown as DashboardtypesQueryDTO[];
expect(toQueryEnvelopes(ai)).toStrictEqual([
{ type: 'builder_ai_query', spec: { name: 'A', signal: 'traces' } },
]);
});
it('passes a CompositeQuery envelope list through verbatim', () => {
const subqueries = [
{ type: 'builder_query', spec: { name: 'A' } },
@@ -207,6 +224,30 @@ describe('buildQueryRangeRequest', () => {
expect(request.formatOptions?.fillGaps).toBe(true);
});
it('stamps offset/limit onto an AI builder query when pagination is given', () => {
const request = buildQueryRangeRequest({
queries: [
{
kind: 'raw',
spec: {
plugin: {
kind: 'signoz/AIBuilderQuery',
spec: { name: 'A', signal: 'traces' },
},
},
},
] as unknown as DashboardtypesQueryDTO[],
queryCapabilities: LIST_PANEL_CAPABILITIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
pagination: { offset: 25, limit: 10 },
});
expect(request.compositeQuery?.queries?.[0]).toMatchObject({
type: 'builder_ai_query',
spec: { name: 'A', signal: 'traces', offset: 25, limit: 10 },
});
});
it('stamps offset/limit onto builder queries when pagination is given', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'logs' }),

View File

@@ -4,7 +4,11 @@ import type {
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Querybuildertypesv5BucketOptionsLogDTOKind } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import {
initialQueriesMap,
initialQueryAIWithType,
PANEL_TYPES,
} from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
@@ -221,6 +225,38 @@ describe('persesQueryAdapters', () => {
expect(result[0].kind).toBe('scalar');
});
it('emits a bare signoz/AIBuilderQuery for an AI List panel', () => {
const result = toPerses(initialQueryAIWithType, PANEL_TYPES.LIST);
expect(result).toHaveLength(1);
expect(result[0].spec.plugin.kind).toBe('signoz/AIBuilderQuery');
});
it('preserves an AI List query through toPerses → fromPerses', () => {
const restored = fromPerses(
toPerses(initialQueryAIWithType, PANEL_TYPES.LIST),
PANEL_TYPES.LIST,
);
expect(restored.builder.queryData[0].builderQueryType).toBe(
'builder_ai_query',
);
});
it('preserves an AI time-series query through fromPerses → toPerses', () => {
const perses = toPerses(initialQueryAIWithType, PANEL_TYPES.TIME_SERIES);
const restored = fromPerses(perses, PANEL_TYPES.TIME_SERIES);
const savedAgain = toPerses(restored, PANEL_TYPES.TIME_SERIES);
const envelopes = (
savedAgain[0].spec.plugin.spec as { queries?: { type?: string }[] }
).queries;
expect(restored.builder.queryData[0].builderQueryType).toBe(
'builder_ai_query',
);
expect(envelopes?.[0]?.type).toBe('builder_ai_query');
});
it('emits a bare signoz/BuilderQuery for a List panel (not a CompositeQuery)', () => {
const result = toPerses(
initialQueriesMap[DataSource.LOGS],

View File

@@ -5,18 +5,21 @@ import type {
Querybuildertypesv5CompositeQueryDTO,
Querybuildertypesv5OrderByDTO,
Querybuildertypesv5PromQueryDTO,
Querybuildertypesv5QueryEnvelopeBuilderAIDTO,
Querybuildertypesv5QueryEnvelopeDTO,
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5QueryRangeRequestDTOVariables,
} from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5OrderDirectionDTO,
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
} from 'api/generated/services/sigNoz.schemas';
import type { PanelQueryCapabilities } from '../Panels/types/panelCapabilities';
import { isBuilderEnvelope } from 'lib/query/builderEnvelope';
// Narrow view over the envelope spec variants. Orval erases envelope `spec` to `unknown`, so
// shared fields are read through this view with a localized cast at the envelope boundary.
@@ -58,6 +61,15 @@ export function toQueryEnvelopes(
spec: plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO,
},
];
case 'signoz/AIBuilderQuery':
// Same spec shape as a traces builder query. The envelope type is what
// tells the querier to run the AI statement builder.
return [
{
type: Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query,
spec: plugin.spec,
} as unknown as Querybuildertypesv5QueryEnvelopeBuilderAIDTO,
];
case 'signoz/PromQLQuery':
return [
{
@@ -127,13 +139,10 @@ function withBucketedStepInterval(
): Querybuildertypesv5QueryEnvelopeDTO[] {
const stepInterval = getBucketedStepIntervalSeconds(startMs, endMs);
return envelopes.map((envelope) => {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
if (!isBuilderEnvelope(envelope)) {
return envelope;
}
if (envelope.spec?.stepInterval) {
if ((envelope.spec as QuerySpecView)?.stepInterval) {
return envelope;
}
return {
@@ -141,8 +150,8 @@ function withBucketedStepInterval(
spec: {
...envelope.spec,
stepInterval,
} as Querybuildertypesv5BuilderQuerySpecDTO,
};
},
} as Querybuildertypesv5QueryEnvelopeDTO;
});
}
@@ -155,10 +164,7 @@ function withListOrderTiebreaker(
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
): Querybuildertypesv5QueryEnvelopeDTO[] {
return envelopes.map((envelope) => {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
if (!isBuilderEnvelope(envelope)) {
return envelope;
}
const spec = envelope.spec as QuerySpecView;
@@ -183,8 +189,8 @@ function withListOrderTiebreaker(
...primary,
{ key: { name: 'id' }, direction: primary[0].direction },
],
} as Querybuildertypesv5BuilderQuerySpecDTO,
};
},
} as Querybuildertypesv5QueryEnvelopeDTO;
});
}
@@ -197,10 +203,7 @@ function withPagination(
{ offset, limit }: { offset: number; limit: number },
): Querybuildertypesv5QueryEnvelopeDTO[] {
return envelopes.map((envelope) => {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
if (!isBuilderEnvelope(envelope)) {
return envelope;
}
return {
@@ -209,8 +212,8 @@ function withPagination(
...envelope.spec,
offset,
limit,
} as Querybuildertypesv5BuilderQuerySpecDTO,
};
},
} as Querybuildertypesv5QueryEnvelopeDTO;
});
}
@@ -303,11 +306,7 @@ export function hasRunnableQueries(queries: DashboardtypesQueryDTO[]): boolean {
}
const metricsSpecs = envelopes
.filter(
(envelope) =>
envelope.type ===
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
)
.filter(isBuilderEnvelope)
.map((envelope) => envelope.spec as QuerySpecView)
.filter((spec) => spec.signal === 'metrics');

View File

@@ -1,13 +1,14 @@
import type {
DashboardtypesAIBuilderQuerySpecDTO,
DashboardtypesBuilderQuerySpecDTO,
DashboardtypesQueryDTO,
Querybuildertypesv5CompositeQueryDTO,
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTOKind as AIBuilderQueryPluginKind,
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind as BuilderQueryPluginKind,
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTOKind as CompositeQueryPluginKind,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
Querybuildertypesv5RequestTypeDTO,
@@ -21,6 +22,10 @@ import type { QueryEnvelope } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import {
isAIBuilderEnvelope,
isBuilderEnvelope,
} from 'lib/query/builderEnvelope';
import { toQueryEnvelopes } from './buildQueryRangeRequest';
/**
@@ -44,11 +49,6 @@ const toGeneratedEnvelopes = (
): Querybuildertypesv5QueryEnvelopeDTO[] =>
envelopes as unknown as Querybuildertypesv5QueryEnvelopeDTO[];
const isBuilderQueryEnvelope = (
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): boolean =>
envelope.type === Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query;
/**
* Clears the V1 explorer's `pageSize`/`offset` before conversion — the shared mapper folds
* `pageSize` into the V5 `limit`, which usePanelQuery would read as a user cap and hide the
@@ -158,8 +158,9 @@ export function fromPerses(
/**
* V1 `Query` → perses panel queries (to write the builder result back to the editor
* draft). Wrapped in a single `signoz/CompositeQuery` to satisfy the
* `panel.queries.length === 1` invariant. Exception: List emits its one builder query
* as a bare `signoz/BuilderQuery` because the backend rejects a `signoz/CompositeQuery`.
* `panel.queries.length === 1` invariant. Exception: List rejects `signoz/CompositeQuery`
* backend-side, so it emits its one builder query as the bare plugin matching the query —
* a bare plugin carries no envelope `type`, so the kind is what keeps an AI list AI.
*/
export function toPerses(
query: Query,
@@ -173,21 +174,23 @@ export function toPerses(
const envelopes = toGeneratedEnvelopes(composite.queries ?? []);
if (panelType === PANEL_TYPES.LIST) {
const builder = envelopes.find(isBuilderQueryEnvelope);
const builder = envelopes.find(isBuilderEnvelope);
if (!builder) {
return [];
}
const plugin = isAIBuilderEnvelope(builder)
? {
kind: AIBuilderQueryPluginKind['signoz/AIBuilderQuery'],
spec: builder.spec as unknown as DashboardtypesAIBuilderQuerySpecDTO,
}
: {
kind: BuilderQueryPluginKind['signoz/BuilderQuery'],
spec: builder.spec as DashboardtypesBuilderQuerySpecDTO,
};
return [
{
kind: panelTypeToRequestType(panelType),
spec: {
plugin: {
kind: BuilderQueryPluginKind['signoz/BuilderQuery'],
// The generated envelope union doesn't discriminate `spec` by `type`, so
// narrow the filtered builder query to the dashboard builder spec.
spec: builder.spec as DashboardtypesBuilderQuerySpecDTO,
},
},
spec: { plugin },
},
];
}

View File

@@ -3,10 +3,9 @@ import type {
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5ScalarDataDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
} from 'api/generated/services/sigNoz.schemas';
import { Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType } from 'api/generated/services/sigNoz.schemas';
import { isBuilderEnvelope } from 'lib/query/builderEnvelope';
import type { PanelTable, PanelTableColumn } from './types';
@@ -28,10 +27,7 @@ export function extractAggregationsPerQuery(
): AggregationsPerQuery {
const perQuery: AggregationsPerQuery = {};
(requestPayload?.compositeQuery?.queries ?? []).forEach((envelope) => {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
if (!isBuilderEnvelope(envelope)) {
return;
}
const spec = envelope.spec;

View File

@@ -0,0 +1,10 @@
import type { DrawerWrapperProps } from '@signozhq/ui/drawer';
import { noop } from 'lodash-es';
/**
* `DrawerWrapper`'s motion@11 runs its slide on WAAPI and paints one stale frame
* when it finishes; an `onUpdate` (untyped, forwarded to motion) keeps it JS-driven.
*/
export const JS_DRIVEN_SLIDE = {
onUpdate: noop,
} as DrawerWrapperProps['drawerContentProps'];

View File

@@ -209,4 +209,29 @@ describe('mapQueryDataFromApi', (): void => {
id: 'b5f4b7db-799c-47d2-bf32-090340995e20',
});
});
it('keeps builder_ai_query on the mapped builder query', () => {
const compositeQuery: ICompositeMetricQuery = {
queryType: EQueryType.QUERY_BUILDER,
panelType: PANEL_TYPES.TIME_SERIES,
unit: undefined,
queries: [
{
type: 'builder_ai_query',
spec: {
name: 'A',
signal: 'traces',
filter: { expression: "service.name = 'adservice'" },
aggregations: [{ expression: 'count()' }],
},
} as QueryEnvelope,
],
};
const result = mapQueryDataFromApi(compositeQuery);
expect(result.builder.queryData[0].builderQueryType).toBe('builder_ai_query');
expect(result.builder.queryData[0].queryName).toBe('A');
expect(result.builder.queryData[0].dataSource).toBe(DataSource.TRACES);
});
});

View File

@@ -1,4 +1,7 @@
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5BucketOptionsDTO,
Querybuildertypesv5QueryTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import { Format } from 'constants/formats/types';
import { EQueryType } from 'types/common/dashboard';
@@ -19,6 +22,14 @@ import {
} from '../v5/queryRange';
import { BaseAutocompleteData } from './queryAutocompleteResponse';
export const QueryMode = {
...EQueryType,
/** Not an `EQueryType`: AI queries are builder queries tagged `builderQueryType: builder_ai_query`. */
AI_QUERY_BUILDER: Querybuildertypesv5QueryTypeDTO.builder_ai_query,
} as const;
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode];
// Type for Formula
export interface IBuilderFormula {
expression: string;
@@ -141,3 +152,17 @@ export type MapQueryDataToApiResult<T> = {
data: T;
newLegendMap: Record<string, string>;
};
/** Query Builder and AI share `Query.builder`; the variant says which one it holds. */
export const BuilderVariant = {
MAIN: 'main',
AI: 'ai',
} as const;
export type BuilderVariant =
(typeof BuilderVariant)[keyof typeof BuilderVariant];
/** Builder queries per variant; `Query` has one `builder` slot, so the off-screen variant lives here. */
export type BuildersByVariant = Partial<
Record<BuilderVariant, Query['builder']>
>;

View File

@@ -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 spans carrying gen_ai input or output messages in timestamp order. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.",
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",

View File

@@ -9,7 +9,6 @@ var (
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
FeatureJSONBodyDualIngestion = featuretypes.MustNewName("json_body_dual_ingestion")
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
FeatureResolveSemconvFamilies = featuretypes.MustNewName("resolve_semconv_families")
FeatureUseTraceAttributesJSON = featuretypes.MustNewName("use_trace_attributes_json")
@@ -65,14 +64,6 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureJSONBodyDualIngestion,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether the collector's normalize operator keeps the original log body so it is ingested into both the legacy body and the JSON body columns",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureEnableMetricsReduction,
Kind: featuretypes.KindBoolean,

View File

@@ -158,10 +158,6 @@ func (s *traceStore) GetTraceStats(ctx context.Context, orgID valuer.UUID, trace
// genAISpanColumns returns the gen_ai columns aggregated per span, resolved across attribute evolutions.
func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bounds *spantypes.TraceBounds, sb *sqlbuilder.SelectBuilder) ([]string, error) {
attributeKey := func(name string, dataType telemetrytypes.FieldDataType) *telemetrytypes.TelemetryFieldKey {
return &telemetrytypes.TelemetryFieldKey{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: dataType}
}
values := []struct{ key, alias string }{
{aiobservabilitytypes.GenAIUsageInputTokens, "input_tokens_value"},
{aiobservabilitytypes.GenAIUsageOutputTokens, "output_tokens_value"},
@@ -175,26 +171,17 @@ func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bo
for _, value := range values {
names = append(names, value.key)
}
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(names))
for _, name := range names {
selectors = append(selectors, &telemetrytypes.FieldKeySelector{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact})
}
keys, _, err := s.metadataStore.GetKeysMulti(ctx, orgID, querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, s.flagger, selectors))
keys, err := s.genAIFieldKeys(ctx, orgID, names)
if err != nil {
return nil, err
}
q := querybuilder.NewQueryInfo(ctx, orgID, s.flagger, telemetrytypes.SignalTraces, nil, uint64(bounds.Start.UnixNano()), uint64(bounds.End.UnixNano()))
gate := make([]string, 0, len(aiobservabilitytypes.GenAISpanGateKeys))
for _, name := range aiobservabilitytypes.GenAISpanGateKeys {
conds, _, err := querybuilder.Conditions(ctx, q, s.storage, attributeKey(name, telemetrytypes.FieldDataTypeString), qbtypes.FilterOperatorExists, nil, keys, false, sb)
if err != nil {
return nil, err
}
gate = append(gate, conds...)
isGenAI, err := s.anyExistsCondition(ctx, q, aiobservabilitytypes.GenAISpanGateKeys, keys, sb)
if err != nil {
return nil, err
}
columns := []string{sb.Or(gate...) + " AS is_gen_ai"}
columns := []string{isGenAI + " AS is_gen_ai"}
for _, value := range values {
// lookup by number, the type metadata stores numeric attributes under; float64 is only the output cast
@@ -310,7 +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))
hasMessages, err := s.messagesExistCondition(ctx, q, orgID, bounds, sb)
keys, err := s.genAIFieldKeys(ctx, orgID, aiobservabilitytypes.GenAIThreadKeys)
if err != nil {
return nil, err
}
isThreadSpan, err := s.anyExistsCondition(ctx, q, aiobservabilitytypes.GenAIThreadKeys, keys, sb)
if err != nil {
return nil, err
}
@@ -318,7 +309,7 @@ func (s *traceStore) GetThreadSpans(ctx context.Context, orgID valuer.UUID, trac
sb.E("trace_id", traceID),
sb.GE("ts_bucket_start", bounds.Start.Unix()-1800),
sb.LE("ts_bucket_start", bounds.End.Unix()),
hasMessages,
isThreadSpan,
)
if cursor := page.Cursor; cursor != nil {
// ClickHouse can't use an index for a tuple comparison, so the separate timestamp and
@@ -355,38 +346,6 @@ func (s *traceStore) GetThreadSpans(ctx context.Context, orgID valuer.UUID, trac
return spans, nil
}
// messagesExistCondition resolves the gen_ai message keys through the attribute evolution metadata
// and the use_trace_attributes_json flag, so the filter reads the same columns the query builder does.
func (s *traceStore) messagesExistCondition(ctx context.Context, q qbtypes.QueryInfo, orgID valuer.UUID, bounds *spantypes.TraceBounds, sb *sqlbuilder.SelectBuilder) (string, error) {
names := []string{aiobservabilitytypes.GenAIInputMessages, aiobservabilitytypes.GenAIOutputMessages}
selectors := make([]*telemetrytypes.FieldKeySelector, len(names))
for i, name := range names {
selectors[i] = &telemetrytypes.FieldKeySelector{
StartUnixMilli: bounds.Start.UnixMilli(),
EndUnixMilli: bounds.End.UnixMilli(),
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextAttribute,
Name: name,
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
}
}
fieldKeys, _, err := s.metadataStore.GetKeysMulti(ctx, orgID, selectors)
if err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "error fetching thread field keys")
}
conds := make([]string, 0, len(names))
for _, name := range names {
key := &telemetrytypes.TelemetryFieldKey{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute}
keyConds, _, err := querybuilder.Conditions(ctx, q, s.storage, key, qbtypes.FilterOperatorExists, nil, fieldKeys, false, sb)
if err != nil {
return "", err
}
conds = append(conds, keyConds...)
}
return sb.Or(conds...), nil
}
func (s *traceStore) GetThreadCursor(ctx context.Context, traceID string, bounds *spantypes.TraceBounds, spanID string) (*spantypes.ThreadCursor, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("toUnixTimestamp64Nano(timestamp)")
@@ -532,3 +491,34 @@ func (s *traceStore) GetSpanDurationByField(ctx context.Context, traceID string,
}
return result, nil
}
func attributeKey(name string, dataType telemetrytypes.FieldDataType) *telemetrytypes.TelemetryFieldKey {
return &telemetrytypes.TelemetryFieldKey{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: dataType}
}
// genAIFieldKeys fetches the metadata keys for the attribute names, expanded across semconv families.
func (s *traceStore) genAIFieldKeys(ctx context.Context, orgID valuer.UUID, names []string) (map[string][]*telemetrytypes.TelemetryFieldKey, error) {
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(names))
for _, name := range names {
selectors = append(selectors, &telemetrytypes.FieldKeySelector{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact})
}
keys, _, err := s.metadataStore.GetKeysMulti(ctx, orgID, querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, s.flagger, selectors))
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error fetching gen_ai field keys")
}
return keys, nil
}
// anyExistsCondition ORs an EXISTS test per attribute name, resolved across attribute evolutions
// and the use_trace_attributes_json flag so the filter reads the same columns the query builder does.
func (s *traceStore) anyExistsCondition(ctx context.Context, q qbtypes.QueryInfo, names []string, keys map[string][]*telemetrytypes.TelemetryFieldKey, sb *sqlbuilder.SelectBuilder) (string, error) {
conds := make([]string, 0, len(names))
for _, name := range names {
keyConds, _, err := querybuilder.Conditions(ctx, q, s.storage, attributeKey(name, telemetrytypes.FieldDataTypeString), qbtypes.FilterOperatorExists, nil, keys, false, sb)
if err != nil {
return "", err
}
conds = append(conds, keyConds...)
}
return sb.Or(conds...), nil
}

View File

@@ -220,14 +220,14 @@ func TestGetThreadSpans(t *testing.T) {
name: "FlagOff_ReadsAndFiltersLegacyMaps",
jsonRelease: &jsonInsideTrace,
selectSQL: selectSQL,
whereSQL: "(mapContains(attributes_string, 'gen_ai.input.messages') OR mapContains(attributes_string, 'gen_ai.output.messages'))",
whereSQL: "(mapContains(attributes_string, 'gen_ai.input.messages') OR mapContains(attributes_string, 'gen_ai.output.messages') OR mapContains(attributes_string, 'gen_ai.tool.name'))",
},
{
name: "FlagOn_ReleasedDuringTrace_ReadsJSONFiltersJSONThenMaps",
jsonOn: true,
jsonRelease: &jsonInsideTrace,
selectSQL: selectSQL + ", attributes",
whereSQL: "(multiIf(attributes.`gen_ai.input.messages` IS NOT NULL, attributes.`gen_ai.input.messages`::String, mapContains(attributes_string, 'gen_ai.input.messages'), attributes_string['gen_ai.input.messages'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.output.messages` IS NOT NULL, attributes.`gen_ai.output.messages`::String, mapContains(attributes_string, 'gen_ai.output.messages'), attributes_string['gen_ai.output.messages'], NULL) IS NOT NULL)",
whereSQL: "(multiIf(attributes.`gen_ai.input.messages` IS NOT NULL, attributes.`gen_ai.input.messages`::String, mapContains(attributes_string, 'gen_ai.input.messages'), attributes_string['gen_ai.input.messages'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.output.messages` IS NOT NULL, attributes.`gen_ai.output.messages`::String, mapContains(attributes_string, 'gen_ai.output.messages'), attributes_string['gen_ai.output.messages'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.tool.name` IS NOT NULL, attributes.`gen_ai.tool.name`::String, mapContains(attributes_string, 'gen_ai.tool.name'), attributes_string['gen_ai.tool.name'], NULL) IS NOT NULL)",
},
}

View File

@@ -2,6 +2,7 @@ package logparsingpipeline
import (
"encoding/json"
"slices"
"strings"
"sync"
@@ -24,10 +25,13 @@ var (
)
const (
memoryLimiterProcessor = "memory_limiter"
memoryLimiterProcessorPrefix = "memory_limiter/"
batchProcessor = "batch"
batchProcessorPrefix = "batch/"
memoryLimiterProcessor = "memory_limiter"
memoryLimiterProcessorPrefix = "memory_limiter/"
batchProcessor = "batch"
batchProcessorPrefix = "batch/"
normalizerProcessor = "signozlogsnormalizer"
normalizerProcessorPrefix = "signozlogsnormalizer/"
normalizerBeforeUserPipelines = "before"
)
// check if the processors already exist
@@ -90,9 +94,14 @@ func getOtelPipelineFromConfig(config map[string]interface{}) (*otelPipeline, er
// 2. signoz user-pipeline processors (in the order given by signozPipelineProcessorNames)
// 3. custom processors (non-signoz, non-memory_limiter, non-batch processors from the current config)
// 4. batch processors (any processor named "batch" or "batch/<id>") and anything after them
//
// If a signozlogsnormalizer processor is present, the order above is skipped: the first one
// runs right after user-pipeline processors, or right before them when its config sets
// `opamp.position: before`, and the other processors keep their places.
func buildCollectorPipelineProcessorsList(
currentCollectorProcessors []string,
signozPipelineProcessorNames []string,
processorConfigs map[string]interface{},
) ([]string, error) {
lockLogsPipelineSpec.Lock()
defer lockLogsPipelineSpec.Unlock()
@@ -103,6 +112,23 @@ func buildCollectorPipelineProcessorsList(
userPipelineSet[p] = struct{}{}
}
if normalizerIdx := slices.IndexFunc(currentCollectorProcessors, isNormalizerProcessor); normalizerIdx >= 0 {
normalizerFirst := opampPosition(processorConfigs, currentCollectorProcessors[normalizerIdx]) == normalizerBeforeUserPipelines
result := make([]string, 0, len(currentCollectorProcessors)+len(signozPipelineProcessorNames))
for idx, p := range currentCollectorProcessors {
if idx == normalizerIdx && !normalizerFirst {
result = append(result, signozPipelineProcessorNames...)
}
if _, inUserPipelines := userPipelineSet[p]; !inUserPipelines && !hasSignozPipelineProcessorPrefix(p) {
result = append(result, p)
}
if idx == normalizerIdx && normalizerFirst {
result = append(result, signozPipelineProcessorNames...)
}
}
return result, nil
}
var memoryLimiters []string
var customProcessors []string
batchIdx := -1
@@ -135,13 +161,51 @@ func buildCollectorPipelineProcessorsList(
return result, nil
}
func GenerateCollectorConfigWithPipelines(config []byte, pipelines []pipelinetypes.GettablePipeline) ([]byte, error) {
func isNormalizerProcessor(name string) bool {
return name == normalizerProcessor || strings.HasPrefix(name, normalizerProcessorPrefix)
}
// collectorNormalizesFirst reports whether the logs pipeline of the collector config runs
// signozlogsnormalizer ahead of user pipelines, judged by the first one as in buildCollectorPipelineProcessorsList.
// Malformed configs report false; generateCollectorConfigWithPipelines surfaces their errors.
func collectorNormalizesFirst(collectorConf map[string]interface{}) bool {
p, err := getOtelPipelineFromConfig(collectorConf)
if err != nil || p.Pipelines.Logs == nil {
return false
}
normalizerIdx := slices.IndexFunc(p.Pipelines.Logs.Processors, isNormalizerProcessor)
if normalizerIdx < 0 {
return false
}
processorConfigs, _ := collectorConf["processors"].(map[string]interface{})
return opampPosition(processorConfigs, p.Pipelines.Logs.Processors[normalizerIdx]) == normalizerBeforeUserPipelines
}
func opampPosition(processorConfigs map[string]interface{}, name string) string {
config, _ := processorConfigs[name].(map[string]interface{})
opamp, _ := config["opamp"].(map[string]interface{})
position, _ := opamp["position"].(string)
return position
}
func unmarshalCollectorConfig(config []byte) (map[string]interface{}, error) {
var collectorConf map[string]interface{}
err := yaml.Unmarshal([]byte(config), &collectorConf)
if err != nil {
if err := yaml.Unmarshal(config, &collectorConf); err != nil {
return nil, errors.WrapInvalidInputf(err, CodeCollectorConfigUnmarshalFailed, "could not unmarshal collector config")
}
return collectorConf, nil
}
func GenerateCollectorConfigWithPipelines(config []byte, pipelines []pipelinetypes.GettablePipeline) ([]byte, error) {
collectorConf, err := unmarshalCollectorConfig(config)
if err != nil {
return nil, err
}
return generateCollectorConfigWithPipelines(collectorConf, pipelines)
}
// generateCollectorConfigWithPipelines modifies collectorConf in place.
func generateCollectorConfigWithPipelines(collectorConf map[string]any, pipelines []pipelinetypes.GettablePipeline) ([]byte, error) {
signozPipelineProcessors, signozPipelineProcNames, err := PreparePipelineProcessor(pipelines)
if err != nil {
return nil, err
@@ -180,11 +244,11 @@ func GenerateCollectorConfigWithPipelines(config []byte, pipelines []pipelinetyp
return nil, errors.NewInternalf(CodeCollectorConfigLogsPipelineNotFound, "logs pipeline doesn't exist")
}
updatedProcessorList, _ := buildCollectorPipelineProcessorsList(p.Pipelines.Logs.Processors, signozPipelineProcNames)
updatedProcessorList, _ := buildCollectorPipelineProcessorsList(p.Pipelines.Logs.Processors, signozPipelineProcNames, collectorConf["processors"].(map[string]any))
p.Pipelines.Logs.Processors = updatedProcessorList
// add the new processor to the data ( no checks required as the keys will exists)
collectorConf["service"].(map[string]interface{})["pipelines"].(map[string]interface{})["logs"] = p.Pipelines.Logs
collectorConf["service"].(map[string]any)["pipelines"].(map[string]any)["logs"] = p.Pipelines.Logs
updatedConf, err := yaml.Marshal(collectorConf)
if err != nil {

View File

@@ -5,11 +5,14 @@ import (
"fmt"
"testing"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
"github.com/SigNoz/signoz/pkg/valuer"
. "github.com/smartystreets/goconvey/convey"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
@@ -206,13 +209,74 @@ var BuildLogsPipelineTestData = []struct {
func TestBuildLogsPipeline(t *testing.T) {
for _, test := range BuildLogsPipelineTestData {
Convey(test.Name, t, func() {
v, err := buildCollectorPipelineProcessorsList(test.fromCollector, test.userPipelines)
v, err := buildCollectorPipelineProcessorsList(test.fromCollector, test.userPipelines, nil)
So(err, ShouldBeNil)
So(v, ShouldResemble, test.finalOutput)
})
}
}
func TestBuildLogsPipelineWithNormalizer(t *testing.T) {
a := constants.LogsPPLPfx + "a"
b := constants.LogsPPLPfx + "b"
before := map[string]any{"opamp": map[string]any{"position": "before"}}
after := map[string]any{"opamp": map[string]any{"position": "after"}}
testCases := []struct {
name string
fromCollector []string
userPipelines []string
configs map[string]any
expected []string
}{
{
name: "ExistingAndStaleUserProcessorsReplaced",
fromCollector: []string{"memory_limiter", a, constants.LogsPPLPfx + "stale", constants.OldLogsPPLPfx + "old", "signozlogsnormalizer", "custom", "batch"},
userPipelines: []string{a, b},
expected: []string{"memory_limiter", a, b, "signozlogsnormalizer", "custom", "batch"},
},
{
name: "NamedNormalizerAfter_CustomProcessorsKeepPlace",
fromCollector: []string{"memory_limiter", "custom_a", "signozlogsnormalizer/x", a, "custom_b", "batch"},
userPipelines: []string{a},
configs: map[string]any{"signozlogsnormalizer/x": after},
expected: []string{"memory_limiter", "custom_a", a, "signozlogsnormalizer/x", "custom_b", "batch"},
},
{
name: "FirstNormalizerAfter_SecondIgnored",
fromCollector: []string{"memory_limiter", "signozlogsnormalizer/first", "signozlogsnormalizer/second", "batch"},
userPipelines: []string{a},
configs: map[string]any{"signozlogsnormalizer/first": after, "signozlogsnormalizer/second": before},
expected: []string{"memory_limiter", a, "signozlogsnormalizer/first", "signozlogsnormalizer/second", "batch"},
},
{
name: "FirstNormalizerBefore_SecondIgnored",
fromCollector: []string{"memory_limiter", "signozlogsnormalizer/first", "signozlogsnormalizer/second", "batch"},
userPipelines: []string{a},
configs: map[string]any{"signozlogsnormalizer/first": before, "signozlogsnormalizer/second": after},
expected: []string{"memory_limiter", "signozlogsnormalizer/first", a, "signozlogsnormalizer/second", "batch"},
},
{
name: "NormalizerAfterBatch",
fromCollector: []string{"memory_limiter", "batch", "signozlogsnormalizer"},
userPipelines: []string{a},
expected: []string{"memory_limiter", "batch", a, "signozlogsnormalizer"},
},
{
name: "NoUserPipelines_StaleRemoved",
fromCollector: []string{"memory_limiter", a, "signozlogsnormalizer", "batch"},
expected: []string{"memory_limiter", "signozlogsnormalizer", "batch"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
processors, err := buildCollectorPipelineProcessorsList(testCase.fromCollector, testCase.userPipelines, testCase.configs)
require.NoError(t, err)
assert.Equal(t, testCase.expected, processors)
})
}
}
func TestPipelineAliasCollisionsDontResultInDuplicateCollectorProcessors(t *testing.T) {
require := require.New(t)
@@ -437,3 +501,61 @@ func TestPipeCharInAliasDoesntBreakCollectorConfig(t *testing.T) {
}, result[0].Attributes_string,
)
}
func TestRecommendAgentConfigWithNormalizerProcessorAndJSONBody(t *testing.T) {
userPipeline := makeTestAddAttributePipeline()
controller := &LogParsingPipelineController{
fl: flaggertest.WithUseJSONBody(t, true),
GetIntegrationPipelines: func(context.Context, string) ([]pipelinetypes.GettablePipeline, error) {
return []pipelinetypes.GettablePipeline{userPipeline}, nil
},
}
normalizeOperator := CollectorConfProcessorName(getNormalizePipeline())
user := CollectorConfProcessorName(userPipeline)
testCases := []struct {
name string
processors string
logs string
expected []any
}{
{
name: "NoNormalizer_OperatorPrepended",
logs: "[memory_limiter, batch]",
expected: []any{"memory_limiter", normalizeOperator, user, "batch"},
},
{
name: "NormalizerDefault_OperatorKept",
logs: "[memory_limiter, signozlogsnormalizer, batch]",
expected: []any{"memory_limiter", normalizeOperator, user, "signozlogsnormalizer", "batch"},
},
{
name: "NormalizerBefore_OperatorDropped",
processors: "signozlogsnormalizer:\n opamp:\n position: before\n",
logs: "[memory_limiter, signozlogsnormalizer, batch]",
expected: []any{"memory_limiter", "signozlogsnormalizer", user, "batch"},
},
{
name: "NormalizerAfter_OperatorKept",
processors: "signozlogsnormalizer:\n opamp:\n position: after\n",
logs: "[memory_limiter, signozlogsnormalizer, batch]",
expected: []any{"memory_limiter", normalizeOperator, user, "signozlogsnormalizer", "batch"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
conf := fmt.Sprintf(
"processors:\n memory_limiter: {}\n batch: {}\n %s\nservice:\n pipelines:\n logs:\n receivers: [otlp]\n processors: %s\n exporters: [clickhouselogsexporter]\n",
testCase.processors, testCase.logs,
)
recommended, _, err := controller.RecommendAgentConfig(valuer.GenerateUUID(), []byte(conf), nil)
require.NoError(t, err)
var recommendedConf map[string]any
require.NoError(t, yaml.Unmarshal(recommended, &recommendedConf))
logs := recommendedConf["service"].(map[string]any)["pipelines"].(map[string]any)["logs"].(map[string]any)
assert.Equal(t, testCase.expected, logs["processors"])
})
}
}

View File

@@ -220,26 +220,17 @@ func (ic *LogParsingPipelineController) ValidatePipelines(ctx context.Context,
return err
}
// withNormalizePipeline places normalize where the read path dictates. Ahead of user pipelines
// when queries run on body_v2 (use_json_body), so operators see the body the explorer shows.
// After them when dual ingestion alone writes body_v2, so operators keep seeing the raw body
// users still query. Absent when neither flag is on.
func (ic *LogParsingPipelineController) withNormalizePipeline(ctx context.Context, orgID valuer.UUID, pipelines []pipelinetypes.GettablePipeline) []pipelinetypes.GettablePipeline {
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
dualIngestion := ic.fl.BooleanOrEmpty(ctx, flagger.FeatureJSONBodyDualIngestion, evalCtx)
switch {
case ic.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, evalCtx):
return append([]pipelinetypes.GettablePipeline{getNormalizePipeline(dualIngestion)}, pipelines...)
case dualIngestion:
return append(slices.Clone(pipelines), getNormalizePipeline(true))
default:
// withNormalizePipeline places normalize ahead of user pipelines when queries run on body_v2
// (use_json_body), so operators see the body the explorer shows. Absent when the flag is off
// or collectorNormalizesFirst.
func (ic *LogParsingPipelineController) withNormalizePipeline(ctx context.Context, orgID valuer.UUID, pipelines []pipelinetypes.GettablePipeline, collectorNormalizesFirst bool) []pipelinetypes.GettablePipeline {
if collectorNormalizesFirst || !ic.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
return pipelines
}
return append([]pipelinetypes.GettablePipeline{getNormalizePipeline()}, pipelines...)
}
// stashOriginalBody makes normalize carry the pre-normalization body in an internal attribute
// for the exporter to restore into the legacy body column.
func getNormalizePipeline(stashOriginalBody bool) pipelinetypes.GettablePipeline {
func getNormalizePipeline() pipelinetypes.GettablePipeline {
return pipelinetypes.GettablePipeline{
StoreablePipeline: pipelinetypes.StoreablePipeline{
Name: "Default Pipeline - PreProcessing Body",
@@ -258,11 +249,10 @@ func getNormalizePipeline(stashOriginalBody bool) pipelinetypes.GettablePipeline
},
Config: []pipelinetypes.PipelineOperator{
{
ID: uuid.NewString(),
Type: "normalize",
Enabled: true,
If: "body != nil",
JSONBodyDualIngestion: stashOriginalBody,
ID: uuid.NewString(),
Type: "normalize",
Enabled: true,
If: "body != nil",
},
},
}
@@ -371,11 +361,8 @@ func (ic *LogParsingPipelineController) PreviewLogsPipelines(
}
// The collector gets the same pipeline prepended over opamp; see RecommendAgentConfig.
// Under dual ingestion alone it runs after user operators and only feeds body_v2, which
// the explorer does not show yet, so the preview leaves it out. The original-body stash
// is left off: the preview has no exporter to restore and strip it.
if ic.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
pipelines = append([]pipelinetypes.GettablePipeline{getNormalizePipeline(false)}, pipelines...)
pipelines = append([]pipelinetypes.GettablePipeline{getNormalizePipeline()}, pipelines...)
}
result, collectorLogs, err := SimulatePipelinesProcessing(ctx, pipelines, request.Logs)
@@ -396,14 +383,14 @@ func (pc *LogParsingPipelineController) AgentFeatureType() agentConf.AgentFeatur
// Implements agentConf.AgentFeature interface.
// RecommendAgentConfig generates the collector config to be sent to agents.
// The normalize pipeline (when use_json_body or json_body_dual_ingestion is on) is placed
// The normalize pipeline (when use_json_body is on) is placed
// here, after rawPipelineData is serialized. So it is only present in the config sent to
// the collector and never persisted to the database as part of the user's pipeline list.
//
// NOTE: The configId sent to agents is derived from the pipeline version number
// (e.g. "LogPipelines:5"), not the YAML content. If server-side logic changes
// the generated YAML without bumping the version (e.g. toggling the use_json_body or
// json_body_dual_ingestion flags or updating operator IfExpressions), agents that already
// the generated YAML without bumping the version (e.g. toggling the use_json_body
// flag or updating operator IfExpressions), agents that already
// applied that version will not re-apply the new config. In such cases, users must save a
// new pipeline version via the API to force agents to pick up the change.
func (pc *LogParsingPipelineController) RecommendAgentConfig(
@@ -431,10 +418,15 @@ func (pc *LogParsingPipelineController) RecommendAgentConfig(
return nil, "", err
}
// normalize is only for sending to the collector, never persisted
enrichedPipelines = pc.withNormalizePipeline(ctx, orgId, enrichedPipelines)
collectorConf, err := unmarshalCollectorConfig(currentConfYaml)
if err != nil {
return nil, "", err
}
updatedConf, err := GenerateCollectorConfigWithPipelines(currentConfYaml, enrichedPipelines)
// normalize is only for sending to the collector, never persisted
enrichedPipelines = pc.withNormalizePipeline(ctx, orgId, enrichedPipelines, collectorNormalizesFirst(collectorConf))
updatedConf, err := generateCollectorConfigWithPipelines(collectorConf, enrichedPipelines)
if err != nil {
return nil, "", err
}

View File

@@ -261,6 +261,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddUserTuplesFactory(sqlstore),
sqlmigration.NewAddRuleViewFactory(sqlstore, sqlschema),
sqlmigration.NewAddFieldTuplesFactory(sqlstore),
sqlmigration.NewKeepRelevantLLMPricingRulesFactory(),
)
}

View File

@@ -0,0 +1,116 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"regexp"
"strings"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/factory"
)
// Mirrors the zeus LLMPriceFilter and its default model authors.
// https://github.com/SigNoz/zeus/pull/591/changes
var (
llmPricingRuleProviders = map[string]struct{}{
"openai": {}, "anthropic": {}, "google": {}, "mistralai": {}, "deepseek": {},
"qwen": {}, "x-ai": {}, "meta-llama": {}, "cohere": {}, "amazon": {},
}
qwenHostedModelPattern = regexp.MustCompile(`max|plus|flash|turbo`)
)
type llmPricingRuleSyncedRow struct {
bun.BaseModel `bun:"table:llm_pricing_rule"`
ID string `bun:"id,pk"`
Provider string `bun:"provider"`
Model string `bun:"model"`
Pricing string `bun:"pricing"`
}
type llmPricingRulePrices struct {
Input float64 `json:"input"`
Output float64 `json:"output"`
}
type keepRelevantLLMPricingRules struct {
settings factory.ProviderSettings
}
func NewKeepRelevantLLMPricingRulesFactory() factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("keep_relevant_llm_pricing_rules"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &keepRelevantLLMPricingRules{settings: ps}, nil
})
}
func (migration *keepRelevantLLMPricingRules) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *keepRelevantLLMPricingRules) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var rows []*llmPricingRuleSyncedRow
if err := tx.NewSelect().
Model(&rows).
Where("source_id IS NOT NULL").
Where("NOT is_override").
Scan(ctx); err != nil {
return err
}
ids := make([]string, 0)
for _, row := range rows {
var prices llmPricingRulePrices
if err := json.Unmarshal([]byte(row.Pricing), &prices); err != nil {
migration.settings.Logger.WarnContext(ctx, "llm pricing rule has unparseable pricing, leaving it untouched", slog.String("rule_id", row.ID), slog.String("raw_pricing", row.Pricing))
continue
}
if !llmPricingRuleRelevant(row.Provider, row.Model, prices) {
ids = append(ids, row.ID)
}
}
if len(ids) > 0 {
if _, err := tx.NewDelete().
Model((*llmPricingRuleSyncedRow)(nil)).
Where("id IN (?)", bun.In(ids)).
Exec(ctx); err != nil {
return err
}
}
migration.settings.Logger.InfoContext(ctx, "deleted irrelevant llm pricing rules", slog.Int("total", len(rows)), slog.Int("deleted", len(ids)))
return tx.Commit()
}
func (migration *keepRelevantLLMPricingRules) Down(context.Context, *bun.DB) error {
return nil
}
// The provider allowlist also rejects "~" alias ids, whose provider segment
// starts with "~", and ids without a "/" that were stored as "unknown".
func llmPricingRuleRelevant(provider, model string, prices llmPricingRulePrices) bool {
if _, ok := llmPricingRuleProviders[provider]; !ok {
return false
}
if strings.Contains(model, ":") {
return false
}
if prices.Input <= 0 || prices.Output <= 0 {
return false
}
if provider == "qwen" && !qwenHostedModelPattern.MatchString(model) {
return false
}
return true
}

View File

@@ -918,6 +918,10 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
sb.OrderBy("__result_0 DESC")
}
if b.operator.Limit > 0 {
sb.Limit(b.operator.Limit)
}
combinedArgs := allAggChArgs
// Add HAVING clause if specified

View File

@@ -343,8 +343,8 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND toFloat64(response_status_code) < ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)) AS `service.name`, avg(toFloat64(duration_nano)) AS __result_0 FROM A_AND_B GROUP BY `service.name` ORDER BY __result_0 desc SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), float64(400)},
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND toFloat64(response_status_code) < ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)) AS `service.name`, avg(toFloat64(duration_nano)) AS __result_0 FROM A_AND_B GROUP BY `service.name` ORDER BY __result_0 desc LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), float64(400), 10},
},
expectedErr: nil,
},

View File

@@ -35,6 +35,9 @@ const (
// agent span. A trace belongs to the AI explorer when any span carries one.
var GenAISpanGateKeys = []string{GenAIRequestModel, GenAIToolName, GenAIAgentName}
// GenAIThreadKeys mark a span as a thread row: a model call with its messages, or a tool execution.
var GenAIThreadKeys = []string{GenAIInputMessages, GenAIOutputMessages, GenAIToolName}
// GenAISpanFilterExpression renders the gate as a query-builder filter
// expression: each gate key ORed on EXISTS.
func GenAISpanFilterExpression() string {

View File

@@ -0,0 +1,58 @@
// The message types mirror the OpenTelemetry GenAI message schemas, one type per schema definition:
// open-telemetry/semantic-conventions-genai@06ec68e, model/gen-ai/gen-ai-{input,output}-messages.json.
// Keys the schema does not name are dropped; the raw attribute on the span still has them.
package aiobservabilitytypes
import "github.com/SigNoz/signoz/pkg/valuer"
var (
RoleSystem = Role{valuer.NewString("system")}
RoleUser = Role{valuer.NewString("user")}
RoleAssistant = Role{valuer.NewString("assistant")}
RoleTool = Role{valuer.NewString("tool")}
)
var (
FinishReasonStop = FinishReason{valuer.NewString("stop")}
FinishReasonLength = FinishReason{valuer.NewString("length")}
FinishReasonContentFilter = FinishReason{valuer.NewString("content_filter")}
FinishReasonToolCall = FinishReason{valuer.NewString("tool_call")}
FinishReasonCompaction = FinishReason{valuer.NewString("compaction")}
FinishReasonError = FinishReason{valuer.NewString("error")}
)
var (
ModalityImage = Modality{valuer.NewString("image")}
ModalityVideo = Modality{valuer.NewString("video")}
ModalityAudio = Modality{valuer.NewString("audio")}
ModalityDocument = Modality{valuer.NewString("document")}
)
// Role, FinishReason and Modality are open enums: the variables are the values the schema
// names, any other string is kept as sent, so none of them lists an Enum for the spec.
type Role struct{ valuer.String }
type FinishReason struct{ valuer.String }
type Modality struct{ valuer.String }
type InputMessages []ChatMessage
// OutputMessages holds one message per choice.
type OutputMessages []OutputMessage
type ChatMessage struct {
Role Role `json:"role" required:"true"`
Parts Parts `json:"parts" required:"true" nullable:"false"`
Name *string `json:"name,omitempty"`
}
// OutputMessage is one choice. The schema deprecates FinishReason in favour of
// gen_ai.response.finish_reasons; it stays so each choice carries its own.
type OutputMessage struct {
Role Role `json:"role" required:"true"`
Parts Parts `json:"parts" required:"true" nullable:"false"`
Name *string `json:"name,omitempty"`
FinishReason *FinishReason `json:"finish_reason,omitempty"`
}

Some files were not shown because too many files have changed in this diff Show More