Compare commits

..

4 Commits

Author SHA1 Message Date
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
64 changed files with 2480 additions and 1040 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:
@@ -10303,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:
@@ -10345,6 +10569,10 @@ components:
- attributes
- events
- references
- formatted_input
- formatted_output
- formatter
- formatter_warnings
type: object
SpantypesTraceAISummary:
properties:
@@ -16212,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

@@ -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
@@ -11531,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

@@ -10,11 +10,6 @@ import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { useQueryBuilderV2Context } from '../../QueryBuilderV2Context';
import {
QueryBuilderField,
type QueryBuilderFieldsConfig,
} from '../../queryBuilderFields.types';
import { resolveQueryBuilderField } from '../../queryBuilderFields.utils';
import './MetricsAggregateSection.styles.scss';
@@ -24,18 +19,14 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
version,
panelType,
signalSource = '',
fieldsConfig,
}: {
query: IBuilderQuery;
index: number;
version: string;
panelType: PANEL_TYPES | null;
signalSource: string;
fieldsConfig?: QueryBuilderFieldsConfig;
}): JSX.Element {
const { setAggregationOptions } = useQueryBuilderV2Context();
const placeholderOf = (key: QueryBuilderField): string | undefined =>
resolveQueryBuilderField(key, fieldsConfig).placeholder;
const {
operators,
spaceAggregationOptions,
@@ -163,7 +154,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
<InputWithLabel
onChange={handleChangeAggregateEvery}
label="Seconds"
placeholder={placeholderOf(QueryBuilderField.StepInterval) ?? 'Auto'}
placeholder="Auto"
labelAfter
initialValue={query?.stepInterval ?? null}
/>
@@ -216,7 +207,6 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
query={query}
onChange={handleChangeGroupByKeys}
signalSource={signalSource}
placeholder={placeholderOf(QueryBuilderField.GroupBy)}
/>
</div>
</div>
@@ -254,7 +244,6 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
query={query}
onChange={handleChangeGroupByKeys}
signalSource={signalSource}
placeholder={placeholderOf(QueryBuilderField.GroupBy)}
/>
</div>
</div>
@@ -288,7 +277,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
<InputWithLabel
onChange={handleChangeAggregateEvery}
label="Seconds"
placeholder={placeholderOf(QueryBuilderField.StepInterval) ?? 'Auto'}
placeholder="Auto"
labelAfter
initialValue={query?.stepInterval ?? null}
className="histogram-every-input"

View File

@@ -88,12 +88,10 @@ function HavingFilter({
onClose,
onChange,
queryData,
placeholder = 'Type Having query like count() > 10 ...',
}: {
onClose: () => void;
onChange: (value: string) => void;
queryData: IBuilderQuery;
placeholder?: string;
}): JSX.Element {
const isDarkMode = useIsDarkMode();
const { getAggregationOptions } = useQueryBuilderV2Context();
@@ -347,7 +345,7 @@ function HavingFilter({
},
]),
]}
placeholder={placeholder}
placeholder="Type Having query like count() > 10 ..."
basicSetup={{
lineNumbers: false,
autocompletion: true,

View File

@@ -343,29 +343,8 @@
font-size: 12px !important;
}
// Not select search inputs: they would grow the select past 36px.
.input-with-label input {
input {
min-height: 36px;
}
.input-with-label,
.periscope-input-with-label {
min-height: 36px;
.label {
min-width: 80px;
}
// `ghost` strips the border.
.close-btn.periscope-btn {
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
}
}
.periscope-input-with-label .input .ant-select-selection-placeholder {
color: var(--query-builder-v2-placeholder-color, var(--l3-foreground));
font-size: 12px;
}
}
}

View File

@@ -1,17 +1,22 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button } from 'antd';
import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
import { OrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter';
import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { get, isEmpty } from 'lodash-es';
import { ChevronUp } from '@signozhq/icons';
import {
BarChart,
ChevronUp,
ExternalLink,
Grid3X3,
ScrollText,
} from '@signozhq/icons';
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
@@ -29,20 +34,162 @@ import {
import BucketOptions from './BucketOptions/BucketOptions';
import HavingFilter from './HavingFilter/HavingFilter';
import {
ADD_ON_DEFINITIONS,
BASE_ADD_ONS,
BUCKET_OPTIONS,
HISTOGRAM_ATTRIBUTE_TYPES,
type InputAddOnField,
REDUCE_TO,
} from './constants';
import TooltipContent from './TooltipContent';
import type { AddOn } from './types';
import { buildDefaultLegendFromGroupBy, hasValue } from './utils';
import { buildDefaultLegendFromGroupBy } from './utils';
import './QueryAddOns.styles.scss';
interface AddOn {
icon: React.ReactNode;
label: string;
key: QueryBuilderField;
description?: string;
docLink?: string;
}
/** Fields the add-on bar does not own: each has its own control elsewhere in the query. */
type NonAddOnField =
| QueryBuilderField.Aggregation
| QueryBuilderField.StepInterval
| QueryBuilderField.Functions
| QueryBuilderField.Formula
| QueryBuilderField.AdditionalQueries;
// Omit rather than Partial, so a field added to the enum has to be placed on one side.
const ADD_ONS_KEYS_TO_QUERY_PATH: Omit<
Record<QueryBuilderField, string>,
NonAddOnField
> = {
[QueryBuilderField.GroupBy]: 'groupBy',
[QueryBuilderField.Having]: 'having.expression',
[QueryBuilderField.OrderBy]: 'orderBy',
[QueryBuilderField.Limit]: 'limit',
[QueryBuilderField.Legend]: 'legend',
[QueryBuilderField.ReduceTo]: 'reduceTo',
[QueryBuilderField.BucketOptions]: 'bucketOptions',
};
const ADD_ONS: AddOn[] = [
{
icon: <BarChart size={14} />,
label: 'Group By',
key: QueryBuilderField.GroupBy,
description:
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
docLink: 'https://signoz.io/docs/querying/aggregation-grouping/#grouping',
},
{
icon: <ScrollText size={14} />,
label: 'Having',
key: QueryBuilderField.Having,
description:
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
docLink:
'https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having',
},
{
icon: <ScrollText size={14} />,
label: 'Order By',
key: QueryBuilderField.OrderBy,
description:
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
docLink:
'https://signoz.io/docs/querying/result-manipulation/#sorting--limiting',
},
{
icon: <ScrollText size={14} />,
label: 'Limit',
key: QueryBuilderField.Limit,
description:
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
docLink:
'https://signoz.io/docs/querying/result-manipulation/#how-limit-works-for-time-series',
},
{
icon: <ScrollText size={14} />,
label: 'Legend format',
key: QueryBuilderField.Legend,
description:
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
docLink:
'https://signoz.io/docs/querying/aggregation-grouping/#legend-formatting',
},
];
const REDUCE_TO: AddOn = {
icon: <ScrollText size={14} />,
label: 'Reduce to',
key: QueryBuilderField.ReduceTo,
description:
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
};
// Offered only by a heatmap over metrics: the bucket axis is what a heatmap plots
// against, and no other panel type sends one. A histogram brings its own buckets.
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
ATTRIBUTE_TYPES.HISTOGRAM,
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
]);
const BUCKET_OPTIONS: AddOn = {
icon: <Grid3X3 size={14} />,
label: 'Bucket by',
key: QueryBuilderField.BucketOptions,
description:
'Choose how the bucket axis is spaced — logarithmically, so every band is the same height and the tail stays readable, or linearly up to a max value. Left to Auto, the query picks the finest log axis it can return.',
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/',
};
const hasValue = (value: unknown): boolean =>
value != null && value !== '' && !(Array.isArray(value) && value.length === 0);
// Custom tooltip content component
function TooltipContent({
label,
description,
docLink,
}: {
label: string;
description?: string;
docLink?: string;
}): JSX.Element {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
maxWidth: '300px',
}}
>
<strong style={{ fontSize: '14px' }}>{label}</strong>
{description && (
<span style={{ fontSize: '12px', lineHeight: '1.5' }}>{description}</span>
)}
{docLink && (
<a
href={docLink}
target="_blank"
rel="noopener noreferrer"
onClick={(e): void => e.stopPropagation()}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
color: '#4096ff',
fontSize: '12px',
marginTop: '4px',
}}
>
Learn more
<ExternalLink size={12} />
</a>
)}
</div>
);
}
function QueryAddOns({
query,
version,
@@ -80,16 +227,12 @@ function QueryAddOns({
let addOns: AddOn[];
if (panelType === PANEL_TYPES.VALUE) {
addOns = BASE_ADD_ONS.filter(
(addOn) => addOn.key === QueryBuilderField.Legend,
);
addOns = ADD_ONS.filter((addOn) => addOn.key === QueryBuilderField.Legend);
} else if (query.dataSource === DataSource.METRICS) {
// Group by for metrics is offered by MetricsAggregateSection instead.
addOns = BASE_ADD_ONS.filter(
(addOn) => addOn.key !== QueryBuilderField.GroupBy,
);
addOns = ADD_ONS.filter((addOn) => addOn.key !== QueryBuilderField.GroupBy);
} else {
addOns = [...BASE_ADD_ONS];
addOns = [...ADD_ONS];
}
if (showReduceTo) {
@@ -145,12 +288,6 @@ function QueryAddOns({
[resolvedFields],
);
const placeholderOf = useCallback(
(key: InputAddOnField): string =>
resolvedFields.get(key)?.placeholder ?? ADD_ON_DEFINITIONS[key].placeholder,
[resolvedFields],
);
const isDisabled = useCallback(
(key: QueryBuilderField): boolean =>
Boolean(resolvedFields.get(key)?.disabled),
@@ -169,6 +306,12 @@ function QueryAddOns({
if (!initializedRef.current || hasAvailabilityItemsChanged) {
initializedRef.current = true;
const activeAddOnKeys = new Set(
Object.entries(ADD_ONS_KEYS_TO_QUERY_PATH)
.filter(([, path]) => hasValue(get(query, path)))
.map(([key]) => key as QueryBuilderField),
);
// Initial seeding from query values on mount. A disabled field never opens.
setSelectedViews(
offeredAddOns.filter((addOn) => {
@@ -176,7 +319,7 @@ function QueryAddOns({
return (
resolved?.pinned ||
(hasValue(get(query, addOn.queryPath)) && !resolved?.disabled)
(activeAddOnKeys.has(addOn.key) && !resolved?.disabled)
);
}),
);
@@ -305,7 +448,7 @@ function QueryAddOns({
{selectedViews.find((view) => view.key === QueryBuilderField.GroupBy) && (
<div className="add-on-content" data-testid="group-by-content">
<div className="periscope-input-with-label">
<TooltipSimple
<Tooltip
title={
<TooltipContent
label="Group By"
@@ -313,13 +456,13 @@ function QueryAddOns({
docLink="https://signoz.io/docs/querying/aggregation-grouping/#grouping"
/>
}
arrow
delayDuration={500}
placement="top"
mouseEnterDelay={0.5}
>
<div className="label" style={{ cursor: 'help' }}>
Group By
</div>
</TooltipSimple>
</Tooltip>
<div className="input">
<GroupByFilter
disabled={
@@ -328,7 +471,6 @@ function QueryAddOns({
}
query={query}
onChange={handleChangeGroupByKeys}
placeholder={placeholderOf(QueryBuilderField.GroupBy)}
/>
</div>
{!isPinned(QueryBuilderField.GroupBy) && (
@@ -344,7 +486,7 @@ function QueryAddOns({
{selectedViews.find((view) => view.key === QueryBuilderField.Having) && (
<div className="add-on-content" data-testid="having-content">
<div className="periscope-input-with-label">
<TooltipSimple
<Tooltip
title={
<TooltipContent
label="Having"
@@ -352,19 +494,18 @@ function QueryAddOns({
docLink="https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having"
/>
}
arrow
delayDuration={500}
placement="top"
mouseEnterDelay={0.5}
>
<div className="label" style={{ cursor: 'help' }}>
Having
</div>
</TooltipSimple>
</Tooltip>
<div className="input">
<HavingFilter
onClose={(): void => handleRemoveView(QueryBuilderField.Having)}
onChange={handleChangeHaving}
queryData={query}
placeholder={placeholderOf(QueryBuilderField.Having)}
/>
</div>
</div>
@@ -377,7 +518,7 @@ function QueryAddOns({
type="number"
onChange={handleChangeLimit}
initialValue={query?.limit ?? undefined}
placeholder={placeholderOf(QueryBuilderField.Limit)}
placeholder="Enter limit"
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}
closeIcon={<ChevronUp size={16} />}
/>
@@ -386,7 +527,7 @@ function QueryAddOns({
{selectedViews.find((view) => view.key === QueryBuilderField.OrderBy) && (
<div className="add-on-content" data-testid="order-by-content">
<div className="periscope-input-with-label">
<TooltipSimple
<Tooltip
title={
<TooltipContent
label="Order By"
@@ -394,13 +535,13 @@ function QueryAddOns({
docLink="https://signoz.io/docs/querying/result-manipulation/#sorting--limiting"
/>
}
arrow
delayDuration={500}
placement="top"
mouseEnterDelay={0.5}
>
<div className="label" style={{ cursor: 'help' }}>
Order By
</div>
</TooltipSimple>
</Tooltip>
<div className="input">
<OrderByFilter
entityVersion={version}
@@ -408,7 +549,6 @@ function QueryAddOns({
onChange={handleChangeOrderByKeys}
isRawQuery={isRawQuery}
isNewQueryV2
placeholder={placeholderOf(QueryBuilderField.OrderBy)}
/>
</div>
{!isPinned(QueryBuilderField.OrderBy) && (
@@ -426,7 +566,7 @@ function QueryAddOns({
showReduceTo && (
<div className="add-on-content" data-testid="reduce-to-content">
<div className="periscope-input-with-label">
<TooltipSimple
<Tooltip
title={
<TooltipContent
label="Reduce to"
@@ -434,13 +574,13 @@ function QueryAddOns({
docLink="https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation"
/>
}
arrow
delayDuration={500}
placement="top"
mouseEnterDelay={0.5}
>
<div className="label" style={{ cursor: 'help' }}>
Reduce to
</div>
</TooltipSimple>
</Tooltip>
<div className="input">
<ReduceToFilter query={query} onChange={handleChangeReduceToV5} />
</div>
@@ -460,7 +600,7 @@ function QueryAddOns({
<div className="add-on-content" data-testid="legend-format-content">
<InputWithLabel
label="Legend format"
placeholder={placeholderOf(QueryBuilderField.Legend)}
placeholder="Write legend format"
onChange={handleChangeQueryLegend}
initialValue={isEmpty(query?.legend) ? undefined : query?.legend}
onClose={(): void => handleRemoveView(QueryBuilderField.Legend)}
@@ -507,7 +647,7 @@ function QueryAddOns({
return {
value: addOn.key,
label: (
<TooltipSimple
<Tooltip
title={
<TooltipContent
label={addOn.label}
@@ -515,8 +655,8 @@ function QueryAddOns({
docLink={resolved?.disabled ? undefined : addOn.docLink}
/>
}
arrow
delayDuration={500}
placement="top"
mouseEnterDelay={0.5}
>
<span
className={cx('add-on-tab-title', {
@@ -528,7 +668,7 @@ function QueryAddOns({
{addOn.icon}
{addOn.label}
</span>
</TooltipSimple>
</Tooltip>
),
};
})}

View File

@@ -1,24 +0,0 @@
.content {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
max-width: 300px;
}
.label {
font-size: var(--font-size-sm);
}
.description {
font-size: var(--font-size-xs);
line-height: var(--line-height-normal);
}
.docLink {
display: flex;
align-items: center;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
color: var(--accent-primary);
font-size: var(--font-size-xs);
}

View File

@@ -1,33 +0,0 @@
import { ExternalLink } from '@signozhq/icons';
import styles from './TooltipContent.module.scss';
// Custom tooltip content component
export default function TooltipContent({
label,
description,
docLink,
}: {
label: string;
description?: string;
docLink?: string;
}): JSX.Element {
return (
<div className={styles.content}>
<strong className={styles.label}>{label}</strong>
{description && <span className={styles.description}>{description}</span>}
{docLink && (
<a
href={docLink}
target="_blank"
rel="noopener noreferrer"
onClick={(e): void => e.stopPropagation()}
className={styles.docLink}
>
Learn more
<ExternalLink size={12} />
</a>
)}
</div>
);
}

View File

@@ -1,115 +0,0 @@
import { BarChart, Grid3X3, ScrollText } from '@signozhq/icons';
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
import { QueryBuilderField } from '../../queryBuilderFields.types';
import type { AddOn, AddOnDefinition, AddOnField } from './types';
// A Record, so a field added to the enum has to be placed here or in `NonAddOnField`.
export const ADD_ON_DEFINITIONS = {
[QueryBuilderField.GroupBy]: {
input: true,
icon: <BarChart size={14} />,
label: 'Group By',
queryPath: 'groupBy',
placeholder: 'Everything (no breakdown)',
description:
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
docLink: 'https://signoz.io/docs/querying/aggregation-grouping/#grouping',
},
[QueryBuilderField.Having]: {
input: true,
icon: <ScrollText size={14} />,
label: 'Having',
queryPath: 'having.expression',
placeholder: 'Type Having query like count() > 10 ...',
description:
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
docLink:
'https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having',
},
[QueryBuilderField.OrderBy]: {
input: true,
icon: <ScrollText size={14} />,
label: 'Order By',
queryPath: 'orderBy',
placeholder: 'Select fields to order by',
description:
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
docLink:
'https://signoz.io/docs/querying/result-manipulation/#sorting--limiting',
},
[QueryBuilderField.Limit]: {
input: true,
icon: <ScrollText size={14} />,
label: 'Limit',
queryPath: 'limit',
placeholder: 'Enter limit',
description:
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
docLink:
'https://signoz.io/docs/querying/result-manipulation/#how-limit-works-for-time-series',
},
[QueryBuilderField.Legend]: {
input: true,
icon: <ScrollText size={14} />,
label: 'Legend format',
queryPath: 'legend',
placeholder: 'Write legend format',
description:
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
docLink:
'https://signoz.io/docs/querying/aggregation-grouping/#legend-formatting',
},
[QueryBuilderField.ReduceTo]: {
input: false,
icon: <ScrollText size={14} />,
label: 'Reduce to',
queryPath: 'reduceTo',
description:
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
},
[QueryBuilderField.BucketOptions]: {
input: false,
icon: <Grid3X3 size={14} />,
label: 'Bucket by',
queryPath: 'bucketOptions',
description:
'Choose how the bucket axis is spaced — logarithmically, so every band is the same height and the tail stays readable, or linearly up to a max value. Left to Auto, the query picks the finest log axis it can return.',
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/',
},
} as const satisfies Record<AddOnField, AddOnDefinition>;
export type InputAddOnField = {
[K in AddOnField]: (typeof ADD_ON_DEFINITIONS)[K] extends { input: true }
? K
: never;
}[AddOnField];
const toAddOn = (key: AddOnField): AddOn => ({
key,
...ADD_ON_DEFINITIONS[key],
});
export const BASE_ADD_ONS = (
[
QueryBuilderField.GroupBy,
QueryBuilderField.Having,
QueryBuilderField.OrderBy,
QueryBuilderField.Limit,
QueryBuilderField.Legend,
] as const satisfies readonly AddOnField[]
).map(toAddOn);
export const REDUCE_TO = toAddOn(QueryBuilderField.ReduceTo);
// Offered only by a heatmap over metrics: the bucket axis is what a heatmap plots
// against, and no other panel type sends one. A histogram brings its own buckets.
export const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
ATTRIBUTE_TYPES.HISTOGRAM,
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
]);
export const BUCKET_OPTIONS = toAddOn(QueryBuilderField.BucketOptions);

View File

@@ -1,27 +0,0 @@
import type { QueryBuilderField } from '../../queryBuilderFields.types';
/** Fields the add-on bar does not own: each has its own control elsewhere in the query. */
export type NonAddOnField =
| QueryBuilderField.Aggregation
| QueryBuilderField.StepInterval
| QueryBuilderField.Functions
| QueryBuilderField.Formula
| QueryBuilderField.AdditionalQueries;
export type AddOnField = Exclude<QueryBuilderField, NonAddOnField>;
export interface AddOnSpec {
icon: React.ReactNode;
label: string;
/** Where the add-on's value lives on the query; a value there opens it on mount. */
queryPath: string;
description?: string;
docLink?: string;
}
/** A text field has to name what leaving it empty means. */
export type AddOnDefinition =
| (AddOnSpec & { input: true; placeholder: string })
| (AddOnSpec & { input: false });
export type AddOn = AddOnDefinition & { key: AddOnField };

View File

@@ -14,6 +14,3 @@ export const buildDefaultLegendFromGroupBy = (
return segments.join(', ');
};
export const hasValue = (value: unknown): boolean =>
value != null && value !== '' && !(Array.isArray(value) && value.length === 0);

View File

@@ -12,10 +12,7 @@ import {
QueryBuilderField,
QueryBuilderFieldsConfig,
} from '../../queryBuilderFields.types';
import {
resolveQueryBuilderField,
type ResolvedQueryBuilderField,
} from '../../queryBuilderFields.utils';
import { resolveQueryBuilderField } from '../../queryBuilderFields.utils';
import QueryAggregationSelect from './QueryAggregationSelect';
@@ -36,9 +33,9 @@ function QueryAggregationOptions({
queryData: IBuilderQuery | IBuilderTraceOperator;
fieldsConfig?: QueryBuilderFieldsConfig;
}): JSX.Element {
const stepInterval = useMemo<ResolvedQueryBuilderField>(() => {
const stepInterval = useMemo(() => {
if (panelType === PANEL_TYPES.VALUE) {
return { hidden: true, disabled: false, pinned: false };
return { hidden: true, disabled: false, reason: undefined };
}
const isNonMetricSource =
@@ -48,7 +45,7 @@ function QueryAggregationOptions({
isNonMetricSource &&
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
) {
return { hidden: true, disabled: false, pinned: false };
return { hidden: true, disabled: false, reason: undefined };
}
return resolveQueryBuilderField(QueryBuilderField.StepInterval, fieldsConfig);
@@ -108,7 +105,7 @@ function QueryAggregationOptions({
initialValue={queryData?.stepInterval ? queryData?.stepInterval : null}
className="query-aggregation-interval-input"
label="Seconds"
placeholder={stepInterval.placeholder ?? 'Auto'}
placeholder="Auto"
type="number"
onChange={handleAggregationIntervalChange}
disabled={stepInterval.disabled}

View File

@@ -317,7 +317,6 @@ export const QueryV2 = forwardRef(function QueryV2(
key={`metrics-aggregate-section-${query.queryName}-${query.dataSource}-${signalSource}`}
version="v4"
signalSource={signalSource as 'meter' | ''}
fieldsConfig={fieldsConfig}
/>
)}

View File

@@ -9,7 +9,6 @@ import {
} from 'tests/test-utils';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { QueryBuilderField } from '../queryBuilderFields.types';
import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
// Mocks: only what is required for this component to render and for us to assert handler calls
@@ -35,10 +34,9 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
}));
jest.mock('container/QueryBuilder/filters/GroupByFilter/GroupByFilter', () => ({
GroupByFilter: ({ onChange, placeholder }: any): JSX.Element => (
GroupByFilter: ({ onChange }: any): JSX.Element => (
<button
data-testid="groupby"
data-placeholder={placeholder}
onClick={(): void => onChange(['service.name'])}
>
GroupByFilter
@@ -47,10 +45,9 @@ jest.mock('container/QueryBuilder/filters/GroupByFilter/GroupByFilter', () => ({
}));
jest.mock('container/QueryBuilder/filters/OrderByFilter/OrderByFilter', () => ({
OrderByFilter: ({ onChange, placeholder }: any): JSX.Element => (
OrderByFilter: ({ onChange }: any): JSX.Element => (
<button
data-testid="orderby"
data-placeholder={placeholder}
onClick={(): void => onChange([{ columnName: 'duration', order: 'desc' }])}
>
OrderByFilter
@@ -60,11 +57,10 @@ jest.mock('container/QueryBuilder/filters/OrderByFilter/OrderByFilter', () => ({
jest.mock('../QueryV2/QueryAddOns/HavingFilter/HavingFilter', () => ({
__esModule: true,
default: ({ onChange, onClose, placeholder }: any): JSX.Element => (
default: ({ onChange, onClose }: any): JSX.Element => (
<div>
<button
data-testid="having-change"
data-placeholder={placeholder}
onClick={(): void => onChange('p99 > 500')}
>
HavingFilter
@@ -173,78 +169,6 @@ describe('QueryAddOns', () => {
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
});
it.each([
[QueryBuilderField.Limit, 'query-add-on-limit', 'input-Limit', 'placeholder'],
[
QueryBuilderField.Legend,
'query-add-on-legend_format',
'input-Legend format',
'placeholder',
],
[
QueryBuilderField.GroupBy,
'query-add-on-group_by',
'groupby',
'data-placeholder',
],
[
QueryBuilderField.Having,
'query-add-on-having',
'having-change',
'data-placeholder',
],
[
QueryBuilderField.OrderBy,
'query-add-on-order_by',
'orderby',
'data-placeholder',
],
])(
'shows the configured %s placeholder',
(field, addOnTestId, inputTestId, attribute) => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
fieldsConfig={{ [field]: { state: 'default', placeholder: 'Default X' } }}
isForTraceOperator={false}
/>,
);
fireEvent.click(screen.getByTestId(addOnTestId));
expect(screen.getByTestId(inputTestId)).toHaveAttribute(
attribute,
'Default X',
);
},
);
it('keeps the built-in placeholder without a rule', () => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
fireEvent.click(screen.getByTestId('query-add-on-limit'));
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
'placeholder',
'Enter limit',
);
});
it('auto-opens Order By and Limit when present in query', () => {
const query = baseQuery({
orderBy: [{ columnName: 'duration', order: 'desc' }],

View File

@@ -1,47 +0,0 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { render, screen } from 'tests/test-utils';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderField } from '../queryBuilderFields.types';
import QueryAggregationOptions from '../QueryV2/QueryAggregation/QueryAggregation';
jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
__esModule: true,
default: (): null => null,
}));
const queryData = initialQueriesMap.traces.builder
.queryData[0] as IBuilderQuery;
describe('QueryAggregationOptions step interval placeholder', () => {
it('shows the configured placeholder', () => {
render(
<QueryAggregationOptions
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TIME_SERIES}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
fieldsConfig={{
[QueryBuilderField.StepInterval]: {
state: 'default',
placeholder: 'Default 60',
},
}}
/>,
);
expect(screen.getByPlaceholderText('Default 60')).toBeInTheDocument();
});
it('falls back to Auto', () => {
render(
<QueryAggregationOptions
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TIME_SERIES}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
expect(screen.getByPlaceholderText('Auto')).toBeInTheDocument();
});
});

View File

@@ -57,19 +57,6 @@ describe('resolveQueryBuilderField', () => {
expect(resolved.hidden).toBe(false);
});
it('keeps a default field as is, carrying its placeholder', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
[QueryBuilderField.Limit]: { state: 'default', placeholder: 'Default 10' },
});
expect(resolved).toStrictEqual({
hidden: false,
disabled: false,
pinned: false,
placeholder: 'Default 10',
});
});
it('only ever resolves one state at a time', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
[QueryBuilderField.Limit]: { state: 'disabled', reason: 'why' },

View File

@@ -1,149 +0,0 @@
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import {
QueryBuilderField,
type SyncedQueryBuilderField,
} from '../queryBuilderFields.types';
import {
snapshotQueries,
syncQueryBuilderFields,
} from '../syncQueryBuilderFields';
const key = (name: string): BaseAutocompleteData =>
({ key: name }) as BaseAutocompleteData;
function query(
queryName: string,
{
groupBy = [],
limit = null,
dataSource = 'traces',
}: { groupBy?: string[]; limit?: number | null; dataSource?: string } = {},
): IBuilderQuery {
return {
queryName,
groupBy: groupBy.map(key),
limit,
dataSource,
} as IBuilderQuery;
}
const GROUP_BY: SyncedQueryBuilderField[] = [QueryBuilderField.GroupBy];
describe('syncQueryBuilderFields', () => {
it('copies an edited field to the other queries', () => {
const previous = snapshotQueries([query('A'), query('B')]);
expect(
syncQueryBuilderFields(
[query('A', { groupBy: ['service.name'] }), query('B')],
previous,
GROUP_BY,
),
).toStrictEqual([
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
]);
});
it('gives a new query the existing values', () => {
const previous = snapshotQueries([query('A', { groupBy: ['service.name'] })]);
expect(
syncQueryBuilderFields(
[query('A', { groupBy: ['service.name'] }), query('B')],
previous,
GROUP_BY,
),
).toStrictEqual([
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
]);
});
it("gives a query switched to another signal the others' values, not its reset ones", () => {
const previous = snapshotQueries([
query('A', { groupBy: ['service.name'] }),
query('B', { groupBy: ['service.name'], dataSource: 'metrics' }),
]);
expect(
syncQueryBuilderFields(
[query('A', { groupBy: ['service.name'] }), query('B')],
previous,
GROUP_BY,
),
).toStrictEqual([
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
]);
});
it('leaves values that already differ until one is edited', () => {
const queries = [query('A', { groupBy: ['host.name'] }), query('B')];
expect(
syncQueryBuilderFields(queries, snapshotQueries(queries), GROUP_BY),
).toStrictEqual([]);
});
it('gives a new query the first value without touching differing ones', () => {
const saved = [
query('A', { groupBy: ['service.name'] }),
query('B', { groupBy: ['host.name'] }),
];
expect(
syncQueryBuilderFields(
[...saved, query('C')],
snapshotQueries(saved),
GROUP_BY,
),
).toStrictEqual([
{ index: 2, query: query('C', { groupBy: ['service.name'] }) },
]);
});
it('copies an edit to every query even when one was added', () => {
const saved = [
query('A', { groupBy: ['service.name'] }),
query('B', { groupBy: ['host.name'] }),
];
expect(
syncQueryBuilderFields(
[query('A', { groupBy: ['k8s.pod.name'] }), saved[1], query('C')],
snapshotQueries(saved),
GROUP_BY,
),
).toStrictEqual([
{ index: 1, query: query('B', { groupBy: ['k8s.pod.name'] }) },
{ index: 2, query: query('C', { groupBy: ['k8s.pod.name'] }) },
]);
});
it('syncs each field from its own source', () => {
const previous = snapshotQueries([query('A'), query('B')]);
expect(
syncQueryBuilderFields(
[query('A', { groupBy: ['service.name'] }), query('B', { limit: 50 })],
previous,
[QueryBuilderField.GroupBy, QueryBuilderField.Limit],
),
).toStrictEqual([
{ index: 0, query: query('A', { groupBy: ['service.name'], limit: 50 }) },
{ index: 1, query: query('B', { groupBy: ['service.name'], limit: 50 }) },
]);
});
it('leaves fields it is not asked to sync', () => {
const previous = snapshotQueries([query('A'), query('B')]);
expect(
syncQueryBuilderFields(
[query('A', { limit: 50 }), query('B')],
previous,
GROUP_BY,
),
).toStrictEqual([]);
});
});

View File

@@ -1,64 +0,0 @@
import { renderHook } from '@testing-library/react';
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import {
QueryBuilderField,
type SyncedQueryBuilderField,
} from '../queryBuilderFields.types';
import { useSyncQueryBuilderFields } from '../useSyncQueryBuilderFields';
const mockHandleSetQueryData = jest.fn();
let mockQueries: IBuilderQuery[] = [];
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({
currentQuery: { builder: { queryData: mockQueries } },
handleSetQueryData: mockHandleSetQueryData,
}),
}));
const query = (queryName: string, groupBy: string[]): IBuilderQuery =>
({
queryName,
groupBy: groupBy.map((name) => ({ key: name }) as BaseAutocompleteData),
dataSource: 'traces',
}) as IBuilderQuery;
const GROUP_BY: SyncedQueryBuilderField[] = [QueryBuilderField.GroupBy];
function setup(
fields: SyncedQueryBuilderField[] | undefined,
initial: IBuilderQuery[],
): (next: IBuilderQuery[]) => void {
mockQueries = initial;
const { rerender } = renderHook(() => useSyncQueryBuilderFields(fields));
return (next): void => {
mockQueries = next;
rerender();
};
}
describe('useSyncQueryBuilderFields', () => {
beforeEach(() => mockHandleSetQueryData.mockClear());
it('writes the synced value into the other queries', () => {
const update = setup(GROUP_BY, [query('A', []), query('B', [])]);
update([query('A', ['service.name']), query('B', [])]);
expect(mockHandleSetQueryData).toHaveBeenCalledTimes(1);
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
1,
query('B', ['service.name']),
);
});
it('does nothing when the kind syncs no fields', () => {
const update = setup(undefined, [query('A', []), query('B', [])]);
update([query('A', ['service.name']), query('B', [])]);
expect(mockHandleSetQueryData).not.toHaveBeenCalled();
});
});

View File

@@ -22,21 +22,8 @@ export enum QueryBuilderField {
AdditionalQueries = 'additional_queries',
}
/** Per-query builder fields that can be kept equal across a panel's queries. */
export type SyncedQueryBuilderField =
| QueryBuilderField.GroupBy
| QueryBuilderField.OrderBy
| QueryBuilderField.Having
| QueryBuilderField.Limit
| QueryBuilderField.StepInterval
| QueryBuilderField.ReduceTo;
/**
* `reason` is required on `disabled`: an inert control the user can see has to explain itself.
* `default`'s placeholder names the value an empty field falls back to.
*/
/** `reason` is required on `disabled`: an inert control the user can see has to explain itself. */
export type QueryBuilderFieldRule =
| { state: 'default'; placeholder: string }
| { state: 'hidden' }
| { state: 'disabled'; reason: string }
| { state: 'pinned' };

View File

@@ -10,7 +10,6 @@ export interface ResolvedQueryBuilderField {
reason?: string;
/** Rendered open, not dismissable, and kept out of the add-on toggle bar. */
pinned: boolean;
placeholder?: string;
}
const AVAILABLE: ResolvedQueryBuilderField = {
@@ -21,8 +20,6 @@ const AVAILABLE: ResolvedQueryBuilderField = {
function fromRule(rule: QueryBuilderFieldRule): ResolvedQueryBuilderField {
switch (rule.state) {
case 'default':
return { ...AVAILABLE, placeholder: rule.placeholder };
case 'hidden':
return { hidden: true, disabled: false, pinned: false };
case 'disabled':

View File

@@ -1,82 +0,0 @@
import { isEqual } from 'lodash-es';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import {
QueryBuilderField,
type SyncedQueryBuilderField,
} from './queryBuilderFields.types';
const QUERY_PROPERTY = {
[QueryBuilderField.GroupBy]: 'groupBy',
[QueryBuilderField.OrderBy]: 'orderBy',
[QueryBuilderField.Having]: 'having',
[QueryBuilderField.Limit]: 'limit',
[QueryBuilderField.StepInterval]: 'stepInterval',
[QueryBuilderField.ReduceTo]: 'reduceTo',
} as const satisfies Record<SyncedQueryBuilderField, keyof IBuilderQuery>;
export function snapshotQueries(
queries: IBuilderQuery[],
): Map<string, IBuilderQuery> {
return new Map(queries.map((query) => [query.queryName, query]));
}
/**
* The query each field should be copied from: the one whose value just changed
* (to all), else a settled one (to newcomers only) when a query was added or
* switched signal. A signal switch reloads that query's own state, so it is
* never the source.
*/
function findSource(
property: keyof IBuilderQuery,
settled: IBuilderQuery[],
hasNewcomer: boolean,
previous: Map<string, IBuilderQuery>,
): { source: IBuilderQuery; newcomersOnly: boolean } | undefined {
const changed = settled.find(
(query) =>
!isEqual(previous.get(query.queryName)?.[property], query[property]),
);
if (changed) {
return { source: changed, newcomersOnly: false };
}
return hasNewcomer && settled.length > 0
? { source: settled[0], newcomersOnly: true }
: undefined;
}
export function syncQueryBuilderFields(
queries: IBuilderQuery[],
previous: Map<string, IBuilderQuery>,
fields: SyncedQueryBuilderField[],
): { index: number; query: IBuilderQuery }[] {
const settled = queries.filter((query) => {
const before = previous.get(query.queryName);
return (
before?.dataSource === query.dataSource && before.source === query.source
);
});
const hasNewcomer = settled.length < queries.length;
const sources = fields
.map((field) => QUERY_PROPERTY[field])
.map((property) => ({
property,
found: findSource(property, settled, hasNewcomer, previous),
}));
return queries.flatMap((query, index) => {
const isSettled = settled.includes(query);
const patch = sources.reduce<Partial<IBuilderQuery>>(
(acc, { property, found }) =>
found &&
!(found.newcomersOnly && isSettled) &&
!isEqual(query[property], found.source[property])
? { ...acc, [property]: found.source[property] }
: acc,
{},
);
return Object.keys(patch).length > 0
? [{ index, query: { ...query, ...patch } }]
: [];
});
}

View File

@@ -1,37 +0,0 @@
import { useEffect, useRef } from 'react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import type { SyncedQueryBuilderField } from './queryBuilderFields.types';
import {
snapshotQueries,
syncQueryBuilderFields,
} from './syncQueryBuilderFields';
const NO_FIELDS: SyncedQueryBuilderField[] = [];
/**
* Keeps `fields` equal across builder queries: an edit to one query copies to
* the rest, and a query added or switched to another signal takes the others'
* values. Values that already differ when this starts are left alone until one
* of them is edited.
*/
export function useSyncQueryBuilderFields(
fields: SyncedQueryBuilderField[] = NO_FIELDS,
): void {
const { currentQuery, handleSetQueryData } = useQueryBuilder();
const queries = currentQuery.builder.queryData;
const previousRef = useRef<Map<string, IBuilderQuery>>();
const enabled = fields.length > 0;
useEffect(() => {
const previous = previousRef.current;
previousRef.current = enabled ? snapshotQueries(queries) : undefined;
if (!enabled || !previous || queries.length < 2) {
return;
}
syncQueryBuilderFields(queries, previous, fields).forEach(
({ index, query }) => handleSetQueryData(index, query),
);
}, [enabled, fields, queries, handleSetQueryData]);
}

View File

@@ -6,5 +6,4 @@ export type GroupByFilterProps = {
onChange: (values: BaseAutocompleteData[]) => void;
disabled: boolean;
signalSource?: string;
placeholder?: string;
};

View File

@@ -32,7 +32,6 @@ export const GroupByFilter = memo(function GroupByFilter({
onChange,
disabled,
signalSource,
placeholder = 'Everything (no breakdown)',
}: GroupByFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const queryClient = useQueryClient();
@@ -192,7 +191,7 @@ export const GroupByFilter = memo(function GroupByFilter({
notFoundContent={isFetching ? <Spin size="small" /> : null}
onChange={handleChange}
data-testid="group-by"
placeholder={localValues?.length === 0 ? placeholder : ''}
placeholder={localValues?.length === 0 ? 'Everything (no breakdown)' : ''}
/>
);
});

View File

@@ -9,7 +9,6 @@ export type OrderByFilterProps = {
isRawQuery?: boolean;
entityVersion?: string;
isNewQueryV2?: boolean;
placeholder?: string;
};
export type OrderByFilterValue = {

View File

@@ -15,7 +15,6 @@ export function OrderByFilter({
isRawQuery = false,
entityVersion,
isNewQueryV2 = false,
placeholder,
}: OrderByFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const {
@@ -93,7 +92,6 @@ export function OrderByFilter({
options={optionsData}
notFoundContent={isFetching ? <Spin size="small" /> : null}
onChange={handleChange}
placeholder={placeholder}
/>
);
}

View File

@@ -5,7 +5,6 @@
height: 100%;
min-height: 0;
overflow: auto;
scrollbar-gutter: stable;
background-color: var(--l1-background);
@include custom-scrollbar;
}

View File

@@ -19,7 +19,6 @@ import PromQLQueryContainer from 'container/QueryBuilder/rawQueryEditors/PromQL'
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSyncQueryBuilderFields } from 'components/QueryBuilderV2/useSyncQueryBuilderFields';
import { DataSource } from 'types/common/queryBuilder';
import {
@@ -73,7 +72,6 @@ function PanelEditorQueryBuilder({
const isRawQuery = isRawRequest(panelDefinition.queryCapabilities);
const { currentQuery } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
useSyncQueryBuilderFields(panelDefinition.syncedQueryBuilderFields);
// Cleared here, not in QueryBuilderV2: builder panes unmount on every tab switch.
useEffect(() => {

View File

@@ -20,9 +20,6 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: (): boolean => false }));
jest.mock('components/QueryBuilderV2/useSyncQueryBuilderFields', () => ({
useSyncQueryBuilderFields: jest.fn(),
}));
jest.mock('components/QueryBuilderV2/QueryBuilderV2', () => ({
QueryBuilderV2: (props: unknown): null => {
mockQueryBuilderV2(props);

View File

@@ -32,11 +32,6 @@ export interface PanelQueryCapabilities {
* carries no explicit limit" — an explicit limit means the user asked for a fixed set.
*/
serverPaginated: boolean;
/**
* `limit` for a lone builder query that sets none. Skipped with several queries,
* where per-query limits can keep different groups and break the join.
*/
defaultRowLimit?: number;
}
/** Raw rows rather than an aggregated result — the single source for "is this raw?". */

View File

@@ -8,7 +8,6 @@ import type { ChartLine } from '@signozhq/icons';
import type { SectionConfig } from './sections';
import type { AnyPanelInteractionProps } from './interactions';
import type { PanelKind } from './panelKind';
import type { SyncedQueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
import type {
PanelQueryCapabilities,
QueryBuilderFieldsConfig,
@@ -122,8 +121,6 @@ export interface QueryPanelDefinition<
queryBuilderFields: QueryBuilderFieldsConfig;
/** How this kind's query-range request is shaped (request type, paging, result formatting). */
queryCapabilities: PanelQueryCapabilities;
/** Builder fields kept equal across queries: an edit to one query copies to the rest. */
syncedQueryBuilderFields?: SyncedQueryBuilderField[];
}
/**

View File

@@ -71,10 +71,6 @@ const LIST_PANEL_CAPABILITIES = {
orderTiebreaker: true,
serverPaginated: true,
};
const DEFAULT_LIMIT_CAPABILITIES = {
...TABLE_CAPABILITIES,
defaultRowLimit: 10_000,
};
describe('requestType', () => {
it.each([
@@ -400,51 +396,6 @@ describe('buildQueryRangeRequest', () => {
});
});
describe('defaultRowLimit', () => {
function limits(queries: DashboardtypesQueryDTO[]): unknown[] {
const request = buildQueryRangeRequest({
queries,
queryCapabilities: DEFAULT_LIMIT_CAPABILITIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
return (request.compositeQuery?.queries ?? []).map(
(envelope) => (envelope.spec as { limit?: number }).limit,
);
}
it('limits a lone builder query', () => {
expect(limits(bareBuilderQuery({ name: 'A' }))).toStrictEqual([10_000]);
});
it('keeps a limit the user set', () => {
expect(limits(bareBuilderQuery({ name: 'A', limit: 50 }))).toStrictEqual([
50,
]);
});
it('leaves several queries unlimited so their join keeps every group', () => {
expect(
limits(
compositeQuery([
{ type: 'builder_query', spec: { name: 'A' } },
{ type: 'builder_query', spec: { name: 'B' } },
]),
),
).toStrictEqual([undefined, undefined]);
});
it('leaves a lone non-builder query alone', () => {
expect(
limits(
compositeQuery([
{ type: 'clickhouse_sql', spec: { name: 'A', query: 'SELECT 1' } },
]),
),
).toStrictEqual([undefined]);
});
});
describe('getBucketedStepIntervalSeconds', () => {
// V1 parity: getBarStepIntervalPoints in container/GridCardLayout/utils.ts
it.each([

View File

@@ -217,30 +217,6 @@ function withPagination(
});
}
function withDefaultRowLimit(
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
limit: number,
): Querybuildertypesv5QueryEnvelopeDTO[] {
const [envelope] = envelopes;
if (
envelopes.length !== 1 ||
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query ||
envelope.spec?.limit
) {
return envelopes;
}
return [
{
...envelope,
spec: {
...envelope.spec,
limit,
} as Querybuildertypesv5BuilderQuerySpecDTO,
},
];
}
export interface BuildQueryRangeRequestArgs {
queries: DashboardtypesQueryDTO[];
/**
@@ -273,7 +249,6 @@ export function buildQueryRangeRequest({
formatTableResultForUI,
bucketedStepInterval,
orderTiebreaker,
defaultRowLimit,
},
startMs,
endMs,
@@ -291,9 +266,6 @@ export function buildQueryRangeRequest({
if (pagination) {
envelopes = withPagination(envelopes, pagination);
}
if (defaultRowLimit) {
envelopes = withDefaultRowLimit(envelopes, defaultRowLimit);
}
return {
schemaVersion: 'v1',

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

@@ -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"`
}

View File

@@ -0,0 +1,239 @@
package aiobservabilitytypes
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
FormatterSemconv = "semconv"
FormatterOpenAIChat = "openai.chat"
FormatterText = "text"
FormatterGeneric = "generic"
)
var dataURL = regexp.MustCompile(`^data:([^;,]+);base64,(.+)$`)
// Provider spellings of the finish reasons; the enum values themselves pass through.
var finishReasonAliases = map[string]FinishReason{
"tool_calls": FinishReasonToolCall, "function_call": FinishReasonToolCall,
"end_turn": FinishReasonStop, "stop_sequence": FinishReasonStop,
"max_tokens": FinishReasonLength,
}
// FormattedMessages is the OTel GenAI view of one span's messages. Formatter names the converter that
// produced it, Warnings what the converter could not resolve. The lists are never nil.
type FormattedMessages struct {
Input InputMessages
Output OutputMessages
Formatter string
Warnings []string
}
// FormatMessages builds the OTel GenAI view of gen_ai.input.messages and gen_ai.output.messages from the
// formats SDKs write. Each value is a JSON string or a structured value; nil means absent.
func FormatMessages(input, output any) FormattedMessages {
f := &formatting{out: FormattedMessages{Input: InputMessages{}, Output: OutputMessages{}, Warnings: []string{}}}
var inputLabel, outputLabel string
if input != nil {
var msgs OutputMessages
msgs, inputLabel = f.side(decode(input), RoleUser)
for _, m := range msgs {
f.out.Input = append(f.out.Input, ChatMessage{Role: m.Role, Parts: m.Parts, Name: m.Name})
}
}
if output != nil {
f.out.Output, outputLabel = f.side(decode(output), RoleAssistant)
}
f.out.Formatter = inputLabel
if inputLabel == "" {
f.out.Formatter = outputLabel
} else if outputLabel != "" && outputLabel != inputLabel {
f.warn("output formatted as %s, input as %s", outputLabel, inputLabel)
}
return f.out
}
type formatting struct {
out FormattedMessages
}
func (f *formatting) warn(format string, args ...any) {
f.out.Warnings = append(f.out.Warnings, fmt.Sprintf(format, args...))
}
// side converts one attribute value by its shape; role is what a bare string on this side is
// spoken as.
func (f *formatting) side(value any, role Role) (OutputMessages, string) {
switch v := value.(type) {
case []any:
if first, ok := firstObject(v); ok {
if _, ok := first["parts"]; ok {
return f.semconv(v), FormatterSemconv
}
if _, ok := first["role"]; ok {
return f.openAIChatMessages(v), FormatterOpenAIChat
}
}
case map[string]any:
if choices, ok := v["choices"].([]any); ok {
return f.openAIChatResponse(choices), FormatterOpenAIChat
}
if messages, ok := v["messages"].([]any); ok {
return f.openAIChatMessages(messages), FormatterOpenAIChat
}
case string:
if v == "" {
return OutputMessages{}, FormatterText
}
f.warn("bare %s text, role assumed", role.StringValue())
return OutputMessages{{Role: role, Parts: Parts{part(textPart(v))}}}, FormatterText
}
f.warn("message format not recognised, kept as generic")
return OutputMessages{genericMessage(value)}, FormatterGeneric
}
func firstObject(list []any) (map[string]any, bool) {
if len(list) == 0 {
return nil, false
}
first, ok := list[0].(map[string]any)
return first, ok
}
func genericMessage(value any) OutputMessage {
return OutputMessage{Parts: Parts{part(GenericPart{"type": FormatterGeneric, "content": stringOf(value)})}}
}
// decode parses a JSON-encoded attribute value, twice when an SDK encoded the string again.
// Anything that is not JSON is returned as is.
func decode(value any) any {
s, ok := value.(string)
if !ok {
return value
}
trimmed := strings.TrimSpace(s)
if trimmed == "" || !strings.ContainsAny(trimmed[:1], `[{"`) {
return s
}
var decoded any
if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil {
return s
}
if inner, ok := decoded.(string); ok {
return decode(inner)
}
return decoded
}
func role(value string) Role {
lower := strings.ToLower(strings.TrimSpace(value))
switch lower {
case "human":
return RoleUser
case "ai", "model":
return RoleAssistant
case "function":
return RoleTool
}
return Role{String: valuer.NewString(lower)}
}
func finishReason(value string) *FinishReason {
lower := strings.ToLower(strings.TrimSpace(value))
if lower == "" {
return nil
}
fr, ok := finishReasonAliases[lower]
if !ok {
fr = FinishReason{String: valuer.NewString(lower)}
}
return &fr
}
func optionalString(value any) *string {
s := stringOf(value)
if s == "" {
return nil
}
return &s
}
func part(value any) Part {
return Part{Value: value}
}
func textPart(content string) TextPart {
return TextPart{Type: PartTypeText, Content: content}
}
// contentPart converts one OpenAI content block, which semconv parts may also carry; a block of
// any other type is kept as sent.
func contentPart(item any) Part {
p, ok := item.(map[string]any)
if !ok {
if s, ok := item.(string); ok {
return part(textPart(s))
}
return part(GenericPart{"type": FormatterGeneric, "content": stringOf(item)})
}
switch stringOf(p["type"]) {
case "text":
return part(textPart(stringOf(p["text"])))
case "refusal":
return part(textPart(stringOf(p["refusal"])))
case "image_url":
url := stringOf(p["image_url"])
if ref, ok := p["image_url"].(map[string]any); ok {
url = stringOf(ref["url"])
}
if url != "" {
return part(mediaPart(url, ModalityImage))
}
case "file":
if f, ok := filePart(p); ok {
return f
}
}
return part(GenericPart(p))
}
// filePart reads {file: {file_id}} or {file: {file_data, filename}}; Chat Completions accepts only PDF here.
func filePart(block map[string]any) (Part, bool) {
f, _ := block["file"].(map[string]any)
if id := stringOf(f["file_id"]); id != "" {
mime := "application/pdf"
return part(FilePart{Type: PartTypeFile, Modality: ModalityDocument, FileID: id, MimeType: &mime}), true
}
if data := stringOf(f["file_data"]); data != "" {
return part(mediaPart(data, ModalityDocument)), true
}
return Part{}, false
}
// mediaPart places a data URL in a BlobPart, any other URL in a UriPart.
func mediaPart(url string, modality Modality) any {
if m := dataURL.FindStringSubmatch(url); m != nil {
return BlobPart{Type: PartTypeBlob, Modality: modality, MimeType: &m[1], Content: m[2]}
}
return UriPart{Type: PartTypeURI, Modality: modality, URI: url}
}
// stringOf renders nil as "" and non-strings as compact JSON.
func stringOf(value any) string {
switch v := value.(type) {
case nil:
return ""
case string:
return v
}
data, err := json.Marshal(value)
if err != nil {
return ""
}
return string(data)
}

View File

@@ -0,0 +1,125 @@
package aiobservabilitytypes
import (
"strings"
)
// Format: OpenAI Chat Completions, a request {messages}, a response {choices}, or a bare message list.
// Written by: bifrost, openrouter requests, the raw OpenAI calls OpenInference records.
// Handled: string and block content, tool_calls nested or flattened, tool messages, refusal,
// reasoning_content, finish reasons.
// Not yet: streaming delta chunks, legacy completions choices[].text, audio output.
func (f *formatting) openAIChatMessages(list []any) OutputMessages {
out := make(OutputMessages, 0, len(list))
for _, item := range list {
m, ok := item.(map[string]any)
if !ok {
out = append(out, genericMessage(item))
continue
}
out = append(out, f.openAIChatMessage(m))
}
return out
}
// openAIChatResponse converts the choices of an OpenAI chat response, one message each with its
// finish reason.
func (f *formatting) openAIChatResponse(choices []any) OutputMessages {
out := make(OutputMessages, 0, len(choices))
for _, item := range choices {
choice, ok := item.(map[string]any)
if !ok {
out = append(out, genericMessage(item))
continue
}
inner, _ := choice["message"].(map[string]any)
msg := f.openAIChatMessage(inner)
if msg.Role.IsZero() {
msg.Role = RoleAssistant
}
if fr := finishReason(stringOf(choice["finish_reason"])); fr != nil {
msg.FinishReason = fr
}
out = append(out, msg)
}
return out
}
// openAIChatMessage converts one {role, content, tool_calls, ...} message in the OpenAI chat shape.
func (f *formatting) openAIChatMessage(m map[string]any) OutputMessage {
msg := OutputMessage{Role: role(stringOf(m["role"])), Parts: Parts{}, Name: optionalString(m["name"]), FinishReason: finishReason(stringOf(m["finish_reason"]))}
if id := stringOf(m["tool_call_id"]); msg.Role == RoleTool && id != "" {
msg.Parts = Parts{part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: &id, Response: toolResponse(m["content"])})}
return msg
}
switch content := m["content"].(type) {
case string:
if content != "" {
msg.Parts = append(msg.Parts, part(textPart(content)))
}
case []any:
for _, item := range content {
msg.Parts = append(msg.Parts, contentPart(item))
}
}
if refusal := stringOf(m["refusal"]); refusal != "" {
msg.Parts = append(msg.Parts, part(textPart(refusal)))
}
reasoning, _ := m["reasoning_content"].(string)
if reasoning == "" {
reasoning, _ = m["reasoning"].(string)
}
if reasoning != "" {
msg.Parts = append(msg.Parts, part(ReasoningPart{Type: PartTypeReasoning, Content: reasoning}))
}
if calls, ok := m["tool_calls"].([]any); ok {
for _, call := range calls {
if c, ok := call.(map[string]any); ok {
msg.Parts = append(msg.Parts, toolCallPart(c))
}
}
}
if call, ok := m["function_call"].(map[string]any); ok {
msg.Parts = append(msg.Parts, part(ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(call["name"]), Arguments: decode(call["arguments"])}))
}
return msg
}
// toolCallPart reads {id, function: {name, arguments}}; a gateway may flatten it to {id, name, arguments|args}.
func toolCallPart(c map[string]any) Part {
call := ToolCallRequestPart{Type: PartTypeToolCall, ID: optionalString(c["id"]), Name: stringOf(c["name"])}
args := c["arguments"]
if args == nil {
args = c["args"]
}
if fn, ok := c["function"].(map[string]any); ok {
call.Name = stringOf(fn["name"])
args = fn["arguments"]
}
if args != nil {
call.Arguments = decode(args)
}
return part(call)
}
// toolResponse parses a JSON string and joins text blocks; anything else stays as sent.
func toolResponse(value any) any {
switch v := value.(type) {
case string:
return decode(v)
case []any:
texts := make([]string, 0, len(v))
for _, item := range v {
block, ok := item.(map[string]any)
if !ok || stringOf(block["type"]) != "text" {
return v
}
texts = append(texts, stringOf(block["text"]))
}
return strings.Join(texts, "\n")
}
return value
}

View File

@@ -0,0 +1,222 @@
package aiobservabilitytypes
import "testing"
// Captured cases are gen_ai.input.messages and gen_ai.output.messages as the collector stored
// them for the SigNoz/scripts static-telemetry-generator captures.
func TestFormat_OpenAIChat(t *testing.T) {
runFormatCases(t, []formatCase{
{
// openinference capture, ChatCompletion span: the raw OpenAI request and response
name: "ResponseWithToolCalls_ToolCallParts_FinishReasonToolCall",
input: `{"messages": [{"role": "user", "content": "What's the weather in Paris and in London? Use the tool for each."}], "model": "gpt-4o-mini", "max_tokens": 120, "tool_choice": "auto", "tools": [{"type": "function", "function": {"name": "get_current_weather", "description": "Get the current weather for a city.", "parameters": {"type": "object", "properties": {"city": {"type": "string"}, "unit": {"type": "string", "enum": ["c", "f"]}}, "required": ["city"]}}}]}`,
output: `{"id":"chatcmpl-DksBDptnQ3aTu18YrqSdYQgiPOuRV","choices":[{"finish_reason":"tool_calls","index":0,"logprobs":null,"message":{"content":null,"refusal":null,"role":"assistant","annotations":[],"tool_calls":[{"id":"call_SW4hst7nzysgvWSAz4OdyK9D","function":{"arguments":"{\"city\": \"Paris\"}","name":"get_current_weather"},"type":"function"},{"id":"call_si8wiETYGpooMB7gMXSgMQpk","function":{"arguments":"{\"city\": \"London\"}","name":"get_current_weather"},"type":"function"}]}}],"created":1780063727,"model":"gpt-4o-mini-2024-07-18","object":"chat.completion","service_tier":"default","system_fingerprint":"fp_6e71a9f378","usage":{"completion_tokens":46,"prompt_tokens":71,"total_tokens":117,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0}}}`,
formatter: FormatterOpenAIChat,
wantInput: `[
{
"role": "user",
"parts": [
{
"type": "text",
"content": "What's the weather in Paris and in London? Use the tool for each."
}
]
}
]`,
wantOutput: `[
{
"role": "assistant",
"parts": [
{
"type": "tool_call",
"name": "get_current_weather",
"id": "call_SW4hst7nzysgvWSAz4OdyK9D",
"arguments": {
"city": "Paris"
}
},
{
"type": "tool_call",
"name": "get_current_weather",
"id": "call_si8wiETYGpooMB7gMXSgMQpk",
"arguments": {
"city": "London"
}
}
],
"finish_reason": "tool_call"
}
]`,
},
{
// openinference capture, the follow-up ChatCompletion span
name: "ToolRoleMessages_BecomeToolCallResponseParts",
input: `{"messages": [{"role": "user", "content": "What's the weather in Paris and in London? Use the tool for each."}, {"content": null, "refusal": null, "role": "assistant", "annotations": [], "audio": null, "function_call": null, "tool_calls": [{"id": "call_SW4hst7nzysgvWSAz4OdyK9D", "function": {"arguments": "{\"city\": \"Paris\"}", "name": "get_current_weather"}, "type": "function"}, {"id": "call_si8wiETYGpooMB7gMXSgMQpk", "function": {"arguments": "{\"city\": \"London\"}", "name": "get_current_weather"}, "type": "function"}]}, {"role": "tool", "tool_call_id": "call_SW4hst7nzysgvWSAz4OdyK9D", "content": "{\"city\": \"Paris\", \"temp_c\": 18, \"summary\": \"Clear\"}"}, {"role": "tool", "tool_call_id": "call_si8wiETYGpooMB7gMXSgMQpk", "content": "{\"city\": \"London\", \"temp_c\": 18, \"summary\": \"Clear\"}"}], "model": "gpt-4o-mini", "max_tokens": 80}`,
output: `{"id":"chatcmpl-DksBF2QuaoMMMFg9UAXX9Rc28XVJT","choices":[{"finish_reason":"stop","index":0,"logprobs":null,"message":{"content":"The current weather is as follows:\n\n- **Paris**: 18°C, Clear\n- **London**: 18°C, Clear\n\nBoth cities are enjoying clear skies with the same temperature.","refusal":null,"role":"assistant","annotations":[]}}],"created":1780063729,"model":"gpt-4o-mini-2024-07-18","object":"chat.completion","service_tier":"default","system_fingerprint":"fp_e2d886d409","usage":{"completion_tokens":40,"prompt_tokens":118,"total_tokens":158,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0}}}`,
formatter: FormatterOpenAIChat,
wantInput: `[
{
"role": "user",
"parts": [
{
"type": "text",
"content": "What's the weather in Paris and in London? Use the tool for each."
}
]
},
{
"role": "assistant",
"parts": [
{
"type": "tool_call",
"name": "get_current_weather",
"id": "call_SW4hst7nzysgvWSAz4OdyK9D",
"arguments": {
"city": "Paris"
}
},
{
"type": "tool_call",
"name": "get_current_weather",
"id": "call_si8wiETYGpooMB7gMXSgMQpk",
"arguments": {
"city": "London"
}
}
]
},
{
"role": "tool",
"parts": [
{
"type": "tool_call_response",
"response": {
"city": "Paris",
"summary": "Clear",
"temp_c": 18
},
"id": "call_SW4hst7nzysgvWSAz4OdyK9D"
}
]
},
{
"role": "tool",
"parts": [
{
"type": "tool_call_response",
"response": {
"city": "London",
"summary": "Clear",
"temp_c": 18
},
"id": "call_si8wiETYGpooMB7gMXSgMQpk"
}
]
}
]`,
wantOutput: `[
{
"role": "assistant",
"parts": [
{
"type": "text",
"content": "The current weather is as follows:\n\n- **Paris**: 18°C, Clear\n- **London**: 18°C, Clear\n\nBoth cities are enjoying clear skies with the same temperature."
}
],
"finish_reason": "stop"
}
]`,
},
{
// bifrost-gateway capture, HTTP span
name: "GatewayFlattenedToolCalls_ToolCallParts_BareInputWarns",
input: `What's the weather in Paris and in London? Use the tool for each.`,
output: `[{"role":"assistant","content":"","tool_calls":[{"id":"call_xP33RT5exgBrNsvsWQZwiY9D","type":"function","name":"get_current_weather","args":"{\"city\": \"Paris\"}"},{"id":"call_lC6GgT8JW2ygJqWEM2wcohIz","type":"function","name":"get_current_weather","args":"{\"city\": \"London\"}"}]}]`,
formatter: FormatterText,
warnings: []string{"bare user text, role assumed", "output formatted as openai.chat, input as text"},
wantInput: `[
{
"role": "user",
"parts": [
{
"type": "text",
"content": "What's the weather in Paris and in London? Use the tool for each."
}
]
}
]`,
wantOutput: `[
{
"role": "assistant",
"parts": [
{
"type": "tool_call",
"name": "get_current_weather",
"id": "call_xP33RT5exgBrNsvsWQZwiY9D",
"arguments": {
"city": "Paris"
}
},
{
"type": "tool_call",
"name": "get_current_weather",
"id": "call_lC6GgT8JW2ygJqWEM2wcohIz",
"arguments": {
"city": "London"
}
}
]
}
]`,
},
{
// openinference capture, input.value of the ChatCompletion span
name: "FileByID_FilePart",
input: `{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": [{"type": "text", "text": "Summarise this document in one sentence."}, {"type": "file", "file": {"file_id": "file-6F2ksmvXxt4VdoqmHRw6kL"}}]}], "max_tokens": 60}`,
output: `{"id":"chatcmpl-DktFileCapture0001","object":"chat.completion","model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"finish_reason":"stop","logprobs":null,"message":{"role":"assistant","content":"The document is a short PDF about dragons.","refusal":null,"annotations":[]}}]}`,
formatter: FormatterOpenAIChat,
wantInput: `[
{
"role": "user",
"parts": [
{"type": "text", "content": "Summarise this document in one sentence."},
{"type": "file", "modality": "document", "file_id": "file-6F2ksmvXxt4VdoqmHRw6kL", "mime_type": "application/pdf"}
]
}
]`,
wantOutput: `[
{
"role": "assistant",
"parts": [{"type": "text", "content": "The document is a short PDF about dragons."}],
"finish_reason": "stop"
}
]`,
},
{
// openinference capture, input.value of the ChatCompletion span
name: "FileInline_BlobPart",
input: `{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": [{"type": "text", "text": "Summarise this document in one sentence."}, {"type": "file", "file": {"filename": "draconomicon.pdf", "file_data": "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbXS9Db3VudCAwPj5lbmRvYmoKdHJhaWxlcjw8L1Jvb3QgMSAwIFI+PgolJUVPRgo="}}]}], "max_tokens": 60}`,
formatter: FormatterOpenAIChat,
wantInput: `[
{
"role": "user",
"parts": [
{"type": "text", "content": "Summarise this document in one sentence."},
{"type": "blob", "modality": "document", "mime_type": "application/pdf", "content": "JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbXS9Db3VudCAwPj5lbmRvYmoKdHJhaWxlcjw8L1Jvb3QgMSAwIFI+PgolJUVPRgo="}
]
}
]`,
wantOutput: `[]`,
},
{
name: "ResponseWithScalarChoice_KeptAsGeneric",
output: `{"choices":["oops",{"finish_reason":"stop","index":1,"message":{"role":"assistant","content":"hi"}}]}`,
formatter: FormatterOpenAIChat,
wantInput: `[]`,
wantOutput: `[
{"role": "", "parts": [{"type": "generic", "content": "oops"}]},
{"role": "assistant", "parts": [{"type": "text", "content": "hi"}], "finish_reason": "stop"}
]`,
},
})
}

View File

@@ -0,0 +1,68 @@
package aiobservabilitytypes
import (
"encoding/json"
"maps"
)
// Format: the OTel shape itself, [{role, parts}].
// Written by: litellm, langchain, openllmetry, portkey, traceloop LLM spans.
// Decoding is the genai types. Repairs: "text" used for "content" on text parts; OpenAI content
// blocks left inside parts.
// Not yet: "thinking" for "reasoning"; "result" or "output" for "response" on tool call responses.
// semconv decodes messages that already follow the schema through the genai types. A part of a
// type the schema does not name is a provider content block, which contentPart converts.
func (f *formatting) semconv(list []any) OutputMessages {
data, err := json.Marshal(semconvTextKey(list))
if err != nil {
f.warn("messages could not be encoded: %s", err)
return OutputMessages{genericMessage(list)}
}
var msgs OutputMessages
if err := json.Unmarshal(data, &msgs); err != nil {
f.warn("messages do not follow the schema: %s", err)
return OutputMessages{genericMessage(list)}
}
for i := range msgs {
if msgs[i].Parts == nil {
msgs[i].Parts = Parts{}
}
for j, p := range msgs[i].Parts {
if block, ok := p.Value.(GenericPart); ok {
msgs[i].Parts[j] = contentPart(map[string]any(block))
}
}
}
return msgs
}
// semconvTextKey copies the text and reasoning parts some SDKs write with "text" in place of the
// schema's "content". The input is left untouched, it is also the span's raw attribute.
func semconvTextKey(list []any) []any {
out := make([]any, 0, len(list))
for _, item := range list {
m, ok := item.(map[string]any)
parts, hasParts := m["parts"].([]any)
if !ok || !hasParts {
out = append(out, item)
continue
}
fixedParts := make([]any, 0, len(parts))
for _, p := range parts {
part, ok := p.(map[string]any)
typ := stringOf(part["type"])
if ok && (typ == "text" || typ == "reasoning") && part["content"] == nil && part["text"] != nil {
fixed := maps.Clone(part)
fixed["content"] = fixed["text"]
delete(fixed, "text")
p = fixed
}
fixedParts = append(fixedParts, p)
}
msg := maps.Clone(m)
msg["parts"] = fixedParts
out = append(out, msg)
}
return out
}

View File

@@ -0,0 +1,163 @@
package aiobservabilitytypes
import "testing"
// Captured cases are gen_ai.input.messages and gen_ai.output.messages as the collector stored
// them for the SigNoz/scripts static-telemetry-generator captures.
func TestFormat_Semconv(t *testing.T) {
runFormatCases(t, []formatCase{
{
// litellm capture, litellm_request span
name: "SemconvMessages_DecodedUnchanged",
input: `[{"role": "system", "parts": [{"type": "text", "content": "You are a concise assistant."}]}, {"role": "user", "parts": [{"type": "text", "content": "Give me a one-line definition of observability."}]}]`,
output: `[{"role": "assistant", "parts": [{"type": "text", "content": "Observability is the ability to understand the internal state of a system based on external outputs, enabling effective monitoring and troubleshooting."}], "finish_reason": "stop"}]`,
formatter: FormatterSemconv,
wantInput: `[
{
"role": "system",
"parts": [
{
"type": "text",
"content": "You are a concise assistant."
}
]
},
{
"role": "user",
"parts": [
{
"type": "text",
"content": "Give me a one-line definition of observability."
}
]
}
]`,
wantOutput: `[
{
"role": "assistant",
"parts": [
{
"type": "text",
"content": "Observability is the ability to understand the internal state of a system based on external outputs, enabling effective monitoring and troubleshooting."
}
],
"finish_reason": "stop"
}
]`,
},
{
// litellm capture, vision call; base64 trimmed
name: "SemconvTextSpelledAsTextKey_ContentFilled_ImageBlockBecomesBlob",
input: `[{"role": "user", "parts": [{"type": "text", "text": "What animal is in this image? One sentence."}, {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBD"}}]}]`,
output: `[{"role": "assistant", "parts": [{"type": "text", "content": "The animal in the image is a cartoon duck."}], "finish_reason": "stop"}]`,
formatter: FormatterSemconv,
wantInput: `[
{
"role": "user",
"parts": [
{
"type": "text",
"content": "What animal is in this image? One sentence."
},
{
"type": "blob",
"modality": "image",
"content": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBD",
"mime_type": "image/jpeg"
}
]
}
]`,
wantOutput: `[
{
"role": "assistant",
"parts": [
{
"type": "text",
"content": "The animal in the image is a cartoon duck."
}
],
"finish_reason": "stop"
}
]`,
},
{
// langchain capture, ChatOpenAI.chat span
name: "SemconvToolCallTurn_DecodedUnchanged",
input: `[{"role": "user", "parts": [{"type": "text", "content": "Search for SigNoz AI observability docs, then tell me the weather in Bengaluru."}]}, {"role": "assistant", "parts": [{"type": "tool_call", "id": "call_jL9UhSdUC9wWn2qfWwOjpzFd", "name": "search_web", "arguments": {"query": "SigNoz AI observability documentation"}}, {"type": "tool_call", "id": "call_nkVdZ8xX8d17HtmVddbUgJUc", "name": "get_weather", "arguments": {"city": "Bengaluru"}}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_jL9UhSdUC9wWn2qfWwOjpzFd", "response": "{\"query\": \"SigNoz AI observability documentation\", \"results\": [{\"title\": \"SigNoz docs: AI observability\", \"snippet\": \"...\"}, {\"title\": \"OpenTelemetry GenAI conventions\", \"snippet\": \"...\"}]}"}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_nkVdZ8xX8d17HtmVddbUgJUc", "response": "{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}"}]}]`,
output: `[{"role": "assistant", "parts": [{"type": "text", "content": "I found some results for the SigNoz AI observability documentation. Here's one of the titles you might be interested in:\n\n- **SigNoz docs: AI observability**\n\nUnfortunately, a snippet was not provided, but you can look it up directly for more details.\n\nAs for the weather in Bengaluru, it's currently 18\u00b0C and clear."}], "finish_reason": "stop"}]`,
formatter: FormatterSemconv,
wantInput: `[
{
"role": "user",
"parts": [
{
"type": "text",
"content": "Search for SigNoz AI observability docs, then tell me the weather in Bengaluru."
}
]
},
{
"role": "assistant",
"parts": [
{
"type": "tool_call",
"name": "search_web",
"id": "call_jL9UhSdUC9wWn2qfWwOjpzFd",
"arguments": {
"query": "SigNoz AI observability documentation"
}
},
{
"type": "tool_call",
"name": "get_weather",
"id": "call_nkVdZ8xX8d17HtmVddbUgJUc",
"arguments": {
"city": "Bengaluru"
}
}
]
},
{
"role": "tool",
"parts": [
{
"type": "tool_call_response",
"response": "{\"query\": \"SigNoz AI observability documentation\", \"results\": [{\"title\": \"SigNoz docs: AI observability\", \"snippet\": \"...\"}, {\"title\": \"OpenTelemetry GenAI conventions\", \"snippet\": \"...\"}]}",
"id": "call_jL9UhSdUC9wWn2qfWwOjpzFd"
}
]
},
{
"role": "tool",
"parts": [
{
"type": "tool_call_response",
"response": "{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}",
"id": "call_nkVdZ8xX8d17HtmVddbUgJUc"
}
]
}
]`,
wantOutput: `[
{
"role": "assistant",
"parts": [
{
"type": "text",
"content": "I found some results for the SigNoz AI observability documentation. Here's one of the titles you might be interested in:\n\n- **SigNoz docs: AI observability**\n\nUnfortunately, a snippet was not provided, but you can look it up directly for more details.\n\nAs for the weather in Bengaluru, it's currently 18°C and clear."
}
],
"finish_reason": "stop"
}
]`,
},
{
name: "StructuredSemconvValue_DecodedWithoutJSONString",
input: []any{map[string]any{"role": "user", "parts": []any{map[string]any{"type": "text", "content": "hi"}}}},
formatter: FormatterSemconv,
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "hi"}]}]`,
wantOutput: `[]`,
},
})
}

View File

@@ -0,0 +1,101 @@
package aiobservabilitytypes
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type formatCase struct {
name string
input any
output any
formatter string
warnings []string
wantInput string
wantOutput string
}
func runFormatCases(t *testing.T, testCases []formatCase) {
t.Helper()
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
formatted := FormatMessages(testCase.input, testCase.output)
assert.Equal(t, testCase.formatter, formatted.Formatter)
assert.Equal(t, append([]string{}, testCase.warnings...), formatted.Warnings)
input, err := json.Marshal(formatted.Input)
require.NoError(t, err)
assert.JSONEq(t, testCase.wantInput, string(input))
output, err := json.Marshal(formatted.Output)
require.NoError(t, err)
assert.JSONEq(t, testCase.wantOutput, string(output))
})
}
}
func TestFormat(t *testing.T) {
runFormatCases(t, []formatCase{
{
// bifrost-gateway capture, HTTP span
name: "BareStrings_RolesAssumed_WarnsPerSide",
input: `A bat and a ball cost $1.10. The bat costs $1 more than the ball. How much is the ball?`,
output: `Let the cost of the ball be x dollars. Then the bat costs x + $1.00. According to the problem:
  x + (x + 1.00) = 1.10
Combine like terms:
  2x + 1.00 = 1.10
Subtract 1.00 from both sides:
  2x = 0.10
Divide by 2:
  x = 0.05
So, the ball costs 5 cents.`,
formatter: FormatterText,
warnings: []string{"bare user text, role assumed", "bare assistant text, role assumed"},
wantInput: `[
{
"role": "user",
"parts": [
{
"type": "text",
"content": "A bat and a ball cost $1.10. The bat costs $1 more than the ball. How much is the ball?"
}
]
}
]`,
wantOutput: `[
{
"role": "assistant",
"parts": [
{
"type": "text",
"content": "Let the cost of the ball be x dollars. Then the bat costs x + $1.00. According to the problem:\n\n  x + (x + 1.00) = 1.10\n\nCombine like terms:\n\n  2x + 1.00 = 1.10\n\nSubtract 1.00 from both sides:\n\n  2x = 0.10\n\nDivide by 2:\n\n  x = 0.05\n\nSo, the ball costs 5 cents."
}
]
}
]`,
},
{
name: "UnrecognisedObject_GenericPart_Warns",
input: `{"city": "Paris", "temp_c": 21}`,
formatter: FormatterGeneric,
warnings: []string{"message format not recognised, kept as generic"},
wantInput: `[{"role": "", "parts": [{"type": "generic", "content": "{\"city\":\"Paris\",\"temp_c\":21}"}]}]`,
wantOutput: `[]`,
},
{
name: "NoAttributes_EmptyListsNoFormatter",
wantInput: `[]`,
wantOutput: `[]`,
},
})
}

View File

@@ -0,0 +1,194 @@
package aiobservabilitytypes
import (
"encoding/json"
"github.com/swaggest/jsonschema-go"
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
PartTypeText = PartType{valuer.NewString("text")}
PartTypeToolCall = PartType{valuer.NewString("tool_call")}
PartTypeToolCallResponse = PartType{valuer.NewString("tool_call_response")}
PartTypeServerToolCall = PartType{valuer.NewString("server_tool_call")}
PartTypeServerToolCallResponse = PartType{valuer.NewString("server_tool_call_response")}
PartTypeBlob = PartType{valuer.NewString("blob")}
PartTypeFile = PartType{valuer.NewString("file")}
PartTypeURI = PartType{valuer.NewString("uri")}
PartTypeReasoning = PartType{valuer.NewString("reasoning")}
PartTypeCompaction = PartType{valuer.NewString("compaction")}
)
var (
_ jsonschema.OneOfExposer = Part{}
_ jsonschema.Preparer = Part{}
)
// partVariants are the part types the schema names; any other type decodes as GenericPart.
// schemaRef is the component in the generated OpenAPI spec, for the discriminator mapping.
var partVariants = []partVariant{
{typ: PartTypeText, decode: decodePart[TextPart], schema: TextPart{}, schemaRef: "#/components/schemas/AiobservabilitytypesTextPart"},
{typ: PartTypeToolCall, decode: decodePart[ToolCallRequestPart], schema: ToolCallRequestPart{}, schemaRef: "#/components/schemas/AiobservabilitytypesToolCallRequestPart"},
{typ: PartTypeToolCallResponse, decode: decodePart[ToolCallResponsePart], schema: ToolCallResponsePart{}, schemaRef: "#/components/schemas/AiobservabilitytypesToolCallResponsePart"},
{typ: PartTypeServerToolCall, decode: decodePart[ServerToolCallPart], schema: ServerToolCallPart{}, schemaRef: "#/components/schemas/AiobservabilitytypesServerToolCallPart"},
{typ: PartTypeServerToolCallResponse, decode: decodePart[ServerToolCallResponsePart], schema: ServerToolCallResponsePart{}, schemaRef: "#/components/schemas/AiobservabilitytypesServerToolCallResponsePart"},
{typ: PartTypeBlob, decode: decodePart[BlobPart], schema: BlobPart{}, schemaRef: "#/components/schemas/AiobservabilitytypesBlobPart"},
{typ: PartTypeFile, decode: decodePart[FilePart], schema: FilePart{}, schemaRef: "#/components/schemas/AiobservabilitytypesFilePart"},
{typ: PartTypeURI, decode: decodePart[UriPart], schema: UriPart{}, schemaRef: "#/components/schemas/AiobservabilitytypesUriPart"},
{typ: PartTypeReasoning, decode: decodePart[ReasoningPart], schema: ReasoningPart{}, schemaRef: "#/components/schemas/AiobservabilitytypesReasoningPart"},
{typ: PartTypeCompaction, decode: decodePart[CompactionPart], schema: CompactionPart{}, schemaRef: "#/components/schemas/AiobservabilitytypesCompactionPart"},
}
type PartType struct{ valuer.String }
// Part is one message part, the schema's anyOf discriminated on "type". Value holds the part
// struct for that type and is encoded as the part itself.
type Part struct {
Value any `json:"-"`
}
type Parts []Part
type TextPart struct {
Type PartType `json:"type" required:"true"`
Content string `json:"content" required:"true"`
}
type ToolCallRequestPart struct {
Type PartType `json:"type" required:"true"`
Name string `json:"name" required:"true"`
ID *string `json:"id,omitempty"`
Arguments any `json:"arguments,omitempty"`
}
// ToolCallResponsePart carries a client tool result, or a built-in tool outcome.
type ToolCallResponsePart struct {
Type PartType `json:"type" required:"true"`
Response any `json:"response" required:"true" nullable:"true"`
ID *string `json:"id,omitempty"`
}
// ServerToolCallPart is a tool the provider ran itself, such as code_interpreter or web_search.
type ServerToolCallPart struct {
Type PartType `json:"type" required:"true"`
Name string `json:"name" required:"true"`
ID *string `json:"id,omitempty"`
ServerToolCall GenericServerToolCall `json:"server_tool_call" required:"true" nullable:"false"`
}
// GenericServerToolCall is the provider's own call object, {type, ...} with any other keys.
type GenericServerToolCall map[string]any
type ServerToolCallResponsePart struct {
Type PartType `json:"type" required:"true"`
ID *string `json:"id,omitempty"`
ServerToolCallResponse GenericServerToolCallResponse `json:"server_tool_call_response" required:"true" nullable:"false"`
}
// GenericServerToolCallResponse is the provider's own response object, {type, ...} with any other keys.
type GenericServerToolCallResponse map[string]any
// BlobPart is inline data; Content is base64.
type BlobPart struct {
Type PartType `json:"type" required:"true"`
Modality Modality `json:"modality" required:"true"`
Content string `json:"content" required:"true"`
MimeType *string `json:"mime_type,omitempty"`
}
// FilePart references a file uploaded to the provider.
type FilePart struct {
Type PartType `json:"type" required:"true"`
Modality Modality `json:"modality" required:"true"`
FileID string `json:"file_id" required:"true"`
MimeType *string `json:"mime_type,omitempty"`
}
// UriPart references external data; a data: URL belongs in BlobPart instead.
type UriPart struct {
Type PartType `json:"type" required:"true"`
Modality Modality `json:"modality" required:"true"`
URI string `json:"uri" required:"true"`
MimeType *string `json:"mime_type,omitempty"`
}
type ReasoningPart struct {
Type PartType `json:"type" required:"true"`
Content string `json:"content" required:"true"`
}
// CompactionPart is compacted conversation state; Content is the summary when it is not encrypted.
type CompactionPart struct {
Type PartType `json:"type" required:"true"`
ID *string `json:"id,omitempty"`
Content *string `json:"content,omitempty"`
}
// GenericPart keeps a part of any other type as sent.
type GenericPart map[string]any
func (Part) JSONSchemaOneOf() []any {
oneOf := make([]any, 0, len(partVariants)+1)
for _, variant := range partVariants {
oneOf = append(oneOf, variant.schema)
}
return append(oneOf, GenericPart{})
}
func (Part) PrepareJSONSchema(schema *jsonschema.Schema) error {
if schema.ExtraProperties == nil {
schema.ExtraProperties = map[string]any{}
}
mapping := make(map[string]string, len(partVariants))
for _, variant := range partVariants {
mapping[variant.typ.StringValue()] = variant.schemaRef
}
schema.ExtraProperties["x-signoz-discriminator"] = map[string]any{
"propertyName": "type",
"mapping": mapping,
}
return nil
}
func (p Part) MarshalJSON() ([]byte, error) {
return json.Marshal(p.Value)
}
func (p *Part) UnmarshalJSON(data []byte) error {
var head struct {
Type PartType `json:"type"`
}
if err := json.Unmarshal(data, &head); err != nil {
return err
}
decode := decodePart[GenericPart]
for _, variant := range partVariants {
if variant.typ == head.Type {
decode = variant.decode
break
}
}
value, err := decode(data)
if err != nil {
return err
}
p.Value = value
return nil
}
type partVariant struct {
typ PartType
decode func(data []byte) (any, error)
schema any
schemaRef string
}
func decodePart[T any](data []byte) (any, error) {
var value T
if err := json.Unmarshal(data, &value); err != nil {
return nil, err
}
return value, nil
}

View File

@@ -0,0 +1,79 @@
package aiobservabilitytypes
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParts_RoundTrip(t *testing.T) {
id := "call_1"
mime := "image/png"
summary := "earlier turns"
testCases := []struct {
name string
json string
want Parts
}{
{
name: "EveryNamedType_DecodesToItsStruct",
json: `[{"type":"text","content":"hi"},` +
`{"type":"tool_call","name":"get_weather","id":"call_1","arguments":{"city":"Paris"}},` +
`{"type":"tool_call_response","id":"call_1","response":"rainy"},` +
`{"type":"server_tool_call","name":"code_interpreter","id":"call_1","server_tool_call":{"type":"code_interpreter","code":"1+1"}},` +
`{"type":"server_tool_call_response","id":"call_1","server_tool_call_response":{"type":"code_interpreter","outputs":[]}},` +
`{"type":"blob","modality":"image","content":"aGk=","mime_type":"image/png"},` +
`{"type":"file","modality":"image","file_id":"file_1"},` +
`{"type":"uri","modality":"image","uri":"gs://b/x.png","mime_type":"image/png"},` +
`{"type":"reasoning","content":"thinking"},` +
`{"type":"compaction","id":"call_1","content":"earlier turns"}]`,
want: Parts{
{Value: TextPart{Type: PartTypeText, Content: "hi"}},
{Value: ToolCallRequestPart{Type: PartTypeToolCall, Name: "get_weather", ID: &id, Arguments: map[string]any{"city": "Paris"}}},
{Value: ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: &id, Response: "rainy"}},
{Value: ServerToolCallPart{Type: PartTypeServerToolCall, Name: "code_interpreter", ID: &id, ServerToolCall: GenericServerToolCall{"type": "code_interpreter", "code": "1+1"}}},
{Value: ServerToolCallResponsePart{Type: PartTypeServerToolCallResponse, ID: &id, ServerToolCallResponse: GenericServerToolCallResponse{"type": "code_interpreter", "outputs": []any{}}}},
{Value: BlobPart{Type: PartTypeBlob, Modality: ModalityImage, Content: "aGk=", MimeType: &mime}},
{Value: FilePart{Type: PartTypeFile, Modality: ModalityImage, FileID: "file_1"}},
{Value: UriPart{Type: PartTypeURI, Modality: ModalityImage, URI: "gs://b/x.png", MimeType: &mime}},
{Value: ReasoningPart{Type: PartTypeReasoning, Content: "thinking"}},
{Value: CompactionPart{Type: PartTypeCompaction, ID: &id, Content: &summary}},
},
},
{
name: "MissingRequiredKey_StillDecodesToItsStruct",
json: `[{"type":"file","file_id":"file_1"}]`,
want: Parts{{Value: FilePart{Type: PartTypeFile, FileID: "file_1"}}},
},
{
name: "UnknownKeys_Dropped",
json: `[{"type":"reasoning","content":"","signature":"sig"}]`,
want: Parts{{Value: ReasoningPart{Type: PartTypeReasoning}}},
},
{
name: "UnknownType_DecodesToGenericMap",
json: `[{"type":"refusal","refusal":"no"}]`,
want: Parts{{Value: GenericPart{"type": "refusal", "refusal": "no"}}},
},
{
name: "MissingType_DecodesToGenericMap",
json: `[{"text":"bare"}]`,
want: Parts{{Value: GenericPart{"text": "bare"}}},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
var parts Parts
require.NoError(t, json.Unmarshal([]byte(testCase.json), &parts))
assert.Equal(t, testCase.want, parts)
encoded, err := json.Marshal(parts)
require.NoError(t, err)
var again Parts
require.NoError(t, json.Unmarshal(encoded, &again))
assert.Equal(t, parts, again)
})
}
}

View File

@@ -122,9 +122,6 @@ type PipelineOperator struct {
EnablePaths bool `json:"enable_paths,omitempty" yaml:"enable_paths,omitempty"`
PathPrefix string `json:"path_prefix,omitempty" yaml:"path_prefix,omitempty"`
// normalize fields, set by the server only
JSONBodyDualIngestion bool `json:"-" yaml:"json_body_dual_ingestion,omitempty"`
// Used in Severity Parsing and JSON Flattening mapping
Mapping map[string][]string `json:"mapping,omitempty" yaml:"mapping,omitempty"`
// severity parser fields

View File

@@ -4,8 +4,10 @@ import (
"encoding/base64"
"encoding/json"
"maps"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
)
const (
@@ -76,6 +78,12 @@ type ThreadSpan struct {
Attributes map[string]any `json:"attributes" required:"true" nullable:"false"`
Events []Event `json:"events" required:"true" nullable:"false"`
References []OtelSpanRef `json:"references" required:"true" nullable:"false"`
// The formatted fields hold the span's messages in the OTel GenAI shape; Formatter names the
// converter that produced them and FormatterWarnings what it could not resolve.
FormattedInput aiobservabilitytypes.InputMessages `json:"formatted_input" required:"true" nullable:"false"`
FormattedOutput aiobservabilitytypes.OutputMessages `json:"formatted_output" required:"true" nullable:"false"`
Formatter string `json:"formatter" required:"true"`
FormatterWarnings []string `json:"formatter_warnings" required:"true" nullable:"false"`
timeUnixNano uint64
}
@@ -171,32 +179,55 @@ func newThreadSpan(traceID string, storable *StorableSpan) *ThreadSpan {
resources := make(map[string]string, len(storable.ResourcesString))
maps.Copy(resources, storable.ResourcesString)
timeUnixNano := uint64(storable.StartTime.UnixNano())
attributes := threadAttributes(storable)
formatted := aiobservabilitytypes.FormatMessages(rawAttribute(storable, aiobservabilitytypes.GenAIInputMessages), rawAttribute(storable, aiobservabilitytypes.GenAIOutputMessages))
return &ThreadSpan{
SpanID: storable.SpanID,
TraceID: traceID,
ParentSpanID: storable.ParentSpanID,
Name: storable.Name,
KindString: storable.SpanKind,
TimeUnix: timeUnixNano / 1_000_000, // client expects millis, as in the waterfall
DurationNano: storable.DurationNano,
HasError: storable.HasError,
StatusCodeString: storable.StatusCodeString,
StatusMessage: storable.StatusMessage,
Resource: resources,
Attributes: threadAttributes(storable),
Events: storable.UnmarshalledEvents(),
References: storable.UnmarshalledRefs(),
timeUnixNano: timeUnixNano,
SpanID: storable.SpanID,
TraceID: traceID,
ParentSpanID: storable.ParentSpanID,
Name: storable.Name,
KindString: storable.SpanKind,
TimeUnix: timeUnixNano / 1_000_000, // client expects millis, as in the waterfall
DurationNano: storable.DurationNano,
HasError: storable.HasError,
StatusCodeString: storable.StatusCodeString,
StatusMessage: storable.StatusMessage,
Resource: resources,
Attributes: attributes,
Events: storable.UnmarshalledEvents(),
References: storable.UnmarshalledRefs(),
FormattedInput: formatted.Input,
FormattedOutput: formatted.Output,
Formatter: formatted.Formatter,
FormatterWarnings: formatted.Warnings,
timeUnixNano: timeUnixNano,
}
}
// threadAttributes reads the JSON column and falls back to the legacy maps for spans written
// before the JSON rollout.
// threadAttributes flattens the JSON column into dotted keys, as the querier does for list
// responses, and falls back to the legacy maps for spans written before the JSON rollout.
func threadAttributes(storable *StorableSpan) map[string]any {
if len(storable.AttributesJSON) > 0 {
attributes := make(map[string]any, len(storable.AttributesJSON))
storable.AttributesJSON.FlattenInto("", attributes)
return attributes
if len(storable.AttributesJSON) == 0 {
return storable.Attributes()
}
return storable.Attributes()
attributes := make(map[string]any, len(storable.AttributesJSON))
storable.AttributesJSON.FlattenInto("", attributes)
return attributes
}
// rawAttribute reads one attribute for decoding from the JSON document, where an object value is
// still whole. The legacy maps hold objects split into one key per field, so spans from before
// the JSON rollout are not decoded. Not handled: a list of JSON strings, which formats as generic,
// and a scalar at a prefix of the path, such as gen_ai.input beside gen_ai.input.messages, which
// ends the walk early.
func rawAttribute(storable *StorableSpan, key string) any {
var current any = map[string]any(storable.AttributesJSON)
for segment := range strings.SplitSeq(key, ".") {
object, ok := current.(map[string]any)
if !ok {
return nil
}
current = object[segment]
}
return current
}

View File

@@ -73,3 +73,35 @@ func TestThreadAttributes(t *testing.T) {
})
}
}
func TestRawAttribute(t *testing.T) {
arguments := map[string]any{"city": "Paris", "days": []any{1.0, 2.0}}
testCases := []struct {
name string
storable StorableSpan
key string
want any
}{
{
name: "JSONColumn_ObjectReturnedWhole",
storable: StorableSpan{AttributesJSON: telemetrystoretypes.JSONValue{"gen_ai": map[string]any{"tool": map[string]any{"call": map[string]any{"arguments": arguments}}}}},
key: "gen_ai.tool.call.arguments",
want: arguments,
},
{
name: "JSONColumn_MissingPath_Nil",
storable: StorableSpan{AttributesJSON: telemetrystoretypes.JSONValue{"gen_ai": map[string]any{"tool": map[string]any{"name": "get_weather"}}}},
key: "gen_ai.tool.call.arguments",
},
{
name: "LegacyMapsOnly_Nil",
storable: StorableSpan{AttributesString: map[string]string{"gen_ai.output.messages": "sunny", "gen_ai.tool.call.arguments.city": "Paris"}},
key: "gen_ai.output.messages",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, rawAttribute(&testCase.storable, testCase.key))
})
}
}

View File

@@ -22,7 +22,6 @@ from fixtures.notification_channel import (
wait_for_alertmanager_sync,
)
METRICS_DATA = "ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
METRICS_RULE = "ruler/test_scenarios/threshold_above_at_least_once/rule.json"
LOGS_DATA = "ruler/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"

View File

@@ -23,7 +23,6 @@ from fixtures.notification_channel import (
wait_for_alertmanager_sync,
)
METRICS_DATA = "ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
METRICS_RULE = "ruler/test_scenarios/threshold_above_at_least_once/rule.json"
LOGS_DATA = "ruler/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"

View File

@@ -24,7 +24,6 @@ from fixtures.notification_channel import (
wait_for_alertmanager_sync,
)
METRICS_DATA = "ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
METRICS_RULE = "ruler/test_scenarios/threshold_above_at_least_once/rule.json"
LOGS_DATA = "ruler/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"

View File

@@ -30,7 +30,6 @@ from fixtures.notification_channel import (
wait_for_alertmanager_sync,
)
# channel test (POST /api/v1/channels/test) drives the notifier once, synchronously,
# with a hardcoded firing test alert and no retry. The search stub decides which
# branch runs (create / update / reopen), so the whole issue lifecycle is

View File

@@ -25,7 +25,6 @@ from fixtures.notification_channel import (
wait_for_alertmanager_sync,
)
METRICS_DATA = "ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
METRICS_RULE = "ruler/test_scenarios/threshold_above_at_least_once/rule.json"
LOGS_DATA = "ruler/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"

View File

@@ -24,7 +24,6 @@ from fixtures.notification_channel import (
wait_for_alertmanager_sync,
)
# channel test (POST /api/v1/channels/test) drives the notifier once, synchronously,
# with a hardcoded firing test alert and no retry: create alert on the JSM Ops
# gateway, then append a timeline note. Default-template events + retry are in

View File

@@ -21,7 +21,7 @@ def test_thread_returns_message_spans_in_order(
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
now = datetime.now(tz=UTC).replace(microsecond=0)
trace_id = TraceIdGenerator.trace_id()
root_id, first_llm_id, tool_id, second_llm_id, third_llm_id = (TraceIdGenerator.span_id() for _ in range(5))
root_id, first_llm_id, tool_id, agent_id, second_llm_id, third_llm_id = (TraceIdGenerator.span_id() for _ in range(6))
resources = {"service.name": "tracedetail-thread"}
first_input = json.dumps([{"role": "user", "parts": [{"type": "text", "content": "weather in Bangalore?"}]}])
first_output = json.dumps([{"role": "assistant", "parts": [{"type": "tool_call", "id": "call_1", "name": "get_weather", "arguments": {"city": "Bangalore"}}], "finish_reason": "tool_call"}])
@@ -33,7 +33,17 @@ def test_thread_returns_message_spans_in_order(
Traces(
timestamp=now - timedelta(seconds=8), trace_id=trace_id, span_id=first_llm_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": first_input, "gen_ai.output.messages": first_output}, attribute_write_mode="json_only"
),
Traces(timestamp=now - timedelta(seconds=6), trace_id=trace_id, span_id=tool_id, parent_span_id=root_id, name="execute_tool get_weather", resources=resources, attributes={"gen_ai.tool.name": "get_weather"}, attribute_write_mode="json_only"),
Traces(
timestamp=now - timedelta(seconds=6),
trace_id=trace_id,
span_id=tool_id,
parent_span_id=root_id,
name="execute_tool get_weather",
resources=resources,
attributes={"gen_ai.tool.name": "get_weather", "gen_ai.tool.call.id": "call_1", "gen_ai.tool.call.arguments": json.dumps({"city": "Bangalore"}), "gen_ai.tool.call.result": "sunny"},
attribute_write_mode="json_only",
),
Traces(timestamp=now - timedelta(seconds=5), trace_id=trace_id, span_id=agent_id, parent_span_id=root_id, name="invoke_agent planner", resources=resources, attributes={"gen_ai.agent.name": "planner"}, attribute_write_mode="json_only"),
Traces(timestamp=now - timedelta(seconds=4), trace_id=trace_id, span_id=second_llm_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": second_input}, attribute_write_mode="json_only"),
Traces(timestamp=now - timedelta(seconds=2), trace_id=trace_id, span_id=third_llm_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.output.messages": "It is sunny in Bangalore."}, attribute_write_mode="json_only"),
]
@@ -44,10 +54,12 @@ def test_thread_returns_message_spans_in_order(
assert response.status_code == HTTPStatus.OK, response.text
thread = response.json()["data"]
assert [span["span_id"] for span in thread["spans"]] == [first_llm_id, second_llm_id, third_llm_id]
assert [span["span_id"] for span in thread["spans"]] == [first_llm_id, tool_id, second_llm_id, third_llm_id]
assert "nextCursor" not in thread
first, input_only, output_only = thread["spans"]
first, tool, input_only, output_only = thread["spans"]
assert tool["attributes"]["gen_ai.tool.call.arguments"] == json.dumps({"city": "Bangalore"})
assert tool["attributes"]["gen_ai.tool.call.result"] == "sunny"
assert first["time_unix"] == int((now - timedelta(seconds=8)).timestamp() * 1000)
assert first["attributes"]["gen_ai.input.messages"] == first_input
assert first["attributes"]["gen_ai.request.model"] == "gpt-4o"
@@ -57,6 +69,23 @@ def test_thread_returns_message_spans_in_order(
assert "gen_ai.input.messages" not in output_only["attributes"]
assert output_only["attributes"]["gen_ai.output.messages"] == "It is sunny in Bangalore."
tool_call = {"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Bangalore"}}
assert first["formatter"] == "semconv"
assert first["formatted_input"] == [{"role": "user", "parts": [{"type": "text", "content": "weather in Bangalore?"}]}]
assert first["formatted_output"] == [{"role": "assistant", "parts": [tool_call], "finish_reason": "tool_call"}]
assert first["formatter_warnings"] == []
# tool spans carry no messages until their converter lands
assert tool["formatter"] == ""
assert tool["formatted_input"] == []
assert tool["formatted_output"] == []
assert input_only["formatter"] == "openai.chat"
assert input_only["formatted_input"] == [{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_1", "response": "sunny"}]}]
assert input_only["formatted_output"] == []
assert output_only["formatter"] == "text"
assert output_only["formatted_input"] == []
assert output_only["formatted_output"] == [{"role": "assistant", "parts": [{"type": "text", "content": "It is sunny in Bangalore."}]}]
assert output_only["formatter_warnings"] == ["bare assistant text, role assumed"]
def test_thread_paginates_with_cursors(
signoz: types.SigNoz,
@@ -180,8 +209,8 @@ def test_thread_opens_around_span(
resources = {"service.name": "tracedetail-thread-anchor"}
root_id = TraceIdGenerator.span_id()
llm_ids = [TraceIdGenerator.span_id() for _ in range(5)]
tool_id = TraceIdGenerator.span_id()
# tool span sits between the third and fourth llm spans
agent_id = TraceIdGenerator.span_id()
# agent span, not a thread row, sits between the third and fourth llm spans
insert_traces(
[
Traces(timestamp=now - timedelta(seconds=20), duration=timedelta(seconds=19), trace_id=trace_id, span_id=root_id, name="POST /chat", kind=TracesKind.SPAN_KIND_SERVER, resources=resources, attribute_write_mode="json_only"),
@@ -189,7 +218,7 @@ def test_thread_opens_around_span(
Traces(timestamp=now - timedelta(seconds=18 - 3 * i), trace_id=trace_id, span_id=span_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
for i, span_id in enumerate(llm_ids)
),
Traces(timestamp=now - timedelta(seconds=11), trace_id=trace_id, span_id=tool_id, parent_span_id=root_id, name="execute_tool get_weather", resources=resources, attributes={"gen_ai.tool.name": "get_weather"}, attribute_write_mode="json_only"),
Traces(timestamp=now - timedelta(seconds=11), trace_id=trace_id, span_id=agent_id, parent_span_id=root_id, name="invoke_agent planner", resources=resources, attributes={"gen_ai.agent.name": "planner"}, attribute_write_mode="json_only"),
]
)
@@ -211,10 +240,10 @@ def test_thread_opens_around_span(
assert [span["span_id"] for span in get_page({"limit": 3, "after": around["nextCursor"]})["spans"]] == llm_ids[4:]
# span without messages: only its neighbours
around_tool = get_page({"limit": 2, "spanId": tool_id})
assert [span["span_id"] for span in around_tool["spans"]] == llm_ids[2:4]
assert around_tool["prevCursor"]
assert around_tool["nextCursor"]
around_agent = get_page({"limit": 2, "spanId": agent_id})
assert [span["span_id"] for span in around_agent["spans"]] == llm_ids[2:4]
assert around_agent["prevCursor"]
assert around_agent["nextCursor"]
# near the start, the short side gives its room to the other
at_start = get_page({"limit": 3, "spanId": llm_ids[0]})
@@ -259,7 +288,7 @@ def test_thread_reads_spans_across_json_rollout(
insert_traces(
[
Traces(timestamp=rollout - timedelta(minutes=10), trace_id=before_trace_id, span_id=before_ids[0], name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": "first"}])}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout - timedelta(minutes=8), trace_id=before_trace_id, span_id=TraceIdGenerator.span_id(), name="execute_tool get_weather", resources=resources, attributes={"gen_ai.tool.name": "get_weather"}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout - timedelta(minutes=8), trace_id=before_trace_id, span_id=TraceIdGenerator.span_id(), name="invoke_agent planner", resources=resources, attributes={"gen_ai.agent.name": "planner"}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout - timedelta(minutes=5), trace_id=before_trace_id, span_id=before_ids[1], name="chat gpt-4o", resources=resources, attributes={"gen_ai.output.messages": json.dumps([{"role": "assistant", "content": "second"}])}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout - timedelta(minutes=5), trace_id=straddle_trace_id, span_id=legacy_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": "legacy"}])}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout + timedelta(minutes=5), trace_id=straddle_trace_id, span_id=json_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": "json"}])}, attribute_write_mode="json_only"),
@@ -275,6 +304,11 @@ def test_thread_reads_spans_across_json_rollout(
assert [span["span_id"] for span in before_spans] == before_ids
assert before_spans[0]["attributes"]["gen_ai.input.messages"] == json.dumps([{"role": "user", "content": "first"}])
assert before_spans[1]["attributes"]["gen_ai.output.messages"] == json.dumps([{"role": "assistant", "content": "second"}])
# messages are decoded from the JSON column only, never from the legacy maps
for span in before_spans:
assert span["formatter"] == ""
assert span["formatted_input"] == []
assert span["formatted_output"] == []
straddle = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{straddle_trace_id}/thread"), headers=headers, timeout=10)
assert straddle.status_code == HTTPStatus.OK, straddle.text
@@ -282,6 +316,10 @@ def test_thread_reads_spans_across_json_rollout(
assert [span["span_id"] for span in straddle_spans] == [legacy_id, json_id]
assert straddle_spans[0]["attributes"]["gen_ai.input.messages"] == json.dumps([{"role": "user", "content": "legacy"}])
assert straddle_spans[1]["attributes"]["gen_ai.input.messages"] == json.dumps([{"role": "user", "content": "json"}])
assert straddle_spans[0]["formatter"] == ""
assert straddle_spans[0]["formatted_input"] == []
assert straddle_spans[1]["formatter"] == "openai.chat"
assert straddle_spans[1]["formatted_input"] == [{"role": "user", "parts": [{"type": "text", "content": "json"}]}]
def test_thread_without_messages_is_empty(