mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-14 06:00:40 +01:00
Compare commits
4 Commits
feat/qb-fi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da556abac1 | ||
|
|
cc8440248b | ||
|
|
e84ddfb61e | ||
|
|
dc9fda2916 |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -88,12 +88,10 @@ function HavingFilter({
|
||||
onClose,
|
||||
onChange,
|
||||
queryData,
|
||||
placeholder = 'Type Having query like count() > 10 ...',
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onChange: (value: string) => void;
|
||||
queryData: IBuilderQuery;
|
||||
placeholder?: string;
|
||||
}): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { getAggregationOptions } = useQueryBuilderV2Context();
|
||||
@@ -347,7 +345,7 @@ function HavingFilter({
|
||||
},
|
||||
]),
|
||||
]}
|
||||
placeholder={placeholder}
|
||||
placeholder="Type Having query like count() > 10 ..."
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
autocompletion: true,
|
||||
|
||||
@@ -343,29 +343,8 @@
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
// Not select search inputs: they would grow the select past 36px.
|
||||
.input-with-label input {
|
||||
input {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.input-with-label,
|
||||
.periscope-input-with-label {
|
||||
min-height: 36px;
|
||||
|
||||
.label {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
// `ghost` strips the border.
|
||||
.close-btn.periscope-btn {
|
||||
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
|
||||
background: var(--input-with-label-background-color, var(--l2-background));
|
||||
}
|
||||
}
|
||||
|
||||
.periscope-input-with-label .input .ant-select-selection-placeholder {
|
||||
color: var(--query-builder-v2-placeholder-color, var(--l3-foreground));
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
|
||||
import { OrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter';
|
||||
import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { get, isEmpty } from 'lodash-es';
|
||||
import { ChevronUp } from '@signozhq/icons';
|
||||
import {
|
||||
BarChart,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
Grid3X3,
|
||||
ScrollText,
|
||||
} from '@signozhq/icons';
|
||||
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
@@ -29,20 +34,162 @@ import {
|
||||
|
||||
import BucketOptions from './BucketOptions/BucketOptions';
|
||||
import HavingFilter from './HavingFilter/HavingFilter';
|
||||
import {
|
||||
ADD_ON_DEFINITIONS,
|
||||
BASE_ADD_ONS,
|
||||
BUCKET_OPTIONS,
|
||||
HISTOGRAM_ATTRIBUTE_TYPES,
|
||||
type InputAddOnField,
|
||||
REDUCE_TO,
|
||||
} from './constants';
|
||||
import TooltipContent from './TooltipContent';
|
||||
import type { AddOn } from './types';
|
||||
import { buildDefaultLegendFromGroupBy, hasValue } from './utils';
|
||||
import { buildDefaultLegendFromGroupBy } from './utils';
|
||||
|
||||
import './QueryAddOns.styles.scss';
|
||||
|
||||
interface AddOn {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
key: QueryBuilderField;
|
||||
description?: string;
|
||||
docLink?: string;
|
||||
}
|
||||
|
||||
/** Fields the add-on bar does not own: each has its own control elsewhere in the query. */
|
||||
type NonAddOnField =
|
||||
| QueryBuilderField.Aggregation
|
||||
| QueryBuilderField.StepInterval
|
||||
| QueryBuilderField.Functions
|
||||
| QueryBuilderField.Formula
|
||||
| QueryBuilderField.AdditionalQueries;
|
||||
|
||||
// Omit rather than Partial, so a field added to the enum has to be placed on one side.
|
||||
const ADD_ONS_KEYS_TO_QUERY_PATH: Omit<
|
||||
Record<QueryBuilderField, string>,
|
||||
NonAddOnField
|
||||
> = {
|
||||
[QueryBuilderField.GroupBy]: 'groupBy',
|
||||
[QueryBuilderField.Having]: 'having.expression',
|
||||
[QueryBuilderField.OrderBy]: 'orderBy',
|
||||
[QueryBuilderField.Limit]: 'limit',
|
||||
[QueryBuilderField.Legend]: 'legend',
|
||||
[QueryBuilderField.ReduceTo]: 'reduceTo',
|
||||
[QueryBuilderField.BucketOptions]: 'bucketOptions',
|
||||
};
|
||||
|
||||
const ADD_ONS: AddOn[] = [
|
||||
{
|
||||
icon: <BarChart size={14} />,
|
||||
label: 'Group By',
|
||||
key: QueryBuilderField.GroupBy,
|
||||
description:
|
||||
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
|
||||
docLink: 'https://signoz.io/docs/querying/aggregation-grouping/#grouping',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Having',
|
||||
key: QueryBuilderField.Having,
|
||||
description:
|
||||
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Order By',
|
||||
key: QueryBuilderField.OrderBy,
|
||||
description:
|
||||
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/result-manipulation/#sorting--limiting',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Limit',
|
||||
key: QueryBuilderField.Limit,
|
||||
description:
|
||||
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/result-manipulation/#how-limit-works-for-time-series',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Legend format',
|
||||
key: QueryBuilderField.Legend,
|
||||
description:
|
||||
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/aggregation-grouping/#legend-formatting',
|
||||
},
|
||||
];
|
||||
|
||||
const REDUCE_TO: AddOn = {
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Reduce to',
|
||||
key: QueryBuilderField.ReduceTo,
|
||||
description:
|
||||
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
|
||||
};
|
||||
|
||||
// Offered only by a heatmap over metrics: the bucket axis is what a heatmap plots
|
||||
// against, and no other panel type sends one. A histogram brings its own buckets.
|
||||
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
|
||||
ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
]);
|
||||
|
||||
const BUCKET_OPTIONS: AddOn = {
|
||||
icon: <Grid3X3 size={14} />,
|
||||
label: 'Bucket by',
|
||||
key: QueryBuilderField.BucketOptions,
|
||||
description:
|
||||
'Choose how the bucket axis is spaced — logarithmically, so every band is the same height and the tail stays readable, or linearly up to a max value. Left to Auto, the query picks the finest log axis it can return.',
|
||||
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/',
|
||||
};
|
||||
|
||||
const hasValue = (value: unknown): boolean =>
|
||||
value != null && value !== '' && !(Array.isArray(value) && value.length === 0);
|
||||
|
||||
// Custom tooltip content component
|
||||
function TooltipContent({
|
||||
label,
|
||||
description,
|
||||
docLink,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
docLink?: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
maxWidth: '300px',
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: '14px' }}>{label}</strong>
|
||||
{description && (
|
||||
<span style={{ fontSize: '12px', lineHeight: '1.5' }}>{description}</span>
|
||||
)}
|
||||
{docLink && (
|
||||
<a
|
||||
href={docLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
color: '#4096ff',
|
||||
fontSize: '12px',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueryAddOns({
|
||||
query,
|
||||
version,
|
||||
@@ -80,16 +227,12 @@ function QueryAddOns({
|
||||
let addOns: AddOn[];
|
||||
|
||||
if (panelType === PANEL_TYPES.VALUE) {
|
||||
addOns = BASE_ADD_ONS.filter(
|
||||
(addOn) => addOn.key === QueryBuilderField.Legend,
|
||||
);
|
||||
addOns = ADD_ONS.filter((addOn) => addOn.key === QueryBuilderField.Legend);
|
||||
} else if (query.dataSource === DataSource.METRICS) {
|
||||
// Group by for metrics is offered by MetricsAggregateSection instead.
|
||||
addOns = BASE_ADD_ONS.filter(
|
||||
(addOn) => addOn.key !== QueryBuilderField.GroupBy,
|
||||
);
|
||||
addOns = ADD_ONS.filter((addOn) => addOn.key !== QueryBuilderField.GroupBy);
|
||||
} else {
|
||||
addOns = [...BASE_ADD_ONS];
|
||||
addOns = [...ADD_ONS];
|
||||
}
|
||||
|
||||
if (showReduceTo) {
|
||||
@@ -145,12 +288,6 @@ function QueryAddOns({
|
||||
[resolvedFields],
|
||||
);
|
||||
|
||||
const placeholderOf = useCallback(
|
||||
(key: InputAddOnField): string =>
|
||||
resolvedFields.get(key)?.placeholder ?? ADD_ON_DEFINITIONS[key].placeholder,
|
||||
[resolvedFields],
|
||||
);
|
||||
|
||||
const isDisabled = useCallback(
|
||||
(key: QueryBuilderField): boolean =>
|
||||
Boolean(resolvedFields.get(key)?.disabled),
|
||||
@@ -169,6 +306,12 @@ function QueryAddOns({
|
||||
if (!initializedRef.current || hasAvailabilityItemsChanged) {
|
||||
initializedRef.current = true;
|
||||
|
||||
const activeAddOnKeys = new Set(
|
||||
Object.entries(ADD_ONS_KEYS_TO_QUERY_PATH)
|
||||
.filter(([, path]) => hasValue(get(query, path)))
|
||||
.map(([key]) => key as QueryBuilderField),
|
||||
);
|
||||
|
||||
// Initial seeding from query values on mount. A disabled field never opens.
|
||||
setSelectedViews(
|
||||
offeredAddOns.filter((addOn) => {
|
||||
@@ -176,7 +319,7 @@ function QueryAddOns({
|
||||
|
||||
return (
|
||||
resolved?.pinned ||
|
||||
(hasValue(get(query, addOn.queryPath)) && !resolved?.disabled)
|
||||
(activeAddOnKeys.has(addOn.key) && !resolved?.disabled)
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -305,7 +448,7 @@ function QueryAddOns({
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.GroupBy) && (
|
||||
<div className="add-on-content" data-testid="group-by-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<TooltipSimple
|
||||
<Tooltip
|
||||
title={
|
||||
<TooltipContent
|
||||
label="Group By"
|
||||
@@ -313,13 +456,13 @@ function QueryAddOns({
|
||||
docLink="https://signoz.io/docs/querying/aggregation-grouping/#grouping"
|
||||
/>
|
||||
}
|
||||
arrow
|
||||
delayDuration={500}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<div className="label" style={{ cursor: 'help' }}>
|
||||
Group By
|
||||
</div>
|
||||
</TooltipSimple>
|
||||
</Tooltip>
|
||||
<div className="input">
|
||||
<GroupByFilter
|
||||
disabled={
|
||||
@@ -328,7 +471,6 @@ function QueryAddOns({
|
||||
}
|
||||
query={query}
|
||||
onChange={handleChangeGroupByKeys}
|
||||
placeholder={placeholderOf(QueryBuilderField.GroupBy)}
|
||||
/>
|
||||
</div>
|
||||
{!isPinned(QueryBuilderField.GroupBy) && (
|
||||
@@ -344,7 +486,7 @@ function QueryAddOns({
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.Having) && (
|
||||
<div className="add-on-content" data-testid="having-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<TooltipSimple
|
||||
<Tooltip
|
||||
title={
|
||||
<TooltipContent
|
||||
label="Having"
|
||||
@@ -352,19 +494,18 @@ function QueryAddOns({
|
||||
docLink="https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having"
|
||||
/>
|
||||
}
|
||||
arrow
|
||||
delayDuration={500}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<div className="label" style={{ cursor: 'help' }}>
|
||||
Having
|
||||
</div>
|
||||
</TooltipSimple>
|
||||
</Tooltip>
|
||||
<div className="input">
|
||||
<HavingFilter
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Having)}
|
||||
onChange={handleChangeHaving}
|
||||
queryData={query}
|
||||
placeholder={placeholderOf(QueryBuilderField.Having)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -377,7 +518,7 @@ function QueryAddOns({
|
||||
type="number"
|
||||
onChange={handleChangeLimit}
|
||||
initialValue={query?.limit ?? undefined}
|
||||
placeholder={placeholderOf(QueryBuilderField.Limit)}
|
||||
placeholder="Enter limit"
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}
|
||||
closeIcon={<ChevronUp size={16} />}
|
||||
/>
|
||||
@@ -386,7 +527,7 @@ function QueryAddOns({
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.OrderBy) && (
|
||||
<div className="add-on-content" data-testid="order-by-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<TooltipSimple
|
||||
<Tooltip
|
||||
title={
|
||||
<TooltipContent
|
||||
label="Order By"
|
||||
@@ -394,13 +535,13 @@ function QueryAddOns({
|
||||
docLink="https://signoz.io/docs/querying/result-manipulation/#sorting--limiting"
|
||||
/>
|
||||
}
|
||||
arrow
|
||||
delayDuration={500}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<div className="label" style={{ cursor: 'help' }}>
|
||||
Order By
|
||||
</div>
|
||||
</TooltipSimple>
|
||||
</Tooltip>
|
||||
<div className="input">
|
||||
<OrderByFilter
|
||||
entityVersion={version}
|
||||
@@ -408,7 +549,6 @@ function QueryAddOns({
|
||||
onChange={handleChangeOrderByKeys}
|
||||
isRawQuery={isRawQuery}
|
||||
isNewQueryV2
|
||||
placeholder={placeholderOf(QueryBuilderField.OrderBy)}
|
||||
/>
|
||||
</div>
|
||||
{!isPinned(QueryBuilderField.OrderBy) && (
|
||||
@@ -426,7 +566,7 @@ function QueryAddOns({
|
||||
showReduceTo && (
|
||||
<div className="add-on-content" data-testid="reduce-to-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<TooltipSimple
|
||||
<Tooltip
|
||||
title={
|
||||
<TooltipContent
|
||||
label="Reduce to"
|
||||
@@ -434,13 +574,13 @@ function QueryAddOns({
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation"
|
||||
/>
|
||||
}
|
||||
arrow
|
||||
delayDuration={500}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<div className="label" style={{ cursor: 'help' }}>
|
||||
Reduce to
|
||||
</div>
|
||||
</TooltipSimple>
|
||||
</Tooltip>
|
||||
<div className="input">
|
||||
<ReduceToFilter query={query} onChange={handleChangeReduceToV5} />
|
||||
</div>
|
||||
@@ -460,7 +600,7 @@ function QueryAddOns({
|
||||
<div className="add-on-content" data-testid="legend-format-content">
|
||||
<InputWithLabel
|
||||
label="Legend format"
|
||||
placeholder={placeholderOf(QueryBuilderField.Legend)}
|
||||
placeholder="Write legend format"
|
||||
onChange={handleChangeQueryLegend}
|
||||
initialValue={isEmpty(query?.legend) ? undefined : query?.legend}
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Legend)}
|
||||
@@ -507,7 +647,7 @@ function QueryAddOns({
|
||||
return {
|
||||
value: addOn.key,
|
||||
label: (
|
||||
<TooltipSimple
|
||||
<Tooltip
|
||||
title={
|
||||
<TooltipContent
|
||||
label={addOn.label}
|
||||
@@ -515,8 +655,8 @@ function QueryAddOns({
|
||||
docLink={resolved?.disabled ? undefined : addOn.docLink}
|
||||
/>
|
||||
}
|
||||
arrow
|
||||
delayDuration={500}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<span
|
||||
className={cx('add-on-tab-title', {
|
||||
@@ -528,7 +668,7 @@ function QueryAddOns({
|
||||
{addOn.icon}
|
||||
{addOn.label}
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
</Tooltip>
|
||||
),
|
||||
};
|
||||
})}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-normal);
|
||||
}
|
||||
|
||||
.docLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
margin-top: var(--spacing-2);
|
||||
color: var(--accent-primary);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { ExternalLink } from '@signozhq/icons';
|
||||
|
||||
import styles from './TooltipContent.module.scss';
|
||||
|
||||
// Custom tooltip content component
|
||||
export default function TooltipContent({
|
||||
label,
|
||||
description,
|
||||
docLink,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
docLink?: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<strong className={styles.label}>{label}</strong>
|
||||
{description && <span className={styles.description}>{description}</span>}
|
||||
{docLink && (
|
||||
<a
|
||||
href={docLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
className={styles.docLink}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { BarChart, Grid3X3, ScrollText } from '@signozhq/icons';
|
||||
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import { QueryBuilderField } from '../../queryBuilderFields.types';
|
||||
|
||||
import type { AddOn, AddOnDefinition, AddOnField } from './types';
|
||||
|
||||
// A Record, so a field added to the enum has to be placed here or in `NonAddOnField`.
|
||||
export const ADD_ON_DEFINITIONS = {
|
||||
[QueryBuilderField.GroupBy]: {
|
||||
input: true,
|
||||
icon: <BarChart size={14} />,
|
||||
label: 'Group By',
|
||||
queryPath: 'groupBy',
|
||||
placeholder: 'Everything (no breakdown)',
|
||||
description:
|
||||
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
|
||||
docLink: 'https://signoz.io/docs/querying/aggregation-grouping/#grouping',
|
||||
},
|
||||
[QueryBuilderField.Having]: {
|
||||
input: true,
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Having',
|
||||
queryPath: 'having.expression',
|
||||
placeholder: 'Type Having query like count() > 10 ...',
|
||||
description:
|
||||
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having',
|
||||
},
|
||||
[QueryBuilderField.OrderBy]: {
|
||||
input: true,
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Order By',
|
||||
queryPath: 'orderBy',
|
||||
placeholder: 'Select fields to order by',
|
||||
description:
|
||||
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/result-manipulation/#sorting--limiting',
|
||||
},
|
||||
[QueryBuilderField.Limit]: {
|
||||
input: true,
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Limit',
|
||||
queryPath: 'limit',
|
||||
placeholder: 'Enter limit',
|
||||
description:
|
||||
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/result-manipulation/#how-limit-works-for-time-series',
|
||||
},
|
||||
[QueryBuilderField.Legend]: {
|
||||
input: true,
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Legend format',
|
||||
queryPath: 'legend',
|
||||
placeholder: 'Write legend format',
|
||||
description:
|
||||
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/aggregation-grouping/#legend-formatting',
|
||||
},
|
||||
[QueryBuilderField.ReduceTo]: {
|
||||
input: false,
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Reduce to',
|
||||
queryPath: 'reduceTo',
|
||||
description:
|
||||
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
|
||||
},
|
||||
[QueryBuilderField.BucketOptions]: {
|
||||
input: false,
|
||||
icon: <Grid3X3 size={14} />,
|
||||
label: 'Bucket by',
|
||||
queryPath: 'bucketOptions',
|
||||
description:
|
||||
'Choose how the bucket axis is spaced — logarithmically, so every band is the same height and the tail stays readable, or linearly up to a max value. Left to Auto, the query picks the finest log axis it can return.',
|
||||
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/',
|
||||
},
|
||||
} as const satisfies Record<AddOnField, AddOnDefinition>;
|
||||
|
||||
export type InputAddOnField = {
|
||||
[K in AddOnField]: (typeof ADD_ON_DEFINITIONS)[K] extends { input: true }
|
||||
? K
|
||||
: never;
|
||||
}[AddOnField];
|
||||
|
||||
const toAddOn = (key: AddOnField): AddOn => ({
|
||||
key,
|
||||
...ADD_ON_DEFINITIONS[key],
|
||||
});
|
||||
|
||||
export const BASE_ADD_ONS = (
|
||||
[
|
||||
QueryBuilderField.GroupBy,
|
||||
QueryBuilderField.Having,
|
||||
QueryBuilderField.OrderBy,
|
||||
QueryBuilderField.Limit,
|
||||
QueryBuilderField.Legend,
|
||||
] as const satisfies readonly AddOnField[]
|
||||
).map(toAddOn);
|
||||
|
||||
export const REDUCE_TO = toAddOn(QueryBuilderField.ReduceTo);
|
||||
|
||||
// Offered only by a heatmap over metrics: the bucket axis is what a heatmap plots
|
||||
// against, and no other panel type sends one. A histogram brings its own buckets.
|
||||
export const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
|
||||
ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
]);
|
||||
|
||||
export const BUCKET_OPTIONS = toAddOn(QueryBuilderField.BucketOptions);
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { QueryBuilderField } from '../../queryBuilderFields.types';
|
||||
|
||||
/** Fields the add-on bar does not own: each has its own control elsewhere in the query. */
|
||||
export type NonAddOnField =
|
||||
| QueryBuilderField.Aggregation
|
||||
| QueryBuilderField.StepInterval
|
||||
| QueryBuilderField.Functions
|
||||
| QueryBuilderField.Formula
|
||||
| QueryBuilderField.AdditionalQueries;
|
||||
|
||||
export type AddOnField = Exclude<QueryBuilderField, NonAddOnField>;
|
||||
|
||||
export interface AddOnSpec {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
/** Where the add-on's value lives on the query; a value there opens it on mount. */
|
||||
queryPath: string;
|
||||
description?: string;
|
||||
docLink?: string;
|
||||
}
|
||||
|
||||
/** A text field has to name what leaving it empty means. */
|
||||
export type AddOnDefinition =
|
||||
| (AddOnSpec & { input: true; placeholder: string })
|
||||
| (AddOnSpec & { input: false });
|
||||
|
||||
export type AddOn = AddOnDefinition & { key: AddOnField };
|
||||
@@ -14,6 +14,3 @@ export const buildDefaultLegendFromGroupBy = (
|
||||
|
||||
return segments.join(', ');
|
||||
};
|
||||
|
||||
export const hasValue = (value: unknown): boolean =>
|
||||
value != null && value !== '' && !(Array.isArray(value) && value.length === 0);
|
||||
|
||||
@@ -12,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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
QueryBuilderField,
|
||||
type SyncedQueryBuilderField,
|
||||
} from '../queryBuilderFields.types';
|
||||
import {
|
||||
snapshotQueries,
|
||||
syncQueryBuilderFields,
|
||||
} from '../syncQueryBuilderFields';
|
||||
|
||||
const key = (name: string): BaseAutocompleteData =>
|
||||
({ key: name }) as BaseAutocompleteData;
|
||||
|
||||
function query(
|
||||
queryName: string,
|
||||
{
|
||||
groupBy = [],
|
||||
limit = null,
|
||||
dataSource = 'traces',
|
||||
}: { groupBy?: string[]; limit?: number | null; dataSource?: string } = {},
|
||||
): IBuilderQuery {
|
||||
return {
|
||||
queryName,
|
||||
groupBy: groupBy.map(key),
|
||||
limit,
|
||||
dataSource,
|
||||
} as IBuilderQuery;
|
||||
}
|
||||
|
||||
const GROUP_BY: SyncedQueryBuilderField[] = [QueryBuilderField.GroupBy];
|
||||
|
||||
describe('syncQueryBuilderFields', () => {
|
||||
it('copies an edited field to the other queries', () => {
|
||||
const previous = snapshotQueries([query('A'), query('B')]);
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(
|
||||
[query('A', { groupBy: ['service.name'] }), query('B')],
|
||||
previous,
|
||||
GROUP_BY,
|
||||
),
|
||||
).toStrictEqual([
|
||||
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives a new query the existing values', () => {
|
||||
const previous = snapshotQueries([query('A', { groupBy: ['service.name'] })]);
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(
|
||||
[query('A', { groupBy: ['service.name'] }), query('B')],
|
||||
previous,
|
||||
GROUP_BY,
|
||||
),
|
||||
).toStrictEqual([
|
||||
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives a query switched to another signal the others' values, not its reset ones", () => {
|
||||
const previous = snapshotQueries([
|
||||
query('A', { groupBy: ['service.name'] }),
|
||||
query('B', { groupBy: ['service.name'], dataSource: 'metrics' }),
|
||||
]);
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(
|
||||
[query('A', { groupBy: ['service.name'] }), query('B')],
|
||||
previous,
|
||||
GROUP_BY,
|
||||
),
|
||||
).toStrictEqual([
|
||||
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves values that already differ until one is edited', () => {
|
||||
const queries = [query('A', { groupBy: ['host.name'] }), query('B')];
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(queries, snapshotQueries(queries), GROUP_BY),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('gives a new query the first value without touching differing ones', () => {
|
||||
const saved = [
|
||||
query('A', { groupBy: ['service.name'] }),
|
||||
query('B', { groupBy: ['host.name'] }),
|
||||
];
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(
|
||||
[...saved, query('C')],
|
||||
snapshotQueries(saved),
|
||||
GROUP_BY,
|
||||
),
|
||||
).toStrictEqual([
|
||||
{ index: 2, query: query('C', { groupBy: ['service.name'] }) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('copies an edit to every query even when one was added', () => {
|
||||
const saved = [
|
||||
query('A', { groupBy: ['service.name'] }),
|
||||
query('B', { groupBy: ['host.name'] }),
|
||||
];
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(
|
||||
[query('A', { groupBy: ['k8s.pod.name'] }), saved[1], query('C')],
|
||||
snapshotQueries(saved),
|
||||
GROUP_BY,
|
||||
),
|
||||
).toStrictEqual([
|
||||
{ index: 1, query: query('B', { groupBy: ['k8s.pod.name'] }) },
|
||||
{ index: 2, query: query('C', { groupBy: ['k8s.pod.name'] }) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('syncs each field from its own source', () => {
|
||||
const previous = snapshotQueries([query('A'), query('B')]);
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(
|
||||
[query('A', { groupBy: ['service.name'] }), query('B', { limit: 50 })],
|
||||
previous,
|
||||
[QueryBuilderField.GroupBy, QueryBuilderField.Limit],
|
||||
),
|
||||
).toStrictEqual([
|
||||
{ index: 0, query: query('A', { groupBy: ['service.name'], limit: 50 }) },
|
||||
{ index: 1, query: query('B', { groupBy: ['service.name'], limit: 50 }) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves fields it is not asked to sync', () => {
|
||||
const previous = snapshotQueries([query('A'), query('B')]);
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(
|
||||
[query('A', { limit: 50 }), query('B')],
|
||||
previous,
|
||||
GROUP_BY,
|
||||
),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
QueryBuilderField,
|
||||
type SyncedQueryBuilderField,
|
||||
} from '../queryBuilderFields.types';
|
||||
import { useSyncQueryBuilderFields } from '../useSyncQueryBuilderFields';
|
||||
|
||||
const mockHandleSetQueryData = jest.fn();
|
||||
let mockQueries: IBuilderQuery[] = [];
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => ({
|
||||
currentQuery: { builder: { queryData: mockQueries } },
|
||||
handleSetQueryData: mockHandleSetQueryData,
|
||||
}),
|
||||
}));
|
||||
|
||||
const query = (queryName: string, groupBy: string[]): IBuilderQuery =>
|
||||
({
|
||||
queryName,
|
||||
groupBy: groupBy.map((name) => ({ key: name }) as BaseAutocompleteData),
|
||||
dataSource: 'traces',
|
||||
}) as IBuilderQuery;
|
||||
|
||||
const GROUP_BY: SyncedQueryBuilderField[] = [QueryBuilderField.GroupBy];
|
||||
|
||||
function setup(
|
||||
fields: SyncedQueryBuilderField[] | undefined,
|
||||
initial: IBuilderQuery[],
|
||||
): (next: IBuilderQuery[]) => void {
|
||||
mockQueries = initial;
|
||||
const { rerender } = renderHook(() => useSyncQueryBuilderFields(fields));
|
||||
return (next): void => {
|
||||
mockQueries = next;
|
||||
rerender();
|
||||
};
|
||||
}
|
||||
|
||||
describe('useSyncQueryBuilderFields', () => {
|
||||
beforeEach(() => mockHandleSetQueryData.mockClear());
|
||||
|
||||
it('writes the synced value into the other queries', () => {
|
||||
const update = setup(GROUP_BY, [query('A', []), query('B', [])]);
|
||||
|
||||
update([query('A', ['service.name']), query('B', [])]);
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenCalledTimes(1);
|
||||
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
|
||||
1,
|
||||
query('B', ['service.name']),
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when the kind syncs no fields', () => {
|
||||
const update = setup(undefined, [query('A', []), query('B', [])]);
|
||||
|
||||
update([query('A', ['service.name']), query('B', [])]);
|
||||
|
||||
expect(mockHandleSetQueryData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -22,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' };
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
QueryBuilderField,
|
||||
type SyncedQueryBuilderField,
|
||||
} from './queryBuilderFields.types';
|
||||
|
||||
const QUERY_PROPERTY = {
|
||||
[QueryBuilderField.GroupBy]: 'groupBy',
|
||||
[QueryBuilderField.OrderBy]: 'orderBy',
|
||||
[QueryBuilderField.Having]: 'having',
|
||||
[QueryBuilderField.Limit]: 'limit',
|
||||
[QueryBuilderField.StepInterval]: 'stepInterval',
|
||||
[QueryBuilderField.ReduceTo]: 'reduceTo',
|
||||
} as const satisfies Record<SyncedQueryBuilderField, keyof IBuilderQuery>;
|
||||
|
||||
export function snapshotQueries(
|
||||
queries: IBuilderQuery[],
|
||||
): Map<string, IBuilderQuery> {
|
||||
return new Map(queries.map((query) => [query.queryName, query]));
|
||||
}
|
||||
|
||||
/**
|
||||
* The query each field should be copied from: the one whose value just changed
|
||||
* (to all), else a settled one (to newcomers only) when a query was added or
|
||||
* switched signal. A signal switch reloads that query's own state, so it is
|
||||
* never the source.
|
||||
*/
|
||||
function findSource(
|
||||
property: keyof IBuilderQuery,
|
||||
settled: IBuilderQuery[],
|
||||
hasNewcomer: boolean,
|
||||
previous: Map<string, IBuilderQuery>,
|
||||
): { source: IBuilderQuery; newcomersOnly: boolean } | undefined {
|
||||
const changed = settled.find(
|
||||
(query) =>
|
||||
!isEqual(previous.get(query.queryName)?.[property], query[property]),
|
||||
);
|
||||
if (changed) {
|
||||
return { source: changed, newcomersOnly: false };
|
||||
}
|
||||
return hasNewcomer && settled.length > 0
|
||||
? { source: settled[0], newcomersOnly: true }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function syncQueryBuilderFields(
|
||||
queries: IBuilderQuery[],
|
||||
previous: Map<string, IBuilderQuery>,
|
||||
fields: SyncedQueryBuilderField[],
|
||||
): { index: number; query: IBuilderQuery }[] {
|
||||
const settled = queries.filter((query) => {
|
||||
const before = previous.get(query.queryName);
|
||||
return (
|
||||
before?.dataSource === query.dataSource && before.source === query.source
|
||||
);
|
||||
});
|
||||
const hasNewcomer = settled.length < queries.length;
|
||||
const sources = fields
|
||||
.map((field) => QUERY_PROPERTY[field])
|
||||
.map((property) => ({
|
||||
property,
|
||||
found: findSource(property, settled, hasNewcomer, previous),
|
||||
}));
|
||||
|
||||
return queries.flatMap((query, index) => {
|
||||
const isSettled = settled.includes(query);
|
||||
const patch = sources.reduce<Partial<IBuilderQuery>>(
|
||||
(acc, { property, found }) =>
|
||||
found &&
|
||||
!(found.newcomersOnly && isSettled) &&
|
||||
!isEqual(query[property], found.source[property])
|
||||
? { ...acc, [property]: found.source[property] }
|
||||
: acc,
|
||||
{},
|
||||
);
|
||||
return Object.keys(patch).length > 0
|
||||
? [{ index, query: { ...query, ...patch } }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import type { SyncedQueryBuilderField } from './queryBuilderFields.types';
|
||||
import {
|
||||
snapshotQueries,
|
||||
syncQueryBuilderFields,
|
||||
} from './syncQueryBuilderFields';
|
||||
|
||||
const NO_FIELDS: SyncedQueryBuilderField[] = [];
|
||||
|
||||
/**
|
||||
* Keeps `fields` equal across builder queries: an edit to one query copies to
|
||||
* the rest, and a query added or switched to another signal takes the others'
|
||||
* values. Values that already differ when this starts are left alone until one
|
||||
* of them is edited.
|
||||
*/
|
||||
export function useSyncQueryBuilderFields(
|
||||
fields: SyncedQueryBuilderField[] = NO_FIELDS,
|
||||
): void {
|
||||
const { currentQuery, handleSetQueryData } = useQueryBuilder();
|
||||
const queries = currentQuery.builder.queryData;
|
||||
const previousRef = useRef<Map<string, IBuilderQuery>>();
|
||||
const enabled = fields.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const previous = previousRef.current;
|
||||
previousRef.current = enabled ? snapshotQueries(queries) : undefined;
|
||||
if (!enabled || !previous || queries.length < 2) {
|
||||
return;
|
||||
}
|
||||
syncQueryBuilderFields(queries, previous, fields).forEach(
|
||||
({ index, query }) => handleSetQueryData(index, query),
|
||||
);
|
||||
}, [enabled, fields, queries, handleSetQueryData]);
|
||||
}
|
||||
@@ -6,5 +6,4 @@ export type GroupByFilterProps = {
|
||||
onChange: (values: BaseAutocompleteData[]) => void;
|
||||
disabled: boolean;
|
||||
signalSource?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
@@ -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)' : ''}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ export type OrderByFilterProps = {
|
||||
isRawQuery?: boolean;
|
||||
entityVersion?: string;
|
||||
isNewQueryV2?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export type OrderByFilterValue = {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
background-color: var(--l1-background);
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import PromQLQueryContainer from 'container/QueryBuilder/rawQueryEditors/PromQL'
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSyncQueryBuilderFields } from 'components/QueryBuilderV2/useSyncQueryBuilderFields';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
@@ -73,7 +72,6 @@ function PanelEditorQueryBuilder({
|
||||
const isRawQuery = isRawRequest(panelDefinition.queryCapabilities);
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
useSyncQueryBuilderFields(panelDefinition.syncedQueryBuilderFields);
|
||||
|
||||
// Cleared here, not in QueryBuilderV2: builder panes unmount on every tab switch.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -20,9 +20,6 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: (): boolean => false }));
|
||||
jest.mock('components/QueryBuilderV2/useSyncQueryBuilderFields', () => ({
|
||||
useSyncQueryBuilderFields: jest.fn(),
|
||||
}));
|
||||
jest.mock('components/QueryBuilderV2/QueryBuilderV2', () => ({
|
||||
QueryBuilderV2: (props: unknown): null => {
|
||||
mockQueryBuilderV2(props);
|
||||
|
||||
@@ -32,11 +32,6 @@ export interface PanelQueryCapabilities {
|
||||
* carries no explicit limit" — an explicit limit means the user asked for a fixed set.
|
||||
*/
|
||||
serverPaginated: boolean;
|
||||
/**
|
||||
* `limit` for a lone builder query that sets none. Skipped with several queries,
|
||||
* where per-query limits can keep different groups and break the join.
|
||||
*/
|
||||
defaultRowLimit?: number;
|
||||
}
|
||||
|
||||
/** Raw rows rather than an aggregated result — the single source for "is this raw?". */
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { ChartLine } from '@signozhq/icons';
|
||||
import type { SectionConfig } from './sections';
|
||||
import type { AnyPanelInteractionProps } from './interactions';
|
||||
import type { PanelKind } from './panelKind';
|
||||
import type { SyncedQueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
|
||||
import type {
|
||||
PanelQueryCapabilities,
|
||||
QueryBuilderFieldsConfig,
|
||||
@@ -122,8 +121,6 @@ export interface QueryPanelDefinition<
|
||||
queryBuilderFields: QueryBuilderFieldsConfig;
|
||||
/** How this kind's query-range request is shaped (request type, paging, result formatting). */
|
||||
queryCapabilities: PanelQueryCapabilities;
|
||||
/** Builder fields kept equal across queries: an edit to one query copies to the rest. */
|
||||
syncedQueryBuilderFields?: SyncedQueryBuilderField[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -71,10 +71,6 @@ const LIST_PANEL_CAPABILITIES = {
|
||||
orderTiebreaker: true,
|
||||
serverPaginated: true,
|
||||
};
|
||||
const DEFAULT_LIMIT_CAPABILITIES = {
|
||||
...TABLE_CAPABILITIES,
|
||||
defaultRowLimit: 10_000,
|
||||
};
|
||||
|
||||
describe('requestType', () => {
|
||||
it.each([
|
||||
@@ -400,51 +396,6 @@ describe('buildQueryRangeRequest', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultRowLimit', () => {
|
||||
function limits(queries: DashboardtypesQueryDTO[]): unknown[] {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries,
|
||||
queryCapabilities: DEFAULT_LIMIT_CAPABILITIES,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
return (request.compositeQuery?.queries ?? []).map(
|
||||
(envelope) => (envelope.spec as { limit?: number }).limit,
|
||||
);
|
||||
}
|
||||
|
||||
it('limits a lone builder query', () => {
|
||||
expect(limits(bareBuilderQuery({ name: 'A' }))).toStrictEqual([10_000]);
|
||||
});
|
||||
|
||||
it('keeps a limit the user set', () => {
|
||||
expect(limits(bareBuilderQuery({ name: 'A', limit: 50 }))).toStrictEqual([
|
||||
50,
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves several queries unlimited so their join keeps every group', () => {
|
||||
expect(
|
||||
limits(
|
||||
compositeQuery([
|
||||
{ type: 'builder_query', spec: { name: 'A' } },
|
||||
{ type: 'builder_query', spec: { name: 'B' } },
|
||||
]),
|
||||
),
|
||||
).toStrictEqual([undefined, undefined]);
|
||||
});
|
||||
|
||||
it('leaves a lone non-builder query alone', () => {
|
||||
expect(
|
||||
limits(
|
||||
compositeQuery([
|
||||
{ type: 'clickhouse_sql', spec: { name: 'A', query: 'SELECT 1' } },
|
||||
]),
|
||||
),
|
||||
).toStrictEqual([undefined]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBucketedStepIntervalSeconds', () => {
|
||||
// V1 parity: getBarStepIntervalPoints in container/GridCardLayout/utils.ts
|
||||
it.each([
|
||||
|
||||
@@ -217,30 +217,6 @@ function withPagination(
|
||||
});
|
||||
}
|
||||
|
||||
function withDefaultRowLimit(
|
||||
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
|
||||
limit: number,
|
||||
): Querybuildertypesv5QueryEnvelopeDTO[] {
|
||||
const [envelope] = envelopes;
|
||||
if (
|
||||
envelopes.length !== 1 ||
|
||||
envelope.type !==
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query ||
|
||||
envelope.spec?.limit
|
||||
) {
|
||||
return envelopes;
|
||||
}
|
||||
return [
|
||||
{
|
||||
...envelope,
|
||||
spec: {
|
||||
...envelope.spec,
|
||||
limit,
|
||||
} as Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export interface BuildQueryRangeRequestArgs {
|
||||
queries: DashboardtypesQueryDTO[];
|
||||
/**
|
||||
@@ -273,7 +249,6 @@ export function buildQueryRangeRequest({
|
||||
formatTableResultForUI,
|
||||
bucketedStepInterval,
|
||||
orderTiebreaker,
|
||||
defaultRowLimit,
|
||||
},
|
||||
startMs,
|
||||
endMs,
|
||||
@@ -291,9 +266,6 @@ export function buildQueryRangeRequest({
|
||||
if (pagination) {
|
||||
envelopes = withPagination(envelopes, pagination);
|
||||
}
|
||||
if (defaultRowLimit) {
|
||||
envelopes = withDefaultRowLimit(envelopes, defaultRowLimit);
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 'v1',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -261,6 +261,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddUserTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddRuleViewFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddFieldTuplesFactory(sqlstore),
|
||||
sqlmigration.NewKeepRelevantLLMPricingRulesFactory(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
116
pkg/sqlmigration/135_keep_relevant_llm_pricing_rules.go
Normal file
116
pkg/sqlmigration/135_keep_relevant_llm_pricing_rules.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
58
pkg/types/aiobservabilitytypes/message.go
Normal file
58
pkg/types/aiobservabilitytypes/message.go
Normal 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"`
|
||||
}
|
||||
239
pkg/types/aiobservabilitytypes/message_format.go
Normal file
239
pkg/types/aiobservabilitytypes/message_format.go
Normal 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)
|
||||
}
|
||||
125
pkg/types/aiobservabilitytypes/message_format_openai_chat.go
Normal file
125
pkg/types/aiobservabilitytypes/message_format_openai_chat.go
Normal 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
|
||||
}
|
||||
@@ -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"}
|
||||
]`,
|
||||
},
|
||||
})
|
||||
}
|
||||
68
pkg/types/aiobservabilitytypes/message_format_semconv.go
Normal file
68
pkg/types/aiobservabilitytypes/message_format_semconv.go
Normal 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
|
||||
}
|
||||
163
pkg/types/aiobservabilitytypes/message_format_semconv_test.go
Normal file
163
pkg/types/aiobservabilitytypes/message_format_semconv_test.go
Normal 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: `[]`,
|
||||
},
|
||||
})
|
||||
}
|
||||
101
pkg/types/aiobservabilitytypes/message_format_test.go
Normal file
101
pkg/types/aiobservabilitytypes/message_format_test.go
Normal 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: `[]`,
|
||||
},
|
||||
})
|
||||
}
|
||||
194
pkg/types/aiobservabilitytypes/message_part.go
Normal file
194
pkg/types/aiobservabilitytypes/message_part.go
Normal 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
|
||||
}
|
||||
79
pkg/types/aiobservabilitytypes/message_part_test.go
Normal file
79
pkg/types/aiobservabilitytypes/message_part_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
Reference in New Issue
Block a user