mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-15 07:50:41 +01:00
Compare commits
6 Commits
feat/dashb
...
issue_5329
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cc9232e0a | ||
|
|
572ef6f47d | ||
|
|
a849eac876 | ||
|
|
5258f5e8e3 | ||
|
|
8292988751 | ||
|
|
6e96a5ce37 |
@@ -3199,53 +3199,6 @@ components:
|
||||
repeatVariable:
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesAreaChartAppearance:
|
||||
properties:
|
||||
fillMode:
|
||||
$ref: '#/components/schemas/DashboardtypesAreaFillMode'
|
||||
fillOpacity:
|
||||
$ref: '#/components/schemas/DashboardtypesFillOpacity'
|
||||
lineInterpolation:
|
||||
$ref: '#/components/schemas/DashboardtypesLineInterpolation'
|
||||
lineStyle:
|
||||
$ref: '#/components/schemas/DashboardtypesLineStyle'
|
||||
showPoints:
|
||||
type: boolean
|
||||
spanGaps:
|
||||
$ref: '#/components/schemas/DashboardtypesSpanGaps'
|
||||
type: object
|
||||
DashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
$ref: '#/components/schemas/DashboardtypesAxes'
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesAreaChartAppearance'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
thresholds:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesAreaChartVisualization'
|
||||
type: object
|
||||
DashboardtypesAreaChartVisualization:
|
||||
properties:
|
||||
fillSpans:
|
||||
type: boolean
|
||||
stack:
|
||||
$ref: '#/components/schemas/DashboardtypesStackMode'
|
||||
timePreference:
|
||||
$ref: '#/components/schemas/DashboardtypesTimePreference'
|
||||
type: object
|
||||
DashboardtypesAreaFillMode:
|
||||
enum:
|
||||
- solid
|
||||
- gradient
|
||||
type: string
|
||||
DashboardtypesAxes:
|
||||
properties:
|
||||
isLogScale:
|
||||
@@ -3477,11 +3430,6 @@ components:
|
||||
- gradient
|
||||
- none
|
||||
type: string
|
||||
DashboardtypesFillOpacity:
|
||||
maximum: 1
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: number
|
||||
DashboardtypesGettableDashboardV2:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -3944,7 +3892,6 @@ components:
|
||||
DashboardtypesPanelPlugin:
|
||||
discriminator:
|
||||
mapping:
|
||||
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
|
||||
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
@@ -3957,7 +3904,6 @@ components:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
@@ -3969,7 +3915,6 @@ components:
|
||||
enum:
|
||||
- signoz/TimeSeriesPanel
|
||||
- signoz/BarChartPanel
|
||||
- signoz/AreaChartPanel
|
||||
- signoz/NumberPanel
|
||||
- signoz/PieChartPanel
|
||||
- signoz/TablePanel
|
||||
@@ -3977,18 +3922,6 @@ components:
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/AreaChartPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesAreaChartPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -4319,12 +4252,6 @@ components:
|
||||
are connected.
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesStackMode:
|
||||
enum:
|
||||
- none
|
||||
- normal
|
||||
- percent
|
||||
type: string
|
||||
DashboardtypesStorableDashboardData:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
@@ -9683,6 +9610,8 @@ components:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
origin:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
@@ -9695,6 +9624,7 @@ components:
|
||||
- fieldContext
|
||||
- config
|
||||
- enabled
|
||||
- origin
|
||||
type: object
|
||||
SpantypesSpanMapperConfig:
|
||||
properties:
|
||||
@@ -9723,48 +9653,75 @@ components:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
origin:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
version:
|
||||
type: integer
|
||||
required:
|
||||
- id
|
||||
- orgId
|
||||
- name
|
||||
- condition
|
||||
- enabled
|
||||
- origin
|
||||
- version
|
||||
type: object
|
||||
SpantypesSpanMapperGroupCondition:
|
||||
nullable: true
|
||||
properties:
|
||||
attributes:
|
||||
items:
|
||||
type: string
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperGroupConditionKey'
|
||||
nullable: true
|
||||
type: array
|
||||
resource:
|
||||
items:
|
||||
type: string
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperGroupConditionKey'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- attributes
|
||||
- resource
|
||||
type: object
|
||||
SpantypesSpanMapperGroupConditionKey:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
origin:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
|
||||
value:
|
||||
type: string
|
||||
required:
|
||||
- value
|
||||
- enabled
|
||||
type: object
|
||||
SpantypesSpanMapperOperation:
|
||||
enum:
|
||||
- move
|
||||
- copy
|
||||
type: string
|
||||
SpantypesSpanMapperOrigin:
|
||||
enum:
|
||||
- user
|
||||
- system
|
||||
type: string
|
||||
SpantypesSpanMapperSource:
|
||||
properties:
|
||||
context:
|
||||
$ref: '#/components/schemas/SpantypesFieldContext'
|
||||
enabled:
|
||||
type: boolean
|
||||
key:
|
||||
type: string
|
||||
operation:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOperation'
|
||||
origin:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
|
||||
priority:
|
||||
type: integer
|
||||
required:
|
||||
@@ -9772,6 +9729,7 @@ components:
|
||||
- context
|
||||
- operation
|
||||
- priority
|
||||
- enabled
|
||||
type: object
|
||||
SpantypesSpanMapperTestSpan:
|
||||
properties:
|
||||
|
||||
@@ -4007,52 +4007,6 @@ export interface DashboardGridLayoutSpecDTO {
|
||||
repeatVariable?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesAreaFillModeDTO {
|
||||
solid = 'solid',
|
||||
gradient = 'gradient',
|
||||
}
|
||||
/**
|
||||
* @minimum 0
|
||||
* @maximum 1
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesFillOpacityDTO = number | null;
|
||||
|
||||
export enum DashboardtypesLineInterpolationDTO {
|
||||
linear = 'linear',
|
||||
spline = 'spline',
|
||||
step_after = 'step_after',
|
||||
step_before = 'step_before',
|
||||
}
|
||||
export enum DashboardtypesLineStyleDTO {
|
||||
solid = 'solid',
|
||||
dashed = 'dashed',
|
||||
}
|
||||
export interface DashboardtypesSpanGapsDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
|
||||
*/
|
||||
fillLessThan?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
|
||||
*/
|
||||
fillOnlyBelow?: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAreaChartAppearanceDTO {
|
||||
fillMode?: DashboardtypesAreaFillModeDTO;
|
||||
fillOpacity?: DashboardtypesFillOpacityDTO | null;
|
||||
lineInterpolation?: DashboardtypesLineInterpolationDTO;
|
||||
lineStyle?: DashboardtypesLineStyleDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
showPoints?: boolean;
|
||||
spanGaps?: DashboardtypesSpanGapsDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAxesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -4130,11 +4084,6 @@ export interface DashboardtypesThresholdWithLabelDTO {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export enum DashboardtypesStackModeDTO {
|
||||
none = 'none',
|
||||
normal = 'normal',
|
||||
percent = 'percent',
|
||||
}
|
||||
export enum DashboardtypesTimePreferenceDTO {
|
||||
global_time = 'global_time',
|
||||
last_5_min = 'last_5_min',
|
||||
@@ -4147,27 +4096,6 @@ export enum DashboardtypesTimePreferenceDTO {
|
||||
last_1_week = 'last_1_week',
|
||||
last_1_month = 'last_1_month',
|
||||
}
|
||||
export interface DashboardtypesAreaChartVisualizationDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
fillSpans?: boolean;
|
||||
stack?: DashboardtypesStackModeDTO;
|
||||
timePreference?: DashboardtypesTimePreferenceDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAreaChartPanelSpecDTO {
|
||||
axes?: DashboardtypesAxesDTO;
|
||||
chartAppearance?: DashboardtypesAreaChartAppearanceDTO;
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
|
||||
visualization?: DashboardtypesAreaChartVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesBarChartVisualizationDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -4865,6 +4793,29 @@ export enum DashboardtypesFillModeDTO {
|
||||
gradient = 'gradient',
|
||||
none = 'none',
|
||||
}
|
||||
export enum DashboardtypesLineInterpolationDTO {
|
||||
linear = 'linear',
|
||||
spline = 'spline',
|
||||
step_after = 'step_after',
|
||||
step_before = 'step_before',
|
||||
}
|
||||
export enum DashboardtypesLineStyleDTO {
|
||||
solid = 'solid',
|
||||
dashed = 'dashed',
|
||||
}
|
||||
export interface DashboardtypesSpanGapsDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
|
||||
*/
|
||||
fillLessThan?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
|
||||
*/
|
||||
fillOnlyBelow?: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardtypesTimeSeriesChartAppearanceDTO {
|
||||
fillMode?: DashboardtypesFillModeDTO;
|
||||
lineInterpolation?: DashboardtypesLineInterpolationDTO;
|
||||
@@ -4917,18 +4868,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesBarChartPanelSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTOKind {
|
||||
'signoz/AreaChartPanel' = 'signoz/AreaChartPanel',
|
||||
}
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/AreaChartPanel
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTOKind;
|
||||
spec: DashboardtypesAreaChartPanelSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpecDTOKind {
|
||||
'signoz/NumberPanel' = 'signoz/NumberPanel',
|
||||
}
|
||||
@@ -5134,7 +5073,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
|
||||
@@ -6059,7 +5997,6 @@ export interface DashboardtypesListableDashboardViewDTO {
|
||||
export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
|
||||
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
|
||||
'signoz/AreaChartPanel' = 'signoz/AreaChartPanel',
|
||||
'signoz/NumberPanel' = 'signoz/NumberPanel',
|
||||
'signoz/PieChartPanel' = 'signoz/PieChartPanel',
|
||||
'signoz/TablePanel' = 'signoz/TablePanel',
|
||||
@@ -10746,15 +10683,31 @@ export interface SpantypesGettableFlamegraphTraceDTO {
|
||||
startTimestampMillis: number;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanMapperOriginDTO {
|
||||
user = 'user',
|
||||
system = 'system',
|
||||
}
|
||||
export interface SpantypesSpanMapperGroupConditionKeyDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
origin?: SpantypesSpanMapperOriginDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type SpantypesSpanMapperGroupConditionDTOAnyOf = {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
attributes: string[] | null;
|
||||
attributes: SpantypesSpanMapperGroupConditionKeyDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
resource: string[] | null;
|
||||
resource: SpantypesSpanMapperGroupConditionKeyDTO[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -10790,6 +10743,7 @@ export interface SpantypesSpanMapperGroupDTO {
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
origin: SpantypesSpanMapperOriginDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -10799,6 +10753,10 @@ export interface SpantypesSpanMapperGroupDTO {
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableSpanMapperGroupsDTO {
|
||||
@@ -10856,11 +10814,16 @@ export enum SpantypesSpanMapperOperationDTO {
|
||||
}
|
||||
export interface SpantypesSpanMapperSourceDTO {
|
||||
context: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
origin?: SpantypesSpanMapperOriginDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -10902,6 +10865,7 @@ export interface SpantypesSpanMapperDTO {
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
origin: SpantypesSpanMapperOriginDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
|
||||
@@ -29,7 +29,6 @@ export const getComponentForPanelType = (
|
||||
[PANEL_TYPES.LIST]:
|
||||
dataSource === DataSource.LOGS ? LogsPanelComponent : TracesTableComponent,
|
||||
[PANEL_TYPES.BAR]: Uplot,
|
||||
[PANEL_TYPES.AREA]: Uplot,
|
||||
[PANEL_TYPES.PIE]: null,
|
||||
[PANEL_TYPES.HISTOGRAM]: Uplot,
|
||||
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
|
||||
|
||||
@@ -374,7 +374,6 @@ export enum PANEL_TYPES {
|
||||
LIST = 'list',
|
||||
TRACE = 'trace',
|
||||
BAR = 'bar',
|
||||
AREA = 'area',
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
TEXT = 'text',
|
||||
|
||||
@@ -333,6 +333,7 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
priority,
|
||||
enabled: true,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { ConditionKey } from 'container/LLMObservability/AttributeMapping/types';
|
||||
|
||||
import styles from './ConditionsTooltip.module.scss';
|
||||
|
||||
interface ConditionsTooltipProps {
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
attributes: ConditionKey[];
|
||||
resource: ConditionKey[];
|
||||
}
|
||||
|
||||
function ConditionsTooltip({
|
||||
@@ -33,8 +35,8 @@ function ConditionsTooltip({
|
||||
</Typography.Text>
|
||||
<div className={styles.keyList}>
|
||||
{attributes.map((key) => (
|
||||
<code key={key} className={styles.key}>
|
||||
{key}
|
||||
<code key={`${key.origin}-${key.value}`} className={styles.key}>
|
||||
{key.value}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
@@ -47,8 +49,8 @@ function ConditionsTooltip({
|
||||
</Typography.Text>
|
||||
<div className={styles.keyList}>
|
||||
{resource.map((key) => (
|
||||
<code key={key} className={styles.key}>
|
||||
{key}
|
||||
<code key={`${key.origin}-${key.value}`} className={styles.key}>
|
||||
{key.value}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
SpantypesSpanMapperDTO as Mapper,
|
||||
SpantypesSpanMapperGroupDTO as MapperGroup,
|
||||
SpantypesSpanMapperOperationDTO as MapperOperation,
|
||||
SpantypesSpanMapperOriginDTO as MapperOrigin,
|
||||
SpantypesSpanMapperTestSpanDTO as TestSpan,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
@@ -21,9 +22,15 @@ export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
|
||||
orgId: 'org-1',
|
||||
name: 'demo',
|
||||
enabled: true,
|
||||
origin: MapperOrigin.user,
|
||||
version: 0,
|
||||
condition: {
|
||||
attributes: ['ai.embeddings'],
|
||||
resource: ['cloud.account.id'],
|
||||
attributes: [
|
||||
{ value: 'ai.embeddings', enabled: true, origin: MapperOrigin.user },
|
||||
],
|
||||
resource: [
|
||||
{ value: 'cloud.account.id', enabled: true, origin: MapperOrigin.user },
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
@@ -35,6 +42,7 @@ export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
|
||||
groupId: 'group-1',
|
||||
name: 'gen_ai.request.model',
|
||||
enabled: true,
|
||||
origin: MapperOrigin.user,
|
||||
fieldContext: FieldContext.attribute,
|
||||
config: {
|
||||
sources: [
|
||||
@@ -43,12 +51,16 @@ export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
priority: 2,
|
||||
enabled: true,
|
||||
origin: MapperOrigin.user,
|
||||
},
|
||||
{
|
||||
key: 'llm.model',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.move,
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
origin: MapperOrigin.user,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -85,8 +97,12 @@ export const mockGroups: MapperGroup[] = [
|
||||
id: 'group-1',
|
||||
name: 'demo',
|
||||
condition: {
|
||||
attributes: ['ai.embeddings'],
|
||||
resource: ['cloud.account.id'],
|
||||
attributes: [
|
||||
{ value: 'ai.embeddings', enabled: true, origin: MapperOrigin.user },
|
||||
],
|
||||
resource: [
|
||||
{ value: 'cloud.account.id', enabled: true, origin: MapperOrigin.user },
|
||||
],
|
||||
},
|
||||
}),
|
||||
makeGroup({
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus, X } from '@signozhq/icons';
|
||||
|
||||
import { FieldContextValue } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import {
|
||||
ConditionKey,
|
||||
FieldContextValue,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { createConditionKey } from 'container/LLMObservability/AttributeMapping/utils';
|
||||
import KeySearchInput from '../../../KeySearchInput/KeySearchInput';
|
||||
import styles from './ConditionKeyList.module.scss';
|
||||
|
||||
interface ConditionKeyListProps {
|
||||
label: string;
|
||||
labelHint?: string;
|
||||
keys: string[];
|
||||
keys: ConditionKey[];
|
||||
placeholder: string;
|
||||
addLabel: string;
|
||||
testIdPrefix: string;
|
||||
fieldContext: FieldContextValue;
|
||||
onChange: (keys: string[]) => void;
|
||||
onChange: (keys: ConditionKey[]) => void;
|
||||
}
|
||||
|
||||
function ConditionKeyList({
|
||||
@@ -27,11 +31,11 @@ function ConditionKeyList({
|
||||
onChange,
|
||||
}: ConditionKeyListProps): JSX.Element {
|
||||
const updateKey = (index: number, value: string): void => {
|
||||
onChange(keys.map((key, i) => (i === index ? value : key)));
|
||||
onChange(keys.map((key, i) => (i === index ? { ...key, value } : key)));
|
||||
};
|
||||
|
||||
const addKey = (): void => {
|
||||
onChange([...keys, '']);
|
||||
onChange([...keys, createConditionKey()]);
|
||||
};
|
||||
|
||||
const removeKey = (index: number): void => {
|
||||
@@ -53,7 +57,7 @@ function ConditionKeyList({
|
||||
<KeySearchInput
|
||||
className={styles.keyInput}
|
||||
placeholder={placeholder}
|
||||
value={key}
|
||||
value={key.value}
|
||||
fieldContext={fieldContext}
|
||||
onChange={(next): void => updateKey(index, next)}
|
||||
testId={`${testIdPrefix}-${index}`}
|
||||
|
||||
@@ -42,7 +42,9 @@ function sourcesEqual(a: SourceConfig[], b: SourceConfig[]): boolean {
|
||||
(source, index) =>
|
||||
source.key === b[index].key &&
|
||||
source.context === b[index].context &&
|
||||
source.operation === b[index].operation,
|
||||
source.operation === b[index].operation &&
|
||||
source.enabled === b[index].enabled &&
|
||||
source.origin === b[index].origin,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
SpantypesSpanMapperDTO,
|
||||
SpantypesSpanMapperGroupDTO,
|
||||
SpantypesSpanMapperOperationDTO,
|
||||
SpantypesSpanMapperOriginDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type MapperGroup = SpantypesSpanMapperGroupDTO;
|
||||
@@ -11,6 +12,16 @@ export const FieldContext = SpantypesFieldContextDTO;
|
||||
export type FieldContextValue = SpantypesFieldContextDTO;
|
||||
export const MapperOperation = SpantypesSpanMapperOperationDTO;
|
||||
export type MapperOperationValue = SpantypesSpanMapperOperationDTO;
|
||||
export const MapperOrigin = SpantypesSpanMapperOriginDTO;
|
||||
export type MapperOriginValue = SpantypesSpanMapperOriginDTO;
|
||||
|
||||
// One condition substring. Shipped (system) keys are read-only apart from
|
||||
// `enabled`; user keys are fully editable.
|
||||
export interface ConditionKey {
|
||||
value: string;
|
||||
enabled: boolean;
|
||||
origin: MapperOriginValue;
|
||||
}
|
||||
|
||||
export type MapperDraftMode = 'add' | 'edit';
|
||||
|
||||
@@ -18,6 +29,8 @@ export interface SourceConfig {
|
||||
key: string;
|
||||
context: SpantypesFieldContextDTO;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
enabled: boolean;
|
||||
origin: MapperOriginValue;
|
||||
}
|
||||
|
||||
// Editable form state for a mapper. `sources` is ordered highest priority
|
||||
@@ -33,8 +46,8 @@ export interface MapperDraft {
|
||||
export interface GroupDraft {
|
||||
id: string | null;
|
||||
name: string;
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
attributes: ConditionKey[];
|
||||
resource: ConditionKey[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -51,8 +64,8 @@ export interface DraftGroup {
|
||||
localId: string;
|
||||
serverId: string | null;
|
||||
name: string;
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
attributes: ConditionKey[];
|
||||
resource: ConditionKey[];
|
||||
enabled: boolean;
|
||||
mappers: DraftMapper[];
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesSpanMapperGroupConditionKeyDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
ConditionKey,
|
||||
DraftGroup,
|
||||
DraftMapper,
|
||||
FieldContext,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
MapperDraft,
|
||||
MapperGroup,
|
||||
MapperOperation,
|
||||
MapperOrigin,
|
||||
SourceConfig,
|
||||
} from './types';
|
||||
|
||||
@@ -24,20 +27,36 @@ function genLocalId(prefix: 'group' | 'mapper'): string {
|
||||
return `local-${prefix}-${uuid()}`;
|
||||
}
|
||||
|
||||
// Trimmed, de-duplicated, non-empty keys preserving input order.
|
||||
function cleanKeys(keys: string[]): string[] {
|
||||
export function createConditionKey(value = ''): ConditionKey {
|
||||
return { value, enabled: true, origin: MapperOrigin.user };
|
||||
}
|
||||
|
||||
// Trimmed, de-duplicated, non-empty keys preserving input order. A shipped and
|
||||
// a user key may share a value, so the origin is part of the identity.
|
||||
function cleanKeys(keys: ConditionKey[]): ConditionKey[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
const result: ConditionKey[] = [];
|
||||
keys.forEach((raw) => {
|
||||
const key = raw.trim();
|
||||
if (key && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(key);
|
||||
const value = raw.value.trim();
|
||||
const dedupeKey = `${raw.origin}:${value}`;
|
||||
if (value && !seen.has(dedupeKey)) {
|
||||
seen.add(dedupeKey);
|
||||
result.push({ ...raw, value });
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function fromConditionKeys(
|
||||
keys: SpantypesSpanMapperGroupConditionKeyDTO[] | null | undefined,
|
||||
): ConditionKey[] {
|
||||
return (keys ?? []).map((key) => ({
|
||||
value: key.value,
|
||||
enabled: key.enabled,
|
||||
origin: key.origin ?? MapperOrigin.user,
|
||||
}));
|
||||
}
|
||||
|
||||
// Source configs for a mapper, highest priority first (first match wins at
|
||||
// evaluation time).
|
||||
function getMapperSources(mapper: Mapper): SourceConfig[] {
|
||||
@@ -48,6 +67,8 @@ function getMapperSources(mapper: Mapper): SourceConfig[] {
|
||||
key: source.key,
|
||||
context: source.context,
|
||||
operation: source.operation,
|
||||
enabled: source.enabled,
|
||||
origin: source.origin ?? MapperOrigin.user,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -56,6 +77,8 @@ export function createEmptySource(): SourceConfig {
|
||||
key: '',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
enabled: true,
|
||||
origin: MapperOrigin.user,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,7 +95,7 @@ function getCleanSources(draft: MapperDraft): SourceConfig[] {
|
||||
const result: SourceConfig[] = [];
|
||||
draft.sources.forEach((source) => {
|
||||
const key = source.key.trim();
|
||||
const dedupeKey = `${source.context}:${key}`;
|
||||
const dedupeKey = `${source.origin}:${source.context}:${key}`;
|
||||
if (key && !seen.has(dedupeKey)) {
|
||||
seen.add(dedupeKey);
|
||||
result.push({ ...source, key });
|
||||
@@ -95,6 +118,8 @@ function buildSources(
|
||||
context: source.context,
|
||||
operation: source.operation,
|
||||
priority: sources.length - index,
|
||||
enabled: source.enabled,
|
||||
origin: source.origin,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -123,7 +148,7 @@ export function buildUpdatableMapper(
|
||||
export const EMPTY_GROUP_DRAFT: GroupDraft = {
|
||||
id: null,
|
||||
name: '',
|
||||
attributes: [''],
|
||||
attributes: [createConditionKey()],
|
||||
resource: [],
|
||||
enabled: true,
|
||||
};
|
||||
@@ -170,8 +195,8 @@ export function buildDraftGroup(
|
||||
localId: group.id,
|
||||
serverId: group.id,
|
||||
name: group.name,
|
||||
attributes: group.condition?.attributes ?? [],
|
||||
resource: group.condition?.resource ?? [],
|
||||
attributes: fromConditionKeys(group.condition?.attributes),
|
||||
resource: fromConditionKeys(group.condition?.resource),
|
||||
enabled: group.enabled,
|
||||
mappers: mappers.map(buildDraftMapper),
|
||||
};
|
||||
@@ -182,7 +207,8 @@ export function groupDraftFromNode(group: DraftGroup): GroupDraft {
|
||||
return {
|
||||
id: group.localId,
|
||||
name: group.name,
|
||||
attributes: group.attributes.length > 0 ? group.attributes : [''],
|
||||
attributes:
|
||||
group.attributes.length > 0 ? group.attributes : [createConditionKey()],
|
||||
resource: group.resource,
|
||||
enabled: group.enabled,
|
||||
};
|
||||
|
||||
@@ -27,7 +27,6 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
|
||||
LIST: false,
|
||||
TRACE: false,
|
||||
BAR: true,
|
||||
AREA: true,
|
||||
PIE: false,
|
||||
HISTOGRAM: false,
|
||||
TEXT: false,
|
||||
|
||||
@@ -19,7 +19,5 @@ export const PanelTypeVsPanelWrapper = {
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
||||
[PANEL_TYPES.PIE]: PiePanelWrapper,
|
||||
[PANEL_TYPES.BAR]: BarPanel,
|
||||
// Dashboards v2 renders this kind; the fallback only keeps the lookup exhaustive.
|
||||
[PANEL_TYPES.AREA]: TimeSeriesPanel,
|
||||
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils';
|
||||
import uPlot, { Series } from 'uplot';
|
||||
|
||||
import { resolveFillOpacity, toAlphaHex } from '../utils/fillOpacity';
|
||||
import { generateGradientFill } from '../utils/generateGradientFill';
|
||||
import { isolatedPointFilter } from '../utils/seriesPointsFilter';
|
||||
import {
|
||||
@@ -59,8 +58,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
}: {
|
||||
resolvedLineColor: string;
|
||||
}): Partial<Series> {
|
||||
const { lineWidth, lineStyle, lineCap, fillColor, fillMode, fillOpacity } =
|
||||
this.props;
|
||||
const { lineWidth, lineStyle, lineCap, fillColor, fillMode } = this.props;
|
||||
const lineConfig: Partial<Series> = {
|
||||
stroke: resolvedLineColor,
|
||||
width: lineWidth ?? DEFAULT_LINE_WIDTH,
|
||||
@@ -88,17 +86,11 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
} else if (this.props.drawStyle === DrawStyle.Histogram) {
|
||||
lineConfig.fill = `${finalFillColor}40`;
|
||||
} else if (fillMode && fillMode !== FillMode.None) {
|
||||
const resolvedOpacity = resolveFillOpacity(fillOpacity);
|
||||
if (fillMode === FillMode.Solid) {
|
||||
lineConfig.fill = `${finalFillColor}${toAlphaHex(resolvedOpacity)}`;
|
||||
lineConfig.fill = `${finalFillColor}70`;
|
||||
} else if (fillMode === FillMode.Gradient) {
|
||||
lineConfig.fill = (self: uPlot): CanvasGradient =>
|
||||
generateGradientFill(
|
||||
self,
|
||||
finalFillColor,
|
||||
'rgba(0, 0, 0, 0)',
|
||||
resolvedOpacity,
|
||||
);
|
||||
generateGradientFill(self, finalFillColor, 'rgba(0, 0, 0, 0)');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import uPlot from 'uplot';
|
||||
|
||||
import { isolatedPointFilter } from '../../utils/seriesPointsFilter';
|
||||
import type { SeriesProps } from '../types';
|
||||
import { DrawStyle, FillMode, LineInterpolation, LineStyle } from '../types';
|
||||
import { DrawStyle, LineInterpolation, LineStyle } from '../types';
|
||||
import { POINT_SIZE_FACTOR, UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
|
||||
|
||||
const createBaseProps = (
|
||||
@@ -362,40 +362,4 @@ describe('UPlotSeriesBuilder', () => {
|
||||
expect(config.points?.filter).toBeUndefined();
|
||||
expect(config.points?.show).toBe(true);
|
||||
});
|
||||
|
||||
// Pins the alpha the solid fill hardcoded before opacity was configurable.
|
||||
it('fills a solid series at the default opacity', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
fillColor: '#112233',
|
||||
fillMode: FillMode.Solid,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(builder.getConfig().fill).toBe('#11223370');
|
||||
});
|
||||
|
||||
it('fills a solid series at the declared opacity', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
fillColor: '#112233',
|
||||
fillMode: FillMode.Solid,
|
||||
fillOpacity: 0.5,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(builder.getConfig().fill).toBe('#11223380');
|
||||
});
|
||||
|
||||
it('leaves an unfilled series without a fill', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
fillColor: '#112233',
|
||||
fillMode: FillMode.None,
|
||||
fillOpacity: 0.5,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(builder.getConfig().fill).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,8 +222,6 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
||||
spanGaps?: boolean | number;
|
||||
fillColor?: string;
|
||||
fillMode?: FillMode;
|
||||
/** 0–1, for `Solid` and `Gradient`; unset uses `DEFAULT_FILL_OPACITY`. */
|
||||
fillOpacity?: number;
|
||||
isDarkMode?: boolean;
|
||||
stepInterval?: number;
|
||||
metric?: { [key: string]: string };
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import {
|
||||
DEFAULT_FILL_OPACITY,
|
||||
GRADIENT_MID_STOP_RATIO,
|
||||
resolveFillOpacity,
|
||||
toAlphaHex,
|
||||
} from '../fillOpacity';
|
||||
|
||||
describe('resolveFillOpacity', () => {
|
||||
it('falls back to the default for a missing or unusable value', () => {
|
||||
expect(resolveFillOpacity(undefined)).toBe(DEFAULT_FILL_OPACITY);
|
||||
expect(resolveFillOpacity(null)).toBe(DEFAULT_FILL_OPACITY);
|
||||
expect(resolveFillOpacity(NaN)).toBe(DEFAULT_FILL_OPACITY);
|
||||
});
|
||||
|
||||
it('keeps 0 rather than treating it as absent', () => {
|
||||
expect(resolveFillOpacity(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps to 0–1', () => {
|
||||
expect(resolveFillOpacity(-0.5)).toBe(0);
|
||||
expect(resolveFillOpacity(2)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toAlphaHex', () => {
|
||||
// The alphas hardcoded before opacity was configurable.
|
||||
it('reproduces the legacy solid alpha at the default opacity', () => {
|
||||
expect(toAlphaHex(DEFAULT_FILL_OPACITY)).toBe('70');
|
||||
});
|
||||
|
||||
it('reproduces the legacy gradient mid-stop alpha at the default opacity', () => {
|
||||
expect(toAlphaHex(DEFAULT_FILL_OPACITY * GRADIENT_MID_STOP_RATIO)).toBe('40');
|
||||
});
|
||||
|
||||
it('pads a single-digit alpha', () => {
|
||||
expect(toAlphaHex(0)).toBe('00');
|
||||
expect(toAlphaHex(0.02)).toBe('05');
|
||||
});
|
||||
|
||||
it('maps a full opacity to ff', () => {
|
||||
expect(toAlphaHex(1)).toBe('ff');
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* `0x70 / 255` reproduces the alpha the fill modes hardcoded before opacity was
|
||||
* configurable, so a series that declares none renders byte-identically.
|
||||
*/
|
||||
export const DEFAULT_FILL_OPACITY = 0x70 / 255;
|
||||
|
||||
/** Alpha ratio between a gradient's two stops, so it keeps its falloff at any opacity. */
|
||||
export const GRADIENT_MID_STOP_RATIO = 0x40 / 0x70;
|
||||
|
||||
/** Clamps into 0–1; missing or non-finite falls back to the default. */
|
||||
export function resolveFillOpacity(opacity?: number | null): number {
|
||||
if (typeof opacity !== 'number' || !Number.isFinite(opacity)) {
|
||||
return DEFAULT_FILL_OPACITY;
|
||||
}
|
||||
return Math.min(1, Math.max(0, opacity));
|
||||
}
|
||||
|
||||
/** 0–1 opacity → the two-digit hex alpha suffix appended to an `#rrggbb` colour. */
|
||||
export function toAlphaHex(opacity: number): string {
|
||||
const alpha = Math.round(resolveFillOpacity(opacity) * 255);
|
||||
return alpha.toString(16).padStart(2, '0');
|
||||
}
|
||||
@@ -1,16 +1,9 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
DEFAULT_FILL_OPACITY,
|
||||
GRADIENT_MID_STOP_RATIO,
|
||||
toAlphaHex,
|
||||
} from './fillOpacity';
|
||||
|
||||
export function generateGradientFill(
|
||||
uPlotInstance: uPlot,
|
||||
startColor: string,
|
||||
endColor: string,
|
||||
opacity: number = DEFAULT_FILL_OPACITY,
|
||||
): CanvasGradient {
|
||||
const g = uPlotInstance.ctx.createLinearGradient(
|
||||
0,
|
||||
@@ -18,11 +11,8 @@ export function generateGradientFill(
|
||||
0,
|
||||
uPlotInstance.bbox.height,
|
||||
);
|
||||
g.addColorStop(0, `${startColor}${toAlphaHex(opacity)}`);
|
||||
g.addColorStop(
|
||||
0.6,
|
||||
`${startColor}${toAlphaHex(opacity * GRADIENT_MID_STOP_RATIO)}`,
|
||||
);
|
||||
g.addColorStop(0, `${startColor}70`);
|
||||
g.addColorStop(0.6, `${startColor}40`);
|
||||
g.addColorStop(1, endColor);
|
||||
return g;
|
||||
}
|
||||
|
||||
@@ -137,15 +137,6 @@ describe('stackSeries', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('tops out at exactly 100 for values that do not divide cleanly', () => {
|
||||
// Accumulating each slice's share drifts past 100 and stretches the y axis.
|
||||
const data: AlignedData = [[1], [63], [78], [44]];
|
||||
|
||||
const [, top] = stackSeries(data, includeAll, StackMode.Percent).data;
|
||||
|
||||
expect(top[0]).toBe(100);
|
||||
});
|
||||
|
||||
it('yields 0 across a column whose signed total cancels to zero', () => {
|
||||
const data: AlignedData = [[1], [10], [-10]];
|
||||
|
||||
|
||||
@@ -42,11 +42,45 @@ interface BuildStackedSeriesParams {
|
||||
mode: StackMode;
|
||||
}
|
||||
|
||||
/** Per-point total. Mixed-sign columns sum signed, as "share of total" implies. */
|
||||
function columnTotals({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
}: Omit<BuildStackedSeriesParams, 'mode'>): number[] {
|
||||
const totals = Array(pointCount).fill(0) as number[];
|
||||
|
||||
for (let seriesIndex = 1; seriesIndex <= valueSeriesCount; seriesIndex++) {
|
||||
if (omit(seriesIndex)) {
|
||||
continue;
|
||||
}
|
||||
const rawValues = data[seriesIndex] as (number | null)[];
|
||||
rawValues.forEach((rawValue, pointIndex) => {
|
||||
totals[pointIndex] += rawValue == null ? 0 : Number(rawValue);
|
||||
});
|
||||
}
|
||||
|
||||
return totals;
|
||||
}
|
||||
|
||||
/** A column whose participating series sum to 0 has no share to divide, so every slice is 0. */
|
||||
function toPercent(value: number, total: number): number {
|
||||
return total === 0 ? 0 : (value / total) * 100;
|
||||
}
|
||||
|
||||
/** What a raw value adds to the stack at a given point. */
|
||||
type Contribution = (value: number, pointIndex: number) => number;
|
||||
|
||||
function contributionForMode(params: BuildStackedSeriesParams): Contribution {
|
||||
if (params.mode !== StackMode.Percent) {
|
||||
return (value): number => value;
|
||||
}
|
||||
// Resolved up front: totals span series the accumulation below has not reached yet.
|
||||
const totals = columnTotals(params);
|
||||
return (value, pointIndex): number => toPercent(value, totals[pointIndex]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulate from last series upward: last series = raw values, first = total.
|
||||
* Omitted series are copied as-is (no accumulation).
|
||||
@@ -59,7 +93,14 @@ function buildStackedSeries({
|
||||
mode,
|
||||
}: BuildStackedSeriesParams): (number | null)[][] {
|
||||
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
|
||||
const columnTotals = Array(pointCount).fill(0) as number[];
|
||||
const cumulativeSums = Array(pointCount).fill(0) as number[];
|
||||
const contributionOf = contributionForMode({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
mode,
|
||||
});
|
||||
|
||||
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
|
||||
const rawValues = data[seriesIndex] as (number | null)[];
|
||||
@@ -69,27 +110,14 @@ function buildStackedSeries({
|
||||
} else {
|
||||
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
|
||||
const numericValue = rawValue == null ? 0 : Number(rawValue);
|
||||
return (columnTotals[pointIndex] += numericValue);
|
||||
return (cumulativeSums[pointIndex] += contributionOf(
|
||||
numericValue,
|
||||
pointIndex,
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mode !== StackMode.Percent) {
|
||||
return stackedSeries;
|
||||
}
|
||||
|
||||
// Scale the running totals once they are final rather than accumulating per-slice
|
||||
// percentages: the topmost series then divides the total by itself and lands on
|
||||
// exactly 100, where accumulated shares drift past it and stretch the y axis.
|
||||
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
|
||||
if (omit(seriesIndex)) {
|
||||
continue;
|
||||
}
|
||||
stackedSeries[seriesIndex - 1] = stackedSeries[seriesIndex - 1].map(
|
||||
(value, pointIndex) => toPercent(value as number, columnTotals[pointIndex]),
|
||||
);
|
||||
}
|
||||
|
||||
return stackedSeries;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
// The design-system slider insets its track by half a thumb on each side so the
|
||||
// fill follows the thumb's centre. Pull that inset back off the row so the track
|
||||
// lines up with the other controls; the thumb never paints past the track edge.
|
||||
margin-inline: calc(var(--slider-thumb-width, 18px) / -2);
|
||||
}
|
||||
|
||||
.value {
|
||||
flex-shrink: 0;
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Slider } from '@signozhq/ui/slider';
|
||||
|
||||
import styles from './ConfigSlider.module.scss';
|
||||
|
||||
interface ConfigSliderProps {
|
||||
testId: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
/** Renders the current value beside the track (e.g. as a percentage). */
|
||||
formatValue?: (value: number) => string;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Numeric slider for the config sections. The design-system Slider is multi-thumb
|
||||
* capable, so its callback hands back `number | number[]`; this narrows to one thumb.
|
||||
*/
|
||||
function ConfigSlider({
|
||||
testId,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
formatValue,
|
||||
onChange,
|
||||
}: ConfigSliderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<Slider
|
||||
testId={testId}
|
||||
className={styles.slider}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(next): void => onChange(Array.isArray(next) ? next[0] : next)}
|
||||
/>
|
||||
<span className={styles.value}>
|
||||
{formatValue ? formatValue(value) : value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigSlider;
|
||||
@@ -2,16 +2,16 @@ import type { ComponentType } from 'react';
|
||||
import type {
|
||||
DashboardtypesLinkDTO,
|
||||
DashboardtypesAxesDTO,
|
||||
DashboardtypesBarChartVisualizationDTO,
|
||||
DashboardtypesHistogramBucketsDTO,
|
||||
DashboardtypesLegendDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesTimeSeriesChartAppearanceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
SectionKind,
|
||||
type AnyThreshold,
|
||||
type PanelChartAppearanceSlice,
|
||||
type PanelFormattingSlice,
|
||||
type PanelVisualizationSlice,
|
||||
type SectionEditorProps,
|
||||
type SectionSpecMap,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
@@ -94,15 +94,21 @@ export const SECTION_REGISTRY: {
|
||||
},
|
||||
[SectionKind.ChartAppearance]: {
|
||||
Component: ChartAppearanceSection,
|
||||
get: (spec): PanelChartAppearanceSlice | undefined =>
|
||||
getPluginSlice<PanelChartAppearanceSlice>(spec, 'chartAppearance'),
|
||||
get: (spec): DashboardtypesTimeSeriesChartAppearanceDTO | undefined =>
|
||||
getPluginSlice<DashboardtypesTimeSeriesChartAppearanceDTO>(
|
||||
spec,
|
||||
'chartAppearance',
|
||||
),
|
||||
update: (spec, chartAppearance): PanelSpec =>
|
||||
updatePluginSlice(spec, 'chartAppearance', chartAppearance),
|
||||
},
|
||||
[SectionKind.Visualization]: {
|
||||
Component: VisualizationSection,
|
||||
get: (spec): PanelVisualizationSlice | undefined =>
|
||||
getPluginSlice<PanelVisualizationSlice>(spec, 'visualization'),
|
||||
get: (spec): DashboardtypesBarChartVisualizationDTO | undefined =>
|
||||
getPluginSlice<DashboardtypesBarChartVisualizationDTO>(
|
||||
spec,
|
||||
'visualization',
|
||||
),
|
||||
update: (spec, visualization): PanelSpec =>
|
||||
updatePluginSlice(spec, 'visualization', visualization),
|
||||
},
|
||||
|
||||
@@ -9,11 +9,8 @@ import type {
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import { resolveFillOpacity } from 'lib/uPlotV2/utils/fillOpacity';
|
||||
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import ConfigSlider from '../../controls/ConfigSlider/ConfigSlider';
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import { SegmentIcon } from '../../controls/segmentIcons';
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
@@ -75,21 +72,10 @@ const FILL_MODE_OPTIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
// An always-filled kind's wire enum (`AreaFillMode`) has no `none`.
|
||||
const FILLED_FILL_MODE_OPTIONS = FILL_MODE_OPTIONS.filter(
|
||||
(option) => option.value !== DashboardtypesFillModeDTO.none,
|
||||
);
|
||||
|
||||
const FILL_OPACITY_STEP = 0.01;
|
||||
|
||||
function formatOpacity(opacity: number): string {
|
||||
return `${Math.round(opacity * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits the `chartAppearance` slice of a TimeSeries panel spec: line style /
|
||||
* interpolation, fill mode, fill opacity, point markers, and the connect-null-gaps
|
||||
* threshold. Each control is gated by its `controls` flag.
|
||||
* interpolation, fill mode, point markers, and the connect-null-gaps threshold. Each
|
||||
* control is gated by its `controls` flag.
|
||||
*/
|
||||
function ChartAppearanceSection({
|
||||
value,
|
||||
@@ -138,9 +124,7 @@ function ChartAppearanceSection({
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-fill-mode"
|
||||
value={value?.fillMode}
|
||||
items={
|
||||
controls.fillOpacity ? FILLED_FILL_MODE_OPTIONS : FILL_MODE_OPTIONS
|
||||
}
|
||||
items={FILL_MODE_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({ ...value, fillMode: next as DashboardtypesFillModeDTO })
|
||||
}
|
||||
@@ -148,22 +132,6 @@ function ChartAppearanceSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.fillOpacity && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Fill opacity</Typography.Text>
|
||||
<ConfigSlider
|
||||
testId="panel-editor-v2-fill-opacity"
|
||||
// The chart's own default, so the thumb starts where an unset fill renders.
|
||||
value={resolveFillOpacity(value?.fillOpacity)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={FILL_OPACITY_STEP}
|
||||
formatValue={formatOpacity}
|
||||
onChange={(fillOpacity): void => onChange({ ...value, fillOpacity })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.showPoints && (
|
||||
<ConfigSwitch
|
||||
testId="panel-editor-v2-show-points"
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
DashboardtypesLineStyleDTO,
|
||||
type DashboardtypesTimeSeriesChartAppearanceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DEFAULT_FILL_OPACITY } from 'lib/uPlotV2/utils/fillOpacity';
|
||||
|
||||
import ChartAppearanceSection from '../ChartAppearanceSection';
|
||||
|
||||
@@ -105,66 +104,6 @@ describe('ChartAppearanceSection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the fill opacity at the chart default when the spec omits it', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ fillOpacity: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-fill-opacity'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(`${Math.round(DEFAULT_FILL_OPACITY * 100)}%`),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the stored fill opacity as a percentage', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={{ fillOpacity: 0.25 }}
|
||||
controls={{ fillOpacity: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('25%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers no None fill mode to a kind that declares fill opacity', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ fillMode: true, fillOpacity: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Solid')).toBeInTheDocument();
|
||||
expect(screen.getByText('Gradient')).toBeInTheDocument();
|
||||
expect(screen.queryByText('None')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-fill-opacity'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers all three fill modes to a kind that can be unfilled', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ fillMode: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('None')).toBeInTheDocument();
|
||||
expect(screen.getByText('Solid')).toBeInTheDocument();
|
||||
expect(screen.getByText('Gradient')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('writes the chosen line interpolation through the dropdown', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesStackModeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import PanelTypeSwitcher from '../../PanelTypeSwitcher/PanelTypeSwitcher';
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
import { STACK_MODE_OPTIONS } from './stackModeOptions';
|
||||
import { TIME_PREFERENCE_OPTIONS } from './timePreferenceOptions';
|
||||
|
||||
import styles from './VisualizationSection.module.scss';
|
||||
@@ -24,10 +21,9 @@ type VisualizationSectionProps = SectionEditorProps<SectionKind.Visualization> &
|
||||
|
||||
/**
|
||||
* Edits the `visualization` slice: the panel-type switcher (`switchPanelKind`, every
|
||||
* kind), the per-panel time preference, bar stacking (`stackedBarChart`, Bar only),
|
||||
* area stacking (`stack`, Area only) and gap filling (`fillSpans`). Each control is
|
||||
* gated by its `controls` flag, so a kind only renders — and only writes — the fields
|
||||
* its spec supports.
|
||||
* kind), the per-panel time preference, bar stacking (`stackedBarChart`, Bar only), and
|
||||
* gap filling (`fillSpans`, TimeSeries only). Each control is gated by its `controls`
|
||||
* flag, so a kind only renders — and only writes — the fields its spec supports.
|
||||
*/
|
||||
function VisualizationSection({
|
||||
value,
|
||||
@@ -81,20 +77,6 @@ function VisualizationSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
{controls.stackMode && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Stack series</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-stack-mode"
|
||||
value={value?.stack}
|
||||
items={STACK_MODE_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({ ...value, stack: next as DashboardtypesStackModeDTO })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.fillSpans && (
|
||||
<ConfigSwitch
|
||||
testId="panel-editor-v2-fill-spans"
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
DashboardtypesStackModeDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import VisualizationSection from '../VisualizationSection';
|
||||
|
||||
@@ -118,43 +115,6 @@ describe('VisualizationSection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('writes the chosen stack mode through the segmented control', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={{ fillSpans: true }}
|
||||
controls={{ switchPanelKind: true, stackMode: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-stack-mode')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Percent'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
fillSpans: true,
|
||||
stack: DashboardtypesStackModeDTO.percent,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders no stack-mode control for a kind that declares bar stacking', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ switchPanelKind: true, stacking: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-stacked-bar-chart'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-stack-mode'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles fill spans through onChange', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { DashboardtypesStackModeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { ConfigSegmentedItem } from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
|
||||
// `percent` rescales each x-slice to its column total; the y axis follows.
|
||||
export const STACK_MODE_OPTIONS: ConfigSegmentedItem[] = [
|
||||
{ value: DashboardtypesStackModeDTO.none, label: 'None' },
|
||||
{ value: DashboardtypesStackModeDTO.normal, label: 'Normal' },
|
||||
{ value: DashboardtypesStackModeDTO.percent, label: 'Percent' },
|
||||
];
|
||||
@@ -29,7 +29,6 @@ const { time_series, scalar, raw } = Querybuildertypesv5RequestTypeDTO;
|
||||
const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
|
||||
'signoz/TimeSeriesPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/BarChartPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/AreaChartPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/NumberPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/HistogramPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/PieChartPanel': [QUERY_BUILDER, CLICKHOUSE],
|
||||
@@ -42,7 +41,6 @@ const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
|
||||
const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
|
||||
'signoz/TimeSeriesPanel': [metrics, logs, traces],
|
||||
'signoz/BarChartPanel': [metrics, logs, traces],
|
||||
'signoz/AreaChartPanel': [metrics, logs, traces],
|
||||
'signoz/NumberPanel': [metrics, logs, traces],
|
||||
'signoz/HistogramPanel': [metrics, logs, traces],
|
||||
'signoz/PieChartPanel': [metrics, logs, traces],
|
||||
@@ -74,13 +72,6 @@ const EXPECTED_QUERY_CAPABILITIES: Partial<
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
'signoz/AreaChartPanel': {
|
||||
requestType: time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
'signoz/HistogramPanel': {
|
||||
requestType: time_series,
|
||||
formatTableResultForUI: false,
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DashboardtypesAreaChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import TimeSeries from 'lib/visualization/charts/TimeSeries/TimeSeries';
|
||||
import ChartManager from 'lib/visualization/components/ChartManager/ChartManager';
|
||||
import TooltipFooter from 'lib/visualization/panels/components/TooltipFooter';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getExecStats,
|
||||
getTimeSeriesResults,
|
||||
} from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
|
||||
import { prepareAlignedData } from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import NoData from '../../components/NoData/NoData';
|
||||
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import {
|
||||
resolveDecimalPrecision,
|
||||
resolveLegendPosition,
|
||||
resolveStackMode,
|
||||
} from '../../utils/chartAppearance/resolvers';
|
||||
import { stepClickTimeRange } from '../../utils/drilldown/chartClickTimeRange';
|
||||
import { enrichChartClick } from '../../utils/drilldown/enrichChartClick';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
|
||||
import { sortSeriesByMeanDesc } from '../../utils/sortSeriesByMean';
|
||||
|
||||
import { buildAreaChartConfig } from './utils/buildConfig';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
function AreaChartPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
onCloseStandaloneView,
|
||||
enableDrillDown,
|
||||
}: PanelRendererProps<'signoz/AreaChartPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const spec = useMemo<DashboardtypesAreaChartPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel.spec.queries),
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
// X-scale clamps come from the request that produced the data, so each panel
|
||||
// pins to the window it fetched — matters during drag-zoom transitions before
|
||||
// new data arrives.
|
||||
const { minTimeScale, maxTimeScale } = useMemo(() => {
|
||||
const { startTime, endTime } = getPanelTimeRange(data.requestPayload);
|
||||
return { minTimeScale: startTime, maxTimeScale: endTime };
|
||||
}, [data.requestPayload]);
|
||||
|
||||
const groupByPerQuery = useGroupByPerQuery(builderQueries);
|
||||
|
||||
const flatSeries = useMemo(
|
||||
() =>
|
||||
sortSeriesByMeanDesc(
|
||||
flattenTimeSeries(
|
||||
getTimeSeriesResults(data.response),
|
||||
data.legendMap ?? {},
|
||||
),
|
||||
),
|
||||
[data.response, data.legendMap],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildAreaChartConfig({
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
series: flatSeries,
|
||||
stepIntervals: getExecStats(data.response)?.stepIntervals,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
}),
|
||||
[
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
flatSeries,
|
||||
data.response,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
// TooltipPlugin mutates `config` for cursor sync; rebuild on syncMode change
|
||||
// so a fresh instance doesn't inherit stale sync settings (e.g. "No Sync").
|
||||
dashboardPreference?.syncMode,
|
||||
],
|
||||
);
|
||||
|
||||
const chartData = useMemo(() => prepareAlignedData(flatSeries), [flatSeries]);
|
||||
|
||||
const decimalPrecision = useMemo(
|
||||
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
|
||||
[spec.formatting?.decimalPrecision],
|
||||
);
|
||||
|
||||
const legendPosition = useMemo(() => {
|
||||
return resolveLegendPosition(spec.legend?.position);
|
||||
}, [spec.legend?.position]);
|
||||
|
||||
// The standalone View modal shows V1's graph-manager legend below the chart:
|
||||
// Filter Series + per-series show/hide + Save. Series visibility auto-persists to
|
||||
// localStorage (STANDALONE_VIEW selection prefs), keyed by panelId.
|
||||
const layoutChildren = useMemo(
|
||||
() =>
|
||||
panelMode === PanelMode.STANDALONE_VIEW ? (
|
||||
<div className={PanelStyles.chartManagerContainer}>
|
||||
<ChartManager
|
||||
config={config}
|
||||
alignedData={chartData}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
onCancel={onCloseStandaloneView}
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
[
|
||||
panelMode,
|
||||
config,
|
||||
chartData,
|
||||
spec.formatting?.unit,
|
||||
decimalPrecision,
|
||||
onCloseStandaloneView,
|
||||
],
|
||||
);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter
|
||||
id={panelId}
|
||||
isPinned={isPinned}
|
||||
canDrilldown={!!enableDrillDown}
|
||||
dismiss={dismiss}
|
||||
/>
|
||||
),
|
||||
[panelId, enableDrillDown],
|
||||
);
|
||||
|
||||
// Keying on sync prefs forces a full chart teardown/re-mount so stale sync
|
||||
// settings aren't inherited — the only way to fully reset the uPlot instance.
|
||||
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(args: ChartClickData): void => {
|
||||
if (!onClick) {
|
||||
return;
|
||||
}
|
||||
const payload = enrichChartClick({
|
||||
clickData: args,
|
||||
series: flatSeries,
|
||||
builderQueries,
|
||||
});
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
const timeRange = stepClickTimeRange({
|
||||
clickedDataTimestamp: args.clickedDataTimestamp,
|
||||
queryName: payload.context.queryName,
|
||||
builderQueries,
|
||||
stepInterval: getExecStats(data.response)?.stepIntervals?.[
|
||||
payload.context.queryName
|
||||
],
|
||||
});
|
||||
onClick({ ...payload, context: { ...payload.context, timeRange } });
|
||||
},
|
||||
[onClick, flatSeries, builderQueries, data.response],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={graphRef}
|
||||
data-testid="area-chart-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
)}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
<TimeSeries
|
||||
key={key}
|
||||
config={config}
|
||||
data={chartData}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
layoutChildren={layoutChildren}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
canPinTooltip
|
||||
timezone={timezone}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
syncMode={dashboardPreference?.syncMode}
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
stack={resolveStackMode(spec.visualization?.stack)}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={enableDrillDown ? handleChartClick : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AreaChartPanelRenderer;
|
||||
@@ -1,48 +0,0 @@
|
||||
import { ChartArea } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/AreaChartPanel'> = {
|
||||
kind: 'signoz/AreaChartPanel',
|
||||
displayName: 'Area Chart',
|
||||
mode: 'query',
|
||||
icon: ChartArea,
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
queryBuilderFields: {},
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import { resolveTimeSeriesLegendSeries } from '../../utils/legendSeries';
|
||||
import {
|
||||
SectionKind,
|
||||
ThresholdVariant,
|
||||
type SectionConfig,
|
||||
} from '../../types/sections';
|
||||
|
||||
// Declaring `fillOpacity` also makes the kind always-filled: `fillMode` drops `none`
|
||||
// and defaults to solid.
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: {
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
stackMode: true,
|
||||
fillSpans: true,
|
||||
},
|
||||
},
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
{
|
||||
kind: SectionKind.Legend,
|
||||
controls: { position: true, colors: resolveTimeSeriesLegendSeries },
|
||||
},
|
||||
{
|
||||
kind: SectionKind.ChartAppearance,
|
||||
controls: {
|
||||
lineStyle: true,
|
||||
lineInterpolation: true,
|
||||
fillMode: true,
|
||||
fillOpacity: true,
|
||||
showPoints: true,
|
||||
spanGaps: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.LABEL },
|
||||
},
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
@@ -1,160 +0,0 @@
|
||||
import type { DashboardtypesAreaChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
buildBaseConfig,
|
||||
minStepInterval,
|
||||
type TimeAxisChromeArgs,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import {
|
||||
LINE_INTERPOLATION_MAP,
|
||||
LINE_STYLE_MAP,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/enumMaps';
|
||||
import {
|
||||
resolveAreaFillMode,
|
||||
resolveSpanGaps,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/resolvers';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import {
|
||||
hasSingleVisiblePoint,
|
||||
toClickPluginPayload,
|
||||
} from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import {
|
||||
DrawStyle,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
const DEFAULT_POINT_SIZE = 5;
|
||||
|
||||
export interface BuildAreaChartConfigArgs extends TimeAxisChromeArgs {
|
||||
spec: DashboardtypesAreaChartPanelSpecDTO;
|
||||
/** Flat list of builder queries (see `getBuilderQueries`); powers per-query legend resolution. */
|
||||
builderQueries: BuilderQuery[];
|
||||
/** Flattened V5 series (see `flattenTimeSeries`). */
|
||||
series: PanelSeries[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a `UPlotConfigBuilder` for an Area panel: shared scaffolding plus one filled
|
||||
* series per result. Stacking is declared on the chart component instead, which hands
|
||||
* it to the builder.
|
||||
*/
|
||||
export function buildAreaChartConfig({
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
stepIntervals,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
onDragSelect,
|
||||
onClick,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
}: BuildAreaChartConfigArgs): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
panelId,
|
||||
isTimeAxis: true,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
isLogScale: spec.axes?.isLogScale,
|
||||
softMin: spec.axes?.softMin ?? undefined,
|
||||
softMax: spec.axes?.softMax ?? undefined,
|
||||
formatting: spec.formatting,
|
||||
thresholds: spec.thresholds,
|
||||
stepIntervals,
|
||||
clickPayload: toClickPluginPayload(series),
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
onClick,
|
||||
});
|
||||
|
||||
addSeries({
|
||||
builder,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
stepIntervals,
|
||||
isDarkMode,
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
interface AddSeriesArgs {
|
||||
builder: UPlotConfigBuilder;
|
||||
spec: DashboardtypesAreaChartPanelSpecDTO;
|
||||
builderQueries: BuilderQuery[];
|
||||
series: PanelSeries[];
|
||||
/** Per-query step intervals (seconds); floor for a numeric spanGaps threshold. */
|
||||
stepIntervals?: Record<string, number>;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one filled uPlot series per flattened V5 series; mutates the builder in place.
|
||||
* Order must match `prepareAlignedData` — both iterate the same flat list.
|
||||
*/
|
||||
function addSeries({
|
||||
builder,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
stepIntervals,
|
||||
isDarkMode,
|
||||
}: AddSeriesArgs): void {
|
||||
const chartAppearance = spec.chartAppearance;
|
||||
// `customColors` is nullable on the spec; coerce so `addSeries` always gets
|
||||
// a defined record (it dereferences keys without a guard).
|
||||
const colorMapping = spec.legend?.customColors ?? {};
|
||||
const resolvedSpanGaps = chartAppearance?.spanGaps
|
||||
? resolveSpanGaps(chartAppearance.spanGaps)
|
||||
: true;
|
||||
// A numeric spanGaps is a max-gap threshold (seconds); floor it at the step interval so a
|
||||
// sub-step value doesn't break the line at every normal point. Boolean `true` passes through.
|
||||
const minStep = stepIntervals ? minStepInterval(stepIntervals) : undefined;
|
||||
const spanGaps =
|
||||
typeof resolvedSpanGaps === 'number' && minStep !== undefined
|
||||
? Math.max(minStep, resolvedSpanGaps)
|
||||
: resolvedSpanGaps;
|
||||
|
||||
const lineStyle = chartAppearance?.lineStyle
|
||||
? LINE_STYLE_MAP[chartAppearance.lineStyle]
|
||||
: LineStyle.Solid;
|
||||
const lineInterpolation = chartAppearance?.lineInterpolation
|
||||
? LINE_INTERPOLATION_MAP[chartAppearance.lineInterpolation]
|
||||
: LineInterpolation.Spline;
|
||||
const fillMode = resolveAreaFillMode(chartAppearance?.fillMode);
|
||||
// Null and undefined both mean "kind default", which the chart layer resolves.
|
||||
const fillOpacity = chartAppearance?.fillOpacity ?? undefined;
|
||||
|
||||
series.forEach((s) => {
|
||||
const hasSingleValidPoint = hasSingleVisiblePoint(s.values);
|
||||
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
|
||||
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
// A single visible point can't be drawn as a line — degrade to points
|
||||
// so the user still sees the datum (matches V1 behavior).
|
||||
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
|
||||
label,
|
||||
colorMapping,
|
||||
spanGaps,
|
||||
lineStyle,
|
||||
lineInterpolation,
|
||||
showPoints: chartAppearance?.showPoints || hasSingleValidPoint,
|
||||
pointSize: DEFAULT_POINT_SIZE,
|
||||
fillMode,
|
||||
fillOpacity,
|
||||
isDarkMode,
|
||||
metric: s.labels,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { definition as AreaChart } from './kinds/AreaChartPanel/definition';
|
||||
import { definition as BarChart } from './kinds/BarChartPanel/definition';
|
||||
import { definition as Histogram } from './kinds/HistogramPanel/definition';
|
||||
import { definition as NumberValue } from './kinds/NumberPanel/definition';
|
||||
@@ -22,7 +21,6 @@ export const PANELS: PanelRegistry = {
|
||||
[NumberValue.kind]: NumberValue,
|
||||
[Table.kind]: Table,
|
||||
[BarChart.kind]: BarChart,
|
||||
[AreaChart.kind]: AreaChart,
|
||||
[PieChart.kind]: PieChart,
|
||||
[Histogram.kind]: Histogram,
|
||||
[List.kind]: List,
|
||||
|
||||
@@ -28,11 +28,6 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
};
|
||||
'signoz/AreaChartPanel': {
|
||||
onClick?: (event: DrilldownClickPayload) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
};
|
||||
'signoz/TablePanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/PieChartPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/NumberPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
|
||||
@@ -18,7 +18,6 @@ export type PanelKind = `${DashboardtypesPanelPluginKindDTO}`;
|
||||
export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
|
||||
'signoz/TimeSeriesPanel': PANEL_TYPES.TIME_SERIES,
|
||||
'signoz/BarChartPanel': PANEL_TYPES.BAR,
|
||||
'signoz/AreaChartPanel': PANEL_TYPES.AREA,
|
||||
'signoz/NumberPanel': PANEL_TYPES.VALUE,
|
||||
'signoz/PieChartPanel': PANEL_TYPES.PIE,
|
||||
'signoz/TablePanel': PANEL_TYPES.TABLE,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type {
|
||||
DashboardtypesLinkDTO,
|
||||
DashboardtypesAreaChartAppearanceDTO,
|
||||
DashboardtypesAreaChartVisualizationDTO,
|
||||
DashboardtypesAxesDTO,
|
||||
DashboardtypesBarChartVisualizationDTO,
|
||||
DashboardtypesComparisonThresholdDTO,
|
||||
@@ -89,27 +87,15 @@ export type AnyThreshold =
|
||||
export type PanelFormattingSlice = DashboardtypesPanelFormattingDTO &
|
||||
Pick<DashboardtypesTableFormattingDTO, 'columnUnits'>;
|
||||
|
||||
// Superset spanning every kind's chart-appearance DTO. Area's `fillMode` is a
|
||||
// nominally distinct enum with the same members as TimeSeries', so the TimeSeries one
|
||||
// types the shared control.
|
||||
export type PanelChartAppearanceSlice =
|
||||
DashboardtypesTimeSeriesChartAppearanceDTO &
|
||||
Pick<DashboardtypesAreaChartAppearanceDTO, 'fillOpacity'>;
|
||||
|
||||
// Superset spanning every kind's visualization DTO. Bar and Area express stacking
|
||||
// differently (`stackedBarChart` bool vs `stack` enum); a kind declares exactly one.
|
||||
export type PanelVisualizationSlice = DashboardtypesBarChartVisualizationDTO &
|
||||
Pick<DashboardtypesAreaChartVisualizationDTO, 'stack'>;
|
||||
|
||||
export interface SectionSpecMap {
|
||||
[SectionKind.Formatting]: PanelFormattingSlice; // spec.plugin.spec.formatting
|
||||
[SectionKind.Axes]: DashboardtypesAxesDTO; // spec.plugin.spec.axes
|
||||
[SectionKind.Legend]: DashboardtypesLegendDTO; // spec.plugin.spec.legend
|
||||
[SectionKind.ChartAppearance]: PanelChartAppearanceSlice; // spec.plugin.spec.chartAppearance
|
||||
[SectionKind.ChartAppearance]: DashboardtypesTimeSeriesChartAppearanceDTO; // spec.plugin.spec.chartAppearance
|
||||
[SectionKind.Buckets]: DashboardtypesHistogramBucketsDTO; // spec.plugin.spec.histogramBuckets
|
||||
// spec.plugin.spec.visualization — typed as the superset of every kind's shape;
|
||||
// spec.plugin.spec.visualization — typed as the Bar shape (widest superset);
|
||||
// the `controls` bag gates which fields each kind writes.
|
||||
[SectionKind.Visualization]: PanelVisualizationSlice;
|
||||
[SectionKind.Visualization]: DashboardtypesBarChartVisualizationDTO;
|
||||
[SectionKind.Thresholds]: AnyThreshold[]; // spec.plugin.spec.thresholds (variant picks the editor)
|
||||
[SectionKind.ContextLinks]: DashboardtypesLinkDTO[]; // spec.links (PANEL-level)
|
||||
[SectionKind.Columns]: TelemetrytypesTelemetryFieldKeyDTO[]; // spec.plugin.spec.selectFields (List)
|
||||
@@ -138,11 +124,6 @@ export interface SectionControls {
|
||||
lineStyle?: boolean;
|
||||
lineInterpolation?: boolean;
|
||||
fillMode?: boolean;
|
||||
/**
|
||||
* Declaring it also marks the kind always-filled: `fillMode` drops `none` to match
|
||||
* the narrower `AreaFillMode` wire enum the save API validates against.
|
||||
*/
|
||||
fillOpacity?: boolean;
|
||||
showPoints?: boolean;
|
||||
spanGaps?: boolean;
|
||||
};
|
||||
@@ -152,13 +133,12 @@ export interface SectionControls {
|
||||
mergeQueries?: boolean;
|
||||
};
|
||||
// switchPanelKind → the visualization-type switcher (every kind, so you can switch
|
||||
// away from any panel); stacking → stackedBarChart (Bar); stackMode → stack
|
||||
// (Area); fillSpans → fill gaps with 0 (TimeSeries / Area).
|
||||
// away from any panel); stacking → stackedBarChart (Bar); fillSpans → fill gaps with
|
||||
// 0 (TimeSeries).
|
||||
[SectionKind.Visualization]: {
|
||||
switchPanelKind: boolean;
|
||||
timePreference?: boolean;
|
||||
stacking?: boolean;
|
||||
stackMode?: boolean;
|
||||
fillSpans?: boolean;
|
||||
};
|
||||
// Editor discriminator (not a spec field): which threshold variant a kind edits.
|
||||
|
||||
@@ -5,14 +5,12 @@ import {
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesStackModeDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { defaultColumnsForSignal } from '../../../PanelEditor/ListColumnsEditor/selectFields';
|
||||
import { sections as areaSections } from '../../kinds/AreaChartPanel/sections';
|
||||
import { sections as listSections } from '../../kinds/ListPanel/sections';
|
||||
import { sections as timeSeriesSections } from '../../kinds/TimeSeriesPanel/sections';
|
||||
import {
|
||||
@@ -180,89 +178,6 @@ describe('buildPluginSpec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('translates Bar stacking into an Area stack mode', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: { switchPanelKind: true, stackMode: true },
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith({ visualization: { stackedBarChart: true } });
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).visualization).toStrictEqual({
|
||||
stack: DashboardtypesStackModeDTO.normal,
|
||||
});
|
||||
});
|
||||
|
||||
it('translates an Area stack mode into Bar stacking, collapsing percent', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: { switchPanelKind: true, stacking: true },
|
||||
},
|
||||
];
|
||||
|
||||
const fromPercent = oldSpecWith({
|
||||
visualization: { stack: DashboardtypesStackModeDTO.percent },
|
||||
});
|
||||
expect(
|
||||
buildPluginSpec(sections, { oldSpec: fromPercent }).visualization,
|
||||
).toStrictEqual({ stackedBarChart: true });
|
||||
|
||||
const fromNone = oldSpecWith({
|
||||
visualization: { stack: DashboardtypesStackModeDTO.none },
|
||||
});
|
||||
expect(
|
||||
buildPluginSpec(sections, { oldSpec: fromNone }).visualization,
|
||||
).toStrictEqual({ stackedBarChart: false });
|
||||
});
|
||||
|
||||
it('defaults a stack-mode kind to normal when there is nothing to carry', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: { switchPanelKind: true, stackMode: true },
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildPluginSpec(sections).visualization).toStrictEqual({
|
||||
stack: DashboardtypesStackModeDTO.normal,
|
||||
});
|
||||
expect(
|
||||
buildPluginSpec(sections, { oldSpec: oldSpecWith({}) }).visualization,
|
||||
).toStrictEqual({ stack: DashboardtypesStackModeDTO.normal });
|
||||
});
|
||||
|
||||
it('carries an Area stack mode unchanged between Area panels', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: { switchPanelKind: true, stackMode: true },
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
visualization: { stack: DashboardtypesStackModeDTO.percent },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).visualization).toStrictEqual({
|
||||
stack: DashboardtypesStackModeDTO.percent,
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds no stacking field when the target declares neither control', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Visualization, controls: { switchPanelKind: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
visualization: {
|
||||
stackedBarChart: true,
|
||||
stack: DashboardtypesStackModeDTO.percent,
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec })).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('carries old legend position but never customColors', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
@@ -349,99 +264,6 @@ describe('buildPluginSpec', () => {
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('defaults fillMode to solid for a kind that offers fill opacity', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.ChartAppearance,
|
||||
controls: { fillMode: true, fillOpacity: true },
|
||||
},
|
||||
];
|
||||
expect(buildPluginSpec(sections).chartAppearance).toStrictEqual({
|
||||
fillMode: DashboardtypesFillModeDTO.solid,
|
||||
});
|
||||
});
|
||||
|
||||
// `none` is absent from the AreaFillMode wire enum, so carrying it would fail the save.
|
||||
it('coerces an unfilled source fillMode to solid for a filled kind', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.ChartAppearance,
|
||||
controls: { fillMode: true, fillOpacity: true },
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
chartAppearance: { fillMode: DashboardtypesFillModeDTO.none },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).chartAppearance).toStrictEqual(
|
||||
{
|
||||
fillMode: DashboardtypesFillModeDTO.solid,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('carries a filled source fillMode unchanged', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.ChartAppearance,
|
||||
controls: { fillMode: true, fillOpacity: true },
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
chartAppearance: { fillMode: DashboardtypesFillModeDTO.gradient },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).chartAppearance).toStrictEqual(
|
||||
{
|
||||
fillMode: DashboardtypesFillModeDTO.gradient,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// TimeSeries keeps all three modes, so an Area -> TimeSeries switch needs no coercion.
|
||||
it('leaves fillMode alone for a kind that can be unfilled', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.ChartAppearance, controls: { fillMode: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
chartAppearance: { fillMode: DashboardtypesFillModeDTO.none },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).chartAppearance).toStrictEqual(
|
||||
{
|
||||
fillMode: DashboardtypesFillModeDTO.none,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('carries fillOpacity only when the target declares it, including 0', () => {
|
||||
const withOpacity: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.ChartAppearance,
|
||||
controls: { fillMode: true, fillOpacity: true },
|
||||
},
|
||||
];
|
||||
const withoutOpacity: SectionConfig[] = [
|
||||
{ kind: SectionKind.ChartAppearance, controls: { fillMode: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
chartAppearance: {
|
||||
fillMode: DashboardtypesFillModeDTO.gradient,
|
||||
fillOpacity: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
buildPluginSpec(withOpacity, { oldSpec }).chartAppearance,
|
||||
).toStrictEqual({
|
||||
fillMode: DashboardtypesFillModeDTO.gradient,
|
||||
fillOpacity: 0,
|
||||
});
|
||||
expect(
|
||||
buildPluginSpec(withoutOpacity, { oldSpec }).chartAppearance,
|
||||
).toStrictEqual({ fillMode: DashboardtypesFillModeDTO.gradient });
|
||||
});
|
||||
|
||||
it('carries old values over the defaults, gated by the declared controls', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
@@ -729,21 +551,6 @@ describe('buildPluginSpec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds the full Area default set, filled solid and stacked', () => {
|
||||
expect(buildPluginSpec(areaSections)).toStrictEqual({
|
||||
visualization: {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
stack: DashboardtypesStackModeDTO.normal,
|
||||
},
|
||||
legend: { position: DashboardtypesLegendPositionDTO.bottom },
|
||||
chartAppearance: {
|
||||
lineStyle: DashboardtypesLineStyleDTO.solid,
|
||||
lineInterpolation: DashboardtypesLineInterpolationDTO.spline,
|
||||
fillMode: DashboardtypesFillModeDTO.solid,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty spec for List (only switchPanelKind, nothing to seed)', () => {
|
||||
expect(buildPluginSpec(listSections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesStackModeDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
DashboardtypesTextAlignDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
@@ -120,43 +119,6 @@ function isEmptySlice(value: object): boolean {
|
||||
: Object.keys(value).length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates stacking across a Bar↔Area switch rather than dropping it. Area's
|
||||
* `percent` has no bar equivalent, so it collapses to stacked-on; a stack-mode kind
|
||||
* with nothing to carry starts on `normal`.
|
||||
*/
|
||||
function seedStacking(
|
||||
controls: SectionControls[SectionKind.Visualization],
|
||||
old: SectionSpecMap[SectionKind.Visualization] | undefined,
|
||||
): Pick<
|
||||
SectionSpecMap[SectionKind.Visualization],
|
||||
'stack' | 'stackedBarChart'
|
||||
> {
|
||||
if (controls.stacking) {
|
||||
if (old?.stackedBarChart !== undefined) {
|
||||
return { stackedBarChart: old.stackedBarChart };
|
||||
}
|
||||
if (old?.stack !== undefined) {
|
||||
return { stackedBarChart: old.stack !== DashboardtypesStackModeDTO.none };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
if (controls.stackMode) {
|
||||
if (old?.stack !== undefined) {
|
||||
return { stack: old.stack };
|
||||
}
|
||||
if (old?.stackedBarChart !== undefined) {
|
||||
return {
|
||||
stack: old.stackedBarChart
|
||||
? DashboardtypesStackModeDTO.normal
|
||||
: DashboardtypesStackModeDTO.none,
|
||||
};
|
||||
}
|
||||
return { stack: DashboardtypesStackModeDTO.normal };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const SECTION_SEEDS: SectionSeeds = {
|
||||
[SectionKind.TextLayout]: {
|
||||
specKey: 'presentation',
|
||||
@@ -196,7 +158,10 @@ const SECTION_SEEDS: SectionSeeds = {
|
||||
timePreference:
|
||||
old?.timePreference ?? DashboardtypesTimePreferenceDTO.global_time,
|
||||
}),
|
||||
...seedStacking(controls, old),
|
||||
...(controls.stacking &&
|
||||
old?.stackedBarChart !== undefined && {
|
||||
stackedBarChart: old.stackedBarChart,
|
||||
}),
|
||||
...(controls.fillSpans &&
|
||||
old?.fillSpans !== undefined && { fillSpans: old.fillSpans }),
|
||||
};
|
||||
@@ -239,8 +204,7 @@ const SECTION_SEEDS: SectionSeeds = {
|
||||
const {
|
||||
lineStyle = DashboardtypesLineStyleDTO.solid,
|
||||
lineInterpolation = DashboardtypesLineInterpolationDTO.spline,
|
||||
fillMode,
|
||||
fillOpacity,
|
||||
fillMode = DashboardtypesFillModeDTO.none,
|
||||
showPoints,
|
||||
spanGaps,
|
||||
} = oldPluginSpec?.chartAppearance ?? {};
|
||||
@@ -252,16 +216,7 @@ const SECTION_SEEDS: SectionSeeds = {
|
||||
appearance.lineInterpolation = lineInterpolation;
|
||||
}
|
||||
if (controls.fillMode) {
|
||||
const carried = fillMode ?? DashboardtypesFillModeDTO.none;
|
||||
// An always-filled kind's wire enum has no `none`, so the save API would
|
||||
// reject it. Keyed off the capability, not the kind.
|
||||
appearance.fillMode =
|
||||
controls.fillOpacity && carried === DashboardtypesFillModeDTO.none
|
||||
? DashboardtypesFillModeDTO.solid
|
||||
: carried;
|
||||
}
|
||||
if (controls.fillOpacity && typeof fillOpacity === 'number') {
|
||||
appearance.fillOpacity = fillOpacity;
|
||||
appearance.fillMode = fillMode;
|
||||
}
|
||||
if (controls.showPoints && showPoints !== undefined) {
|
||||
appearance.showPoints = showPoints;
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
import {
|
||||
DashboardtypesAreaFillModeDTO,
|
||||
DashboardtypesStackModeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { FillMode, StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import {
|
||||
resolveAreaFillMode,
|
||||
resolveSpanGaps,
|
||||
resolveStackMode,
|
||||
} from '../resolvers';
|
||||
import { resolveSpanGaps } from '../resolvers';
|
||||
|
||||
describe('resolveSpanGaps', () => {
|
||||
it('parses a duration string into seconds when thresholding', () => {
|
||||
@@ -43,43 +33,3 @@ describe('resolveSpanGaps', () => {
|
||||
expect(resolveSpanGaps({ fillLessThan: '5m' })).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAreaFillMode', () => {
|
||||
it('maps each wire value to its chart fill mode', () => {
|
||||
expect(resolveAreaFillMode(DashboardtypesAreaFillModeDTO.solid)).toBe(
|
||||
FillMode.Solid,
|
||||
);
|
||||
expect(resolveAreaFillMode(DashboardtypesAreaFillModeDTO.gradient)).toBe(
|
||||
FillMode.Gradient,
|
||||
);
|
||||
});
|
||||
|
||||
// Includes a stale `none`, which the area wire enum no longer carries.
|
||||
it('falls back to solid for a missing or unknown value', () => {
|
||||
expect(resolveAreaFillMode(undefined)).toBe(FillMode.Solid);
|
||||
expect(resolveAreaFillMode('none' as DashboardtypesAreaFillModeDTO)).toBe(
|
||||
FillMode.Solid,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveStackMode', () => {
|
||||
it('maps each wire value to its chart stack mode', () => {
|
||||
expect(resolveStackMode(DashboardtypesStackModeDTO.none)).toBe(
|
||||
StackMode.None,
|
||||
);
|
||||
expect(resolveStackMode(DashboardtypesStackModeDTO.normal)).toBe(
|
||||
StackMode.Normal,
|
||||
);
|
||||
expect(resolveStackMode(DashboardtypesStackModeDTO.percent)).toBe(
|
||||
StackMode.Percent,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to none for a missing or unknown value', () => {
|
||||
expect(resolveStackMode(undefined)).toBe(StackMode.None);
|
||||
expect(resolveStackMode('stretch' as DashboardtypesStackModeDTO)).toBe(
|
||||
StackMode.None,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import {
|
||||
DashboardtypesAreaFillModeDTO,
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
DashboardtypesStackModeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
FillMode,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
StackMode,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
|
||||
/**
|
||||
@@ -41,21 +38,6 @@ export const FILL_MODE_MAP: Record<DashboardtypesFillModeDTO, FillMode> = {
|
||||
[DashboardtypesFillModeDTO.none]: FillMode.None,
|
||||
};
|
||||
|
||||
/** Narrower than TimeSeries' — an area panel is always filled, so there is no `none`. */
|
||||
export const AREA_FILL_MODE_MAP: Record<
|
||||
DashboardtypesAreaFillModeDTO,
|
||||
FillMode
|
||||
> = {
|
||||
[DashboardtypesAreaFillModeDTO.solid]: FillMode.Solid,
|
||||
[DashboardtypesAreaFillModeDTO.gradient]: FillMode.Gradient,
|
||||
};
|
||||
|
||||
export const STACK_MODE_MAP: Record<DashboardtypesStackModeDTO, StackMode> = {
|
||||
[DashboardtypesStackModeDTO.none]: StackMode.None,
|
||||
[DashboardtypesStackModeDTO.normal]: StackMode.Normal,
|
||||
[DashboardtypesStackModeDTO.percent]: StackMode.Percent,
|
||||
};
|
||||
|
||||
export const LEGEND_POSITION_MAP: Record<
|
||||
DashboardtypesLegendPositionDTO,
|
||||
LegendPosition
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { rangeUtil } from '@grafana/data';
|
||||
import {
|
||||
type DashboardtypesAreaFillModeDTO,
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesPrecisionOptionDTO,
|
||||
type DashboardtypesSpanGapsDTO,
|
||||
type DashboardtypesStackModeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { FillMode, StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import {
|
||||
AREA_FILL_MODE_MAP,
|
||||
LEGEND_POSITION_MAP,
|
||||
STACK_MODE_MAP,
|
||||
} from './enumMaps';
|
||||
import { LEGEND_POSITION_MAP } from './enumMaps';
|
||||
|
||||
// Resolvers turning raw `spec` chart-appearance fields into runtime chart
|
||||
// values, falling back to chart defaults for missing/unknown input.
|
||||
@@ -72,23 +65,3 @@ export function resolveLegendPosition(
|
||||
}
|
||||
return LegendPosition.BOTTOM;
|
||||
}
|
||||
|
||||
/** Missing/unknown falls back to `Solid`; an area panel is never a bare line. */
|
||||
export function resolveAreaFillMode(
|
||||
fillMode: DashboardtypesAreaFillModeDTO | undefined,
|
||||
): FillMode {
|
||||
if (fillMode && fillMode in AREA_FILL_MODE_MAP) {
|
||||
return AREA_FILL_MODE_MAP[fillMode];
|
||||
}
|
||||
return FillMode.Solid;
|
||||
}
|
||||
|
||||
/** Missing/unknown falls back to `None` — series drawn independently. */
|
||||
export function resolveStackMode(
|
||||
stack: DashboardtypesStackModeDTO | undefined,
|
||||
): StackMode {
|
||||
if (stack && stack in STACK_MODE_MAP) {
|
||||
return STACK_MODE_MAP[stack];
|
||||
}
|
||||
return StackMode.None;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,6 @@ export function panelTypeToRequestType(
|
||||
switch (panelType) {
|
||||
case PANEL_TYPES.TIME_SERIES:
|
||||
case PANEL_TYPES.BAR:
|
||||
case PANEL_TYPES.AREA:
|
||||
case PANEL_TYPES.HISTOGRAM:
|
||||
return Querybuildertypesv5RequestTypeDTO.time_series;
|
||||
case PANEL_TYPES.TABLE:
|
||||
|
||||
@@ -12,7 +12,6 @@ export const panelTypeToExplorerView: Record<PANEL_TYPES, ExplorerViews> = {
|
||||
[PANEL_TYPES.TABLE]: ExplorerViews.TABLE,
|
||||
[PANEL_TYPES.VALUE]: ExplorerViews.TIMESERIES,
|
||||
[PANEL_TYPES.BAR]: ExplorerViews.TIMESERIES,
|
||||
[PANEL_TYPES.AREA]: ExplorerViews.TIMESERIES,
|
||||
[PANEL_TYPES.PIE]: ExplorerViews.TIMESERIES,
|
||||
[PANEL_TYPES.HISTOGRAM]: ExplorerViews.TIMESERIES,
|
||||
// Dashboard-only visualisation; explorers never offer it.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -16,10 +17,11 @@ type setter struct {
|
||||
alertmanager alertmanager.Alertmanager
|
||||
quickfilter quickfilter.Module
|
||||
dashboard dashboard.Module
|
||||
spanMapper spanmapper.Module
|
||||
}
|
||||
|
||||
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, dashboard dashboard.Module) organization.Setter {
|
||||
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, dashboard: dashboard}
|
||||
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, dashboard dashboard.Module, spanMapper spanmapper.Module) organization.Setter {
|
||||
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, dashboard: dashboard, spanMapper: spanMapper}
|
||||
}
|
||||
|
||||
func (module *setter) Create(ctx context.Context, organization *types.Organization, createManagedRoles func(context.Context, valuer.UUID) error) error {
|
||||
@@ -43,6 +45,10 @@ func (module *setter) Create(ctx context.Context, organization *types.Organizati
|
||||
return err
|
||||
}
|
||||
|
||||
if err := module.spanMapper.ReconcileSystemGroups(ctx, organization.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
46
pkg/modules/spanmapper/implspanmapper/definitions.go
Normal file
46
pkg/modules/spanmapper/implspanmapper/definitions.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package implspanmapper
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"path"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
)
|
||||
|
||||
const definitionsRoot = "fs/definitions"
|
||||
|
||||
//go:embed fs/definitions/*.json
|
||||
var definitionFiles embed.FS
|
||||
|
||||
// NewSystemGroupRegistry parses every embedded definition. Definitions are
|
||||
// build-time assets validated by a test, so a failure here means the binary
|
||||
// shipped broken JSON.
|
||||
func NewSystemGroupRegistry() (spantypes.SpanMapperGroupRegistry, error) {
|
||||
entries, err := fs.ReadDir(definitionFiles, definitionsRoot)
|
||||
if err != nil {
|
||||
return spantypes.SpanMapperGroupRegistry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read span mapper group definitions")
|
||||
}
|
||||
|
||||
definitions := make([]spantypes.SpanMapperGroupDefinition, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
file := path.Join(definitionsRoot, entry.Name())
|
||||
raw, err := definitionFiles.ReadFile(file)
|
||||
if err != nil {
|
||||
return spantypes.SpanMapperGroupRegistry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read %s", file)
|
||||
}
|
||||
|
||||
definition, err := spantypes.NewSpanMapperGroupDefinition(raw)
|
||||
if err != nil {
|
||||
return spantypes.SpanMapperGroupRegistry{}, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't parse %s", file)
|
||||
}
|
||||
definitions = append(definitions, definition)
|
||||
}
|
||||
|
||||
return spantypes.NewSpanMapperGroupRegistry(definitions)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"version": 1,
|
||||
"definition": {
|
||||
"name": "gen_ai.agent",
|
||||
"condition": {
|
||||
"attributes": [
|
||||
{
|
||||
"value": "agent"
|
||||
}
|
||||
],
|
||||
"resource": []
|
||||
},
|
||||
"enabled": true,
|
||||
"mappers": [
|
||||
{
|
||||
"name": "gen_ai.agent.name",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "agent.name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "agent_name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.agent.id",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "agent.id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.agent.description",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "agent.description",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.output.messages",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "final_result",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
{
|
||||
"version": 1,
|
||||
"definition": {
|
||||
"name": "gen_ai.llm",
|
||||
"condition": {
|
||||
"attributes": [
|
||||
{
|
||||
"value": "model"
|
||||
}
|
||||
],
|
||||
"resource": []
|
||||
},
|
||||
"enabled": true,
|
||||
"mappers": [
|
||||
{
|
||||
"name": "gen_ai.request.model",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "llm.model_name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 60
|
||||
},
|
||||
{
|
||||
"key": "llm.request.model",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 50
|
||||
},
|
||||
{
|
||||
"key": "ai.model.id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "langfuse.observation.model.name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "embedding.model_name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "model",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.response.model",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "llm.response.model",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.response.model",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.provider.name",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.system",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 50
|
||||
},
|
||||
{
|
||||
"key": "llm.vendor",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "llm.provider",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "llm.system",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.model.provider",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.operation.name",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "llm.request.type",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.usage.input_tokens",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.usage.prompt_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 50
|
||||
},
|
||||
{
|
||||
"key": "llm.usage.prompt_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "llm.token_count.prompt",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "ai.usage.inputTokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.usage.promptTokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.usage.output_tokens",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.usage.completion_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 50
|
||||
},
|
||||
{
|
||||
"key": "llm.usage.completion_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "llm.token_count.completion",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "ai.usage.outputTokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.usage.completionTokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.usage.cache_read.input_tokens",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.usage.cache_read_input_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "llm.token_count.prompt_details.cache_read",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.usage.cachedInputTokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.usage.cache_creation.input_tokens",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.usage.cache_write.input_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "gen_ai.usage.cache_creation_input_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "llm.token_count.prompt_details.cache_write",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.input.messages",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.prompt",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "ai.prompt.messages",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "input.value",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.output.messages",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.completion",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "ai.response.text",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "output.value",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.conversation.id",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "session.id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "langfuse.session.id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.response.finish_reason",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "ai.response.finishReason",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"version": 1,
|
||||
"definition": {
|
||||
"name": "gen_ai.tool",
|
||||
"condition": {
|
||||
"attributes": [
|
||||
{
|
||||
"value": "tool"
|
||||
}
|
||||
],
|
||||
"resource": []
|
||||
},
|
||||
"enabled": true,
|
||||
"mappers": [
|
||||
{
|
||||
"name": "gen_ai.tool.name",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "tool.name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.toolCall.name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.tool.call.id",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "tool.id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.toolCall.id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.tool.description",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "tool.description",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.tool.call.arguments",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "ai.toolCall.args",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "traceloop.entity.input",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "gcp.vertex.agent.tool_call_args",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "input.value",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.tool.call.result",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "ai.toolCall.result",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "traceloop.entity.output",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "gcp.vertex.agent.tool_response",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "output.value",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,10 @@ func (h *handler) CreateGroup(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
group := spantypes.NewSpanMapperGroup(orgID, claims.Email, req)
|
||||
|
||||
@@ -191,6 +195,10 @@ func (h *handler) CreateMapper(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
mapper := spantypes.NewSpanMapper(groupID, claims.Email, req)
|
||||
|
||||
if err := h.module.CreateMapper(ctx, orgID, groupID, mapper); err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
@@ -14,13 +15,24 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// maxTestSpans bounds the input size: every test request boots a full
|
||||
// in-memory collector pipeline and is reachable with viewer access.
|
||||
const maxTestSpans = 100
|
||||
|
||||
type module struct {
|
||||
store spantypes.SpanMapperStore
|
||||
flagger flagger.Flagger
|
||||
store spantypes.SpanMapperStore
|
||||
flagger flagger.Flagger
|
||||
registry spantypes.SpanMapperGroupRegistry
|
||||
settings factory.ScopedProviderSettings
|
||||
}
|
||||
|
||||
func NewModule(store spantypes.SpanMapperStore, flagger flagger.Flagger) spanmapper.Module {
|
||||
return &module{store: store, flagger: flagger}
|
||||
func NewModule(store spantypes.SpanMapperStore, flagger flagger.Flagger, registry spantypes.SpanMapperGroupRegistry, providerSettings factory.ProviderSettings) spanmapper.Module {
|
||||
return &module{
|
||||
store: store,
|
||||
flagger: flagger,
|
||||
registry: registry,
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"),
|
||||
}
|
||||
}
|
||||
|
||||
func (module *module) ListGroups(ctx context.Context, orgID valuer.UUID, q *spantypes.ListSpanMapperGroupsQuery) ([]*spantypes.SpanMapperGroup, error) {
|
||||
@@ -32,6 +44,9 @@ func (module *module) GetGroup(ctx context.Context, orgID, id valuer.UUID) (*spa
|
||||
}
|
||||
|
||||
func (module *module) CreateGroup(ctx context.Context, orgID valuer.UUID, group *spantypes.SpanMapperGroup) error {
|
||||
if module.registry.IsReserved(group.Name) {
|
||||
return errors.Newf(errors.TypeInvalidInput, spantypes.ErrCodeMappingGroupNameReserved, "group name %q is reserved for a default group", group.Name)
|
||||
}
|
||||
return module.store.CreateGroup(ctx, group)
|
||||
}
|
||||
|
||||
@@ -40,10 +55,14 @@ func (module *module) UpdateGroup(ctx context.Context, orgID, id valuer.UUID, na
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
group.Update(name, condition, enabled, updatedBy)
|
||||
if name != nil && *name != group.Name && module.registry.IsReserved(*name) {
|
||||
return errors.Newf(errors.TypeInvalidInput, spantypes.ErrCodeMappingGroupNameReserved, "group name %q is reserved for a default group", *name)
|
||||
}
|
||||
if err := group.Update(name, condition, enabled, updatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.UpdateGroup(ctx, group)
|
||||
if err != nil {
|
||||
if err := module.store.UpdateGroup(ctx, group); err != nil {
|
||||
return err
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
@@ -51,8 +70,7 @@ func (module *module) UpdateGroup(ctx context.Context, orgID, id valuer.UUID, na
|
||||
}
|
||||
|
||||
func (module *module) DeleteGroup(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
err := module.store.DeleteGroup(ctx, orgID, id)
|
||||
if err != nil {
|
||||
if err := module.store.DeleteGroup(ctx, orgID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
@@ -81,14 +99,13 @@ func (module *module) CreateMapper(ctx context.Context, orgID, groupID valuer.UU
|
||||
}
|
||||
|
||||
func (module *module) UpdateMapper(ctx context.Context, orgID, groupID, id valuer.UUID, fieldContext spantypes.FieldContext, config *spantypes.SpanMapperConfig, enabled *bool, updatedBy string) error {
|
||||
if _, err := module.store.GetGroup(ctx, orgID, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
mapper, err := module.store.GetMapper(ctx, orgID, groupID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mapper.Update(fieldContext, config, enabled, updatedBy)
|
||||
if err := mapper.Update(fieldContext, config, enabled, updatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
err = module.store.UpdateMapper(ctx, mapper)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -98,18 +115,13 @@ func (module *module) UpdateMapper(ctx context.Context, orgID, groupID, id value
|
||||
}
|
||||
|
||||
func (module *module) DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error {
|
||||
err := module.store.DeleteMapper(ctx, orgID, groupID, id)
|
||||
if err != nil {
|
||||
if err := module.store.DeleteMapper(ctx, orgID, groupID, id, spantypes.SpanMapperOriginUser); err != nil {
|
||||
return err
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxTestSpans bounds the input size: every test request boots a full
|
||||
// in-memory collector pipeline and is reachable with viewer access.
|
||||
const maxTestSpans = 100
|
||||
|
||||
func (module *module) TestMappers(ctx context.Context, orgID valuer.UUID, spans []spantypes.SpanMapperTestSpan, groups []*spantypes.SpanMapperGroupWithMappers) ([]spantypes.SpanMapperTestSpan, []string, error) {
|
||||
if len(spans) == 0 {
|
||||
return nil, nil, errors.New(errors.TypeInvalidInput, spantypes.ErrCodeMappingInvalidInput, "'spans' must contain at least one span")
|
||||
@@ -130,37 +142,6 @@ func (module *module) TestMappers(ctx context.Context, orgID valuer.UUID, spans
|
||||
return out, collectorLogs, nil
|
||||
}
|
||||
|
||||
// backfillMappers loads saved mappers for any enabled group whose Mappers is
|
||||
// nil. Disabled groups are skipped: the simulation filters them out anyway,
|
||||
// so there is no point loading their mappers or failing on their names.
|
||||
func (module *module) backfillMappers(ctx context.Context, orgID valuer.UUID, groups []*spantypes.SpanMapperGroupWithMappers) ([]*spantypes.SpanMapperGroupWithMappers, error) {
|
||||
savedGroups, err := module.store.ListGroups(ctx, orgID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
savedByName := make(map[string]*spantypes.SpanMapperGroup, len(savedGroups))
|
||||
for _, g := range savedGroups {
|
||||
savedByName[g.Name] = g
|
||||
}
|
||||
|
||||
// For each group in the request, if Mappers is nil, load the saved mappers for that group name.
|
||||
for _, g := range groups {
|
||||
if g.Mappers != nil || !g.Group.Enabled {
|
||||
continue
|
||||
}
|
||||
saved, ok := savedByName[g.Group.Name]
|
||||
if !ok {
|
||||
return nil, errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "no saved group named %q to load mappers from; send 'mappers' for new or edited groups", g.Group.Name)
|
||||
}
|
||||
loaded, err := module.store.ListMappers(ctx, orgID, saved.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Mappers = loaded
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func (module *module) AgentFeatureType() agentConf.AgentFeatureType {
|
||||
return spantypes.SpanAttrMappingFeatureType
|
||||
}
|
||||
@@ -196,6 +177,37 @@ func (module *module) RecommendAgentConfig(orgID valuer.UUID, currentConfYaml []
|
||||
return updatedConf, string(serialized), nil
|
||||
}
|
||||
|
||||
// backfillMappers loads saved mappers for any enabled group whose Mappers is
|
||||
// nil. Disabled groups are skipped: the simulation filters them out anyway,
|
||||
// so there is no point loading their mappers or failing on their names.
|
||||
func (module *module) backfillMappers(ctx context.Context, orgID valuer.UUID, groups []*spantypes.SpanMapperGroupWithMappers) ([]*spantypes.SpanMapperGroupWithMappers, error) {
|
||||
savedGroups, err := module.store.ListGroups(ctx, orgID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
savedByName := make(map[string]*spantypes.SpanMapperGroup, len(savedGroups))
|
||||
for _, g := range savedGroups {
|
||||
savedByName[g.Name] = g
|
||||
}
|
||||
|
||||
// For each group in the request, if Mappers is nil, load the saved mappers for that group name.
|
||||
for _, g := range groups {
|
||||
if g.Mappers != nil || !g.Group.Enabled {
|
||||
continue
|
||||
}
|
||||
saved, ok := savedByName[g.Group.Name]
|
||||
if !ok {
|
||||
return nil, errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "no saved group named %q to load mappers from; send 'mappers' for new or edited groups", g.Group.Name)
|
||||
}
|
||||
loaded, err := module.store.ListMappers(ctx, orgID, saved.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Mappers = loaded
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// listEnabledGroupsWithMappers returns groups with their mappers.
|
||||
func (module *module) listEnabledGroupsWithMappers(ctx context.Context, orgID valuer.UUID) ([]*spantypes.SpanMapperGroupWithMappers, error) {
|
||||
enabled := true
|
||||
|
||||
228
pkg/modules/spanmapper/implspanmapper/module_test.go
Normal file
228
pkg/modules/spanmapper/implspanmapper/module_test.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package implspanmapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testUser = "user@signoz.io"
|
||||
|
||||
func newTestSQLStore(t *testing.T) sqlstore.SQLStore {
|
||||
t.Helper()
|
||||
|
||||
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
|
||||
Provider: "sqlite",
|
||||
Connection: sqlstore.ConnectionConfig{MaxOpenConns: 10},
|
||||
Sqlite: sqlstore.SqliteConfig{
|
||||
Path: filepath.Join(t.TempDir(), "test.db"),
|
||||
Mode: "wal",
|
||||
BusyTimeout: 5 * time.Second,
|
||||
TransactionMode: "deferred",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, model := range []any{
|
||||
(*spantypes.StorableSpanMapperGroup)(nil),
|
||||
(*spantypes.StorableSpanMapper)(nil),
|
||||
} {
|
||||
_, err := store.BunDB().NewCreateTable().Model(model).IfNotExists().Exec(context.Background())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_span_mapper_group_org_name ON span_mapper_group (org_id, name)`)
|
||||
require.NoError(t, err)
|
||||
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_span_mapper_group_name ON span_mapper (group_id, name)`)
|
||||
require.NoError(t, err)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func newTestModule(t *testing.T, sqlStore sqlstore.SQLStore, definitions ...spantypes.SpanMapperGroupDefinition) *module {
|
||||
t.Helper()
|
||||
|
||||
registry, err := spantypes.NewSpanMapperGroupRegistry(definitions)
|
||||
require.NoError(t, err)
|
||||
|
||||
return NewModule(NewStore(sqlStore), nil, registry, factorytest.NewSettings()).(*module)
|
||||
}
|
||||
|
||||
func newTestDefinition(t *testing.T, version int, body string) spantypes.SpanMapperGroupDefinition {
|
||||
t.Helper()
|
||||
|
||||
definition, err := spantypes.NewSpanMapperGroupDefinition([]byte(`{"version": ` + strconv.Itoa(version) + `, "definition": ` + body + `}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
return definition
|
||||
}
|
||||
|
||||
// llmV1 ships two mappers; llmV2 renames a source, adds a mapper and drops one.
|
||||
const llmV1 = `{
|
||||
"name": "llm",
|
||||
"condition": {"attributes": [{"value": "model"}], "resource": []},
|
||||
"enabled": true,
|
||||
"mappers": [
|
||||
{"name": "gen_ai.request.model", "fieldContext": "attribute", "config": {"sources": [
|
||||
{"key": "llm.model_name", "context": "attribute", "operation": "copy", "priority": 20},
|
||||
{"key": "ai.model.id", "context": "attribute", "operation": "copy", "priority": 10}
|
||||
]}},
|
||||
{"name": "gen_ai.input.messages", "fieldContext": "attribute", "config": {"sources": [
|
||||
{"key": "gen_ai.prompt", "context": "attribute", "operation": "copy", "priority": 10}
|
||||
]}}
|
||||
]
|
||||
}`
|
||||
|
||||
const llmV2 = `{
|
||||
"name": "llm",
|
||||
"condition": {"attributes": [{"value": "model"}, {"value": "llm."}], "resource": []},
|
||||
"enabled": true,
|
||||
"mappers": [
|
||||
{"name": "gen_ai.request.model", "fieldContext": "attribute", "config": {"sources": [
|
||||
{"key": "llm.model_name", "context": "attribute", "operation": "copy", "priority": 20},
|
||||
{"key": "langfuse.observation.model.name", "context": "attribute", "operation": "copy", "priority": 10}
|
||||
]}},
|
||||
{"name": "gen_ai.provider.name", "fieldContext": "attribute", "config": {"sources": [
|
||||
{"key": "llm.vendor", "context": "attribute", "operation": "copy", "priority": 10}
|
||||
]}}
|
||||
]
|
||||
}`
|
||||
|
||||
func findMapper(t *testing.T, mappers []*spantypes.SpanMapper, name string) *spantypes.SpanMapper {
|
||||
t.Helper()
|
||||
for _, m := range mappers {
|
||||
if m.Name == name {
|
||||
return m
|
||||
}
|
||||
}
|
||||
require.Failf(t, "mapper not found", "no mapper named %q", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func findSource(t *testing.T, sources []spantypes.SpanMapperSource, key string, origin spantypes.SpanMapperOrigin) spantypes.SpanMapperSource {
|
||||
t.Helper()
|
||||
for _, s := range sources {
|
||||
if s.Key == key && s.Origin == origin {
|
||||
return s
|
||||
}
|
||||
}
|
||||
require.Failf(t, "source not found", "no %s source with key %q", origin.StringValue(), key)
|
||||
return spantypes.SpanMapperSource{}
|
||||
}
|
||||
|
||||
func TestReconcileUpgradeKeepsTogglesAndUserItems(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
orgID := valuer.GenerateUUID()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
v1 := newTestModule(t, sqlStore, newTestDefinition(t, 1, llmV1))
|
||||
require.NoError(t, v1.ReconcileSystemGroups(ctx, orgID))
|
||||
|
||||
group, err := v1.store.GetGroupByName(ctx, orgID, "llm")
|
||||
require.NoError(t, err)
|
||||
mappers, err := v1.ListMappers(ctx, orgID, group.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Switch the shipped substring off and add a user one.
|
||||
off := false
|
||||
require.NoError(t, v1.UpdateGroup(ctx, orgID, group.ID, nil, &spantypes.SpanMapperGroupCondition{
|
||||
Attributes: []spantypes.SpanMapperGroupConditionKey{
|
||||
{Value: "model", Enabled: false, Origin: spantypes.SpanMapperOriginSystem},
|
||||
{Value: "gen_ai.request.model", Enabled: true, Origin: spantypes.SpanMapperOriginUser},
|
||||
},
|
||||
Resource: []spantypes.SpanMapperGroupConditionKey{},
|
||||
}, &off, testUser))
|
||||
|
||||
// Switch a shipped source off, add a user override, and switch the mapper off.
|
||||
model := findMapper(t, mappers, "gen_ai.request.model")
|
||||
require.NoError(t, v1.UpdateMapper(ctx, orgID, group.ID, model.ID, spantypes.FieldContext{}, &spantypes.SpanMapperConfig{Sources: []spantypes.SpanMapperSource{
|
||||
{Key: "llm.model_name", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationCopy, Priority: 20, Enabled: false, Origin: spantypes.SpanMapperOriginSystem},
|
||||
{Key: "llm.model_name", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationMove, Priority: 1, Enabled: true, Origin: spantypes.SpanMapperOriginUser},
|
||||
}}, &off, testUser))
|
||||
|
||||
// Add a user source to the mapper v2 stops shipping, so it must survive.
|
||||
messages := findMapper(t, mappers, "gen_ai.input.messages")
|
||||
require.NoError(t, v1.UpdateMapper(ctx, orgID, group.ID, messages.ID, spantypes.FieldContext{}, &spantypes.SpanMapperConfig{Sources: []spantypes.SpanMapperSource{
|
||||
{Key: "input.value", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationCopy, Priority: 1, Enabled: true},
|
||||
}}, nil, testUser))
|
||||
|
||||
// A user mapper in the shipped group.
|
||||
require.NoError(t, v1.CreateMapper(ctx, orgID, group.ID, spantypes.NewSpanMapper(group.ID, testUser, &spantypes.PostableSpanMapper{
|
||||
Name: "gen_ai.custom", FieldContext: spantypes.FieldContextSpanAttribute, Enabled: true,
|
||||
Config: spantypes.SpanMapperConfig{Sources: []spantypes.SpanMapperSource{{Key: "custom", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationCopy, Priority: 1, Enabled: true}}},
|
||||
})))
|
||||
|
||||
v2 := newTestModule(t, sqlStore, newTestDefinition(t, 2, llmV2))
|
||||
require.NoError(t, v2.ReconcileSystemGroups(ctx, orgID))
|
||||
|
||||
upgraded, err := v2.GetGroup(ctx, orgID, group.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, upgraded.Version)
|
||||
assert.False(t, upgraded.Enabled)
|
||||
assert.Equal(t, spantypes.ProvisionerIdentity, upgraded.UpdatedBy)
|
||||
assert.Equal(t, []spantypes.SpanMapperGroupConditionKey{
|
||||
{Value: "model", Enabled: false, Origin: spantypes.SpanMapperOriginSystem},
|
||||
{Value: "llm.", Enabled: true, Origin: spantypes.SpanMapperOriginSystem},
|
||||
{Value: "gen_ai.request.model", Enabled: true, Origin: spantypes.SpanMapperOriginUser},
|
||||
}, upgraded.Condition.Attributes)
|
||||
|
||||
mappers, err = v2.ListMappers(ctx, orgID, group.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, mappers, 4)
|
||||
|
||||
model = findMapper(t, mappers, "gen_ai.request.model")
|
||||
assert.False(t, model.Enabled)
|
||||
assert.Equal(t, spantypes.SpanMapperOriginSystem, model.Origin)
|
||||
assert.False(t, findSource(t, model.Config.Sources, "llm.model_name", spantypes.SpanMapperOriginSystem).Enabled)
|
||||
assert.True(t, findSource(t, model.Config.Sources, "langfuse.observation.model.name", spantypes.SpanMapperOriginSystem).Enabled)
|
||||
assert.Equal(t, spantypes.SpanMapperOperationMove, findSource(t, model.Config.Sources, "llm.model_name", spantypes.SpanMapperOriginUser).Operation)
|
||||
assert.Len(t, model.Config.Sources, 3)
|
||||
|
||||
messages = findMapper(t, mappers, "gen_ai.input.messages")
|
||||
assert.Equal(t, spantypes.SpanMapperOriginUser, messages.Origin)
|
||||
require.Len(t, messages.Config.Sources, 1)
|
||||
assert.Equal(t, "input.value", messages.Config.Sources[0].Key)
|
||||
|
||||
assert.Equal(t, spantypes.SpanMapperOriginSystem, findMapper(t, mappers, "gen_ai.provider.name").Origin)
|
||||
assert.Equal(t, spantypes.SpanMapperOriginUser, findMapper(t, mappers, "gen_ai.custom").Origin)
|
||||
|
||||
// Shipping v1 again drops provider.name outright (no user sources) and
|
||||
// re-adopts the surviving user mapper input.messages as a shipped one.
|
||||
v3 := newTestModule(t, sqlStore, newTestDefinition(t, 3, llmV1))
|
||||
require.NoError(t, v3.ReconcileSystemGroups(ctx, orgID))
|
||||
mappers, err = v3.ListMappers(ctx, orgID, group.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, mappers, 3)
|
||||
for _, m := range mappers {
|
||||
assert.NotEqual(t, "gen_ai.provider.name", m.Name)
|
||||
}
|
||||
messages = findMapper(t, mappers, "gen_ai.input.messages")
|
||||
assert.Equal(t, spantypes.SpanMapperOriginSystem, messages.Origin)
|
||||
assert.Len(t, messages.Config.Sources, 2)
|
||||
}
|
||||
|
||||
func TestReconcileDoesNotDowngrade(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
orgID := valuer.GenerateUUID()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
|
||||
require.NoError(t, newTestModule(t, sqlStore, newTestDefinition(t, 2, llmV2)).ReconcileSystemGroups(ctx, orgID))
|
||||
older := newTestModule(t, sqlStore, newTestDefinition(t, 1, llmV1))
|
||||
require.NoError(t, older.ReconcileSystemGroups(ctx, orgID))
|
||||
|
||||
group, err := older.store.GetGroupByName(ctx, orgID, "llm")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, group.Version)
|
||||
mappers, err := older.ListMappers(ctx, orgID, group.ID)
|
||||
require.NoError(t, err)
|
||||
findMapper(t, mappers, "gen_ai.provider.name")
|
||||
}
|
||||
187
pkg/modules/spanmapper/implspanmapper/reconcile.go
Normal file
187
pkg/modules/spanmapper/implspanmapper/reconcile.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package implspanmapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func (module *module) ReconcileSystemGroups(ctx context.Context, orgID valuer.UUID) error {
|
||||
for _, definition := range module.registry.List() {
|
||||
if err := module.reconcileSystemGroup(ctx, orgID, definition); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcileSystemGroup brings one org's copy of a definition to the shipped
|
||||
// version in a single transaction. A concurrent provisioner (another replica,
|
||||
// or the org-creation hook racing the startup sweep) loses on the group's
|
||||
// unique (org_id, name) index and is treated as a no-op.
|
||||
func (module *module) reconcileSystemGroup(ctx context.Context, orgID valuer.UUID, definition spantypes.SpanMapperGroupDefinition) error {
|
||||
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
group, err := module.store.GetGroupByName(ctx, orgID, definition.Name())
|
||||
if err != nil && errors.Ast(err, errors.TypeNotFound) {
|
||||
group = newSystemGroup(orgID, definition)
|
||||
err = module.store.CreateGroup(ctx, group)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if group.Origin != spantypes.SpanMapperOriginSystem {
|
||||
module.settings.Logger().WarnContext(ctx, "skipping default span mapper group: a user group holds its name", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
if group.Version >= definition.Version {
|
||||
return nil
|
||||
}
|
||||
return module.applyDefinition(ctx, orgID, group, definition)
|
||||
})
|
||||
if err != nil && errors.Ast(err, errors.TypeAlreadyExists) {
|
||||
module.settings.Logger().DebugContext(ctx, "default span mapper group provisioned concurrently", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// applyDefinition replaces every shipped item with the definition, carrying each
|
||||
// enabled flag over by identity, and leaves user items untouched. A mapper that
|
||||
// is no longer shipped is deleted unless the user added sources to it, in which
|
||||
// case it survives as a user mapper.
|
||||
func (module *module) applyDefinition(ctx context.Context, orgID valuer.UUID, group *spantypes.SpanMapperGroup, definition spantypes.SpanMapperGroupDefinition) error {
|
||||
mappers, err := module.store.ListMappers(ctx, orgID, group.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byName := make(map[string]*spantypes.SpanMapper, len(mappers))
|
||||
for _, m := range mappers {
|
||||
byName[m.Name] = m
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for i := range definition.Definition.Mappers {
|
||||
pm := &definition.Definition.Mappers[i]
|
||||
mapper, exists := byName[pm.Name]
|
||||
delete(byName, pm.Name)
|
||||
if !exists {
|
||||
if err := module.store.CreateMapper(ctx, newSystemMapper(group.ID, pm)); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
mapper.Config.Sources = mergeShippedSources(mapper.Config.Sources, pm.Config.Sources)
|
||||
mapper.FieldContext = pm.FieldContext
|
||||
mapper.Origin = spantypes.SpanMapperOriginSystem
|
||||
mapper.UpdatedAt = now
|
||||
mapper.UpdatedBy = spantypes.ProvisionerIdentity
|
||||
if err := module.store.UpdateMapper(ctx, mapper); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever is left in byName is not shipped any more.
|
||||
for _, mapper := range byName {
|
||||
if mapper.Origin != spantypes.SpanMapperOriginSystem {
|
||||
continue
|
||||
}
|
||||
mapper.Config.Sources = mergeShippedSources(mapper.Config.Sources, nil)
|
||||
if len(mapper.Config.Sources) == 0 {
|
||||
if err := module.store.DeleteMapper(ctx, orgID, group.ID, mapper.ID, spantypes.SpanMapperOriginSystem); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
mapper.Origin = spantypes.SpanMapperOriginUser
|
||||
mapper.UpdatedAt = now
|
||||
mapper.UpdatedBy = spantypes.ProvisionerIdentity
|
||||
if err := module.store.UpdateMapper(ctx, mapper); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
shipped := definition.Definition.Condition
|
||||
group.Condition = spantypes.SpanMapperGroupCondition{
|
||||
Attributes: mergeShippedConditionKeys(group.Condition.Attributes, shipped.Attributes),
|
||||
Resource: mergeShippedConditionKeys(group.Condition.Resource, shipped.Resource),
|
||||
}
|
||||
group.Version = definition.Version
|
||||
group.UpdatedAt = now
|
||||
group.UpdatedBy = spantypes.ProvisionerIdentity
|
||||
if err := module.store.UpdateGroup(ctx, group); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
module.settings.Logger().InfoContext(ctx, "applied default span mapper group", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// newSystemGroup is the empty shell applyDefinition fills: version 0 so the
|
||||
// definition is applied right after the row exists.
|
||||
func newSystemGroup(orgID valuer.UUID, definition spantypes.SpanMapperGroupDefinition) *spantypes.SpanMapperGroup {
|
||||
group := spantypes.NewSpanMapperGroup(orgID, spantypes.ProvisionerIdentity, &definition.Definition.PostableSpanMapperGroup)
|
||||
group.Condition = definition.Definition.Condition
|
||||
group.Enabled = true
|
||||
group.Origin = spantypes.SpanMapperOriginSystem
|
||||
return group
|
||||
}
|
||||
|
||||
func newSystemMapper(groupID valuer.UUID, pm *spantypes.PostableSpanMapper) *spantypes.SpanMapper {
|
||||
mapper := spantypes.NewSpanMapper(groupID, spantypes.ProvisionerIdentity, pm)
|
||||
mapper.Config = pm.Config
|
||||
mapper.Enabled = true
|
||||
mapper.Origin = spantypes.SpanMapperOriginSystem
|
||||
return mapper
|
||||
}
|
||||
|
||||
// mergeShippedConditionKeys returns the shipped keys, each keeping the enabled
|
||||
// flag of the stored system key with the same value, followed by the stored
|
||||
// user keys.
|
||||
func mergeShippedConditionKeys(stored, shipped []spantypes.SpanMapperGroupConditionKey) []spantypes.SpanMapperGroupConditionKey {
|
||||
out := make([]spantypes.SpanMapperGroupConditionKey, 0, len(stored)+len(shipped))
|
||||
for _, k := range shipped {
|
||||
idx := slices.IndexFunc(stored, func(s spantypes.SpanMapperGroupConditionKey) bool {
|
||||
return s.Origin == spantypes.SpanMapperOriginSystem && s.Value == k.Value
|
||||
})
|
||||
if idx != -1 {
|
||||
k.Enabled = stored[idx].Enabled
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
for _, k := range stored {
|
||||
if k.Origin != spantypes.SpanMapperOriginSystem {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeShippedSources returns the shipped sources, each keeping the enabled
|
||||
// flag of the stored system source with the same key and context, followed by
|
||||
// the stored user sources.
|
||||
func mergeShippedSources(stored, shipped []spantypes.SpanMapperSource) []spantypes.SpanMapperSource {
|
||||
out := make([]spantypes.SpanMapperSource, 0, len(stored)+len(shipped))
|
||||
for _, s := range shipped {
|
||||
idx := slices.IndexFunc(stored, func(o spantypes.SpanMapperSource) bool {
|
||||
return o.Origin == spantypes.SpanMapperOriginSystem && o.Key == s.Key && o.Context == s.Context
|
||||
})
|
||||
if idx != -1 {
|
||||
s.Enabled = stored[idx].Enabled
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
for _, s := range stored {
|
||||
if s.Origin != spantypes.SpanMapperOriginSystem {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
81
pkg/modules/spanmapper/implspanmapper/service.go
Normal file
81
pkg/modules/spanmapper/implspanmapper/service.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package implspanmapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
)
|
||||
|
||||
const reconcileRetryInterval = 30 * time.Second
|
||||
|
||||
type service struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
module spanmapper.Module
|
||||
orgGetter organization.Getter
|
||||
stopC chan struct{}
|
||||
healthyC chan struct{}
|
||||
}
|
||||
|
||||
// NewService reconciles every org's default mapping groups once at startup.
|
||||
// Orgs created later are reconciled by the organization setter instead.
|
||||
func NewService(providerSettings factory.ProviderSettings, module spanmapper.Module, orgGetter organization.Getter) factory.Service {
|
||||
return &service{
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"),
|
||||
module: module,
|
||||
orgGetter: orgGetter,
|
||||
stopC: make(chan struct{}),
|
||||
healthyC: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (service *service) Start(ctx context.Context) error {
|
||||
ticker := time.NewTicker(reconcileRetryInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
err := service.reconcile(ctx)
|
||||
if err == nil {
|
||||
close(service.healthyC)
|
||||
<-service.stopC
|
||||
return nil
|
||||
}
|
||||
|
||||
service.settings.Logger().WarnContext(ctx, "default span mapper group reconciliation failed, retrying", errors.Attr(err))
|
||||
|
||||
select {
|
||||
case <-service.stopC:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (service *service) Healthy() <-chan struct{} {
|
||||
return service.healthyC
|
||||
}
|
||||
|
||||
func (service *service) Stop(_ context.Context) error {
|
||||
close(service.stopC)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *service) reconcile(ctx context.Context) error {
|
||||
orgs, err := service.orgGetter.ListByOwnedKeyRange(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
if err := service.module.ReconcileSystemGroups(ctx, org.ID); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't reconcile default span mapper groups for org %s", org.ID.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
service.settings.Logger().InfoContext(ctx, "default span mapper group reconciliation completed", slog.Int("orgs", len(orgs)))
|
||||
return nil
|
||||
}
|
||||
@@ -17,6 +17,10 @@ func NewStore(sqlstore sqlstore.SQLStore) spantypes.SpanMapperStore {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (s *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
|
||||
return s.sqlstore.RunInTxCtx(ctx, nil, cb)
|
||||
}
|
||||
|
||||
func (s *store) CreateGroup(ctx context.Context, group *spantypes.SpanMapperGroup) error {
|
||||
storable := group.ToStorable()
|
||||
_, err := s.sqlstore.
|
||||
@@ -34,7 +38,7 @@ func (s *store) GetGroup(ctx context.Context, orgID, id valuer.UUID) (*spantypes
|
||||
storable := new(spantypes.StorableSpanMapperGroup)
|
||||
|
||||
err := s.sqlstore.
|
||||
BunDB().
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("org_id = ?", orgID).
|
||||
@@ -46,11 +50,27 @@ func (s *store) GetGroup(ctx context.Context, orgID, id valuer.UUID) (*spantypes
|
||||
return storable.ToSpanMapperGroup(), nil
|
||||
}
|
||||
|
||||
func (s *store) GetGroupByName(ctx context.Context, orgID valuer.UUID, name string) (*spantypes.SpanMapperGroup, error) {
|
||||
storable := new(spantypes.StorableSpanMapperGroup)
|
||||
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("name = ?", name).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, s.sqlstore.WrapNotFoundErrf(err, spantypes.ErrCodeMappingGroupNotFound, "span mapper group %q not found", name)
|
||||
}
|
||||
return storable.ToSpanMapperGroup(), nil
|
||||
}
|
||||
|
||||
func (s *store) ListGroups(ctx context.Context, orgID valuer.UUID, q *spantypes.ListSpanMapperGroupsQuery) ([]*spantypes.SpanMapperGroup, error) {
|
||||
storables := make([]*spantypes.StorableSpanMapperGroup, 0)
|
||||
|
||||
sel := s.sqlstore.
|
||||
BunDB().
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&storables).
|
||||
Where("org_id = ?", orgID)
|
||||
@@ -91,38 +111,43 @@ func (s *store) UpdateGroup(ctx context.Context, group *spantypes.SpanMapperGrou
|
||||
}
|
||||
|
||||
func (s *store) DeleteGroup(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
tx, err := s.sqlstore.BunDBCtx(ctx).BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
return s.RunInTx(ctx, func(ctx context.Context) error {
|
||||
db := s.sqlstore.BunDBCtx(ctx)
|
||||
|
||||
// Cascade: remove mappers belonging to this group first.
|
||||
if _, err := tx.NewDelete().
|
||||
Model((*spantypes.StorableSpanMapper)(nil)).
|
||||
Where("group_id = ?", id).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// Cascade: remove mappers belonging to this group first.
|
||||
if _, err := db.NewDelete().
|
||||
Model((*spantypes.StorableSpanMapper)(nil)).
|
||||
Where("group_id = ?", id).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := tx.NewDelete().
|
||||
Model((*spantypes.StorableSpanMapperGroup)(nil)).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := db.NewDelete().
|
||||
Model((*spantypes.StorableSpanMapperGroup)(nil)).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Where("origin = ?", spantypes.SpanMapperOriginUser).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "span mapper group %s not found", id)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
group, err := s.GetGroup(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := group.ErrIfNotDeletable(); err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "span mapper group %s not found", id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *store) CreateMapper(ctx context.Context, mapper *spantypes.SpanMapper) error {
|
||||
@@ -146,7 +171,7 @@ func (s *store) GetMapper(ctx context.Context, orgID, groupID, id valuer.UUID) (
|
||||
|
||||
storable := new(spantypes.StorableSpanMapper)
|
||||
err := s.sqlstore.
|
||||
BunDB().
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("group_id = ?", groupID).
|
||||
@@ -166,7 +191,7 @@ func (s *store) ListMappers(ctx context.Context, orgID, groupID valuer.UUID) ([]
|
||||
|
||||
storables := make([]*spantypes.StorableSpanMapper, 0)
|
||||
if err := s.sqlstore.
|
||||
BunDB().
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&storables).
|
||||
Where("group_id = ?", groupID).
|
||||
@@ -200,7 +225,7 @@ func (s *store) UpdateMapper(ctx context.Context, mapper *spantypes.SpanMapper)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error {
|
||||
func (s *store) DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID, origin spantypes.SpanMapperOrigin) error {
|
||||
if _, err := s.GetGroup(ctx, orgID, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -211,6 +236,7 @@ func (s *store) DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID
|
||||
Model((*spantypes.StorableSpanMapper)(nil)).
|
||||
Where("group_id = ?", groupID).
|
||||
Where("id = ?", id).
|
||||
Where("origin = ?", origin).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -221,6 +247,13 @@ func (s *store) DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
mapper, err := s.GetMapper(ctx, orgID, groupID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mapper.ErrIfNotDeletable(); err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMapperNotFound, "span mapper %s not found", id)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -28,6 +28,10 @@ type Module interface {
|
||||
UpdateMapper(ctx context.Context, orgID, groupID, id valuer.UUID, fieldContext spantypes.FieldContext, config *spantypes.SpanMapperConfig, enabled *bool, updatedBy string) error
|
||||
DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error
|
||||
TestMappers(ctx context.Context, orgID valuer.UUID, spans []spantypes.SpanMapperTestSpan, groups []*spantypes.SpanMapperGroupWithMappers) ([]spantypes.SpanMapperTestSpan, []string, error)
|
||||
|
||||
// ReconcileSystemGroups provisions or upgrades the shipped mapping groups
|
||||
// for one org. It runs at startup for every org and again on org creation.
|
||||
ReconcileSystemGroups(ctx context.Context, orgID valuer.UUID) error
|
||||
}
|
||||
|
||||
// Handler defines the HTTP handler interface for mapping group and mapper endpoints.
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
@@ -61,7 +62,10 @@ func TestNewHandlers(t *testing.T) {
|
||||
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
|
||||
|
||||
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil)
|
||||
spanMapperRegistry, err := implspanmapper.NewSystemGroupRegistry()
|
||||
require.NoError(t, err)
|
||||
spanMapperModule := implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), flagger, spanMapperRegistry, providerSettings)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil, spanMapperModule)
|
||||
|
||||
querierHandler := querier.NewHandler(providerSettings, nil, nil)
|
||||
registryHandler := factory.NewHandler(nil)
|
||||
|
||||
@@ -45,7 +45,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session/implsession"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
@@ -124,9 +123,10 @@ func NewModules(
|
||||
fl flagger.Flagger,
|
||||
tagModule tag.Module,
|
||||
metricReductionRule metricreductionrule.Module,
|
||||
spanMapper spanmapper.Module,
|
||||
) Modules {
|
||||
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
|
||||
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter, dashboard)
|
||||
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter, dashboard, spanMapper)
|
||||
// Cleanup callbacks from other modules, invoked when a user is deleted.
|
||||
onDeleteUser := []user.OnDeleteUser{
|
||||
dashboard.DeletePreferencesForUser,
|
||||
@@ -162,7 +162,7 @@ func NewModules(
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
|
||||
SpanMapper: spanMapper,
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
|
||||
Tag: tagModule,
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
@@ -68,7 +69,11 @@ func TestNewModules(t *testing.T) {
|
||||
|
||||
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
|
||||
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule())
|
||||
spanMapperRegistry, err := implspanmapper.NewSystemGroupRegistry()
|
||||
require.NoError(t, err)
|
||||
spanMapperModule := implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), flagger, spanMapperRegistry, providerSettings)
|
||||
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule(), spanMapperModule)
|
||||
|
||||
reflectVal := reflect.ValueOf(modules)
|
||||
for i := 0; i < reflectVal.NumField(); i++ {
|
||||
|
||||
@@ -254,6 +254,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddIngestionTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddSubscriptionTuplesFactory(sqlstore),
|
||||
sqlmigration.NewNormalizeQuickFilterFieldsFactory(sqlstore),
|
||||
sqlmigration.NewAddSpanMapperOriginFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
@@ -549,7 +550,15 @@ func New(
|
||||
metricReductionRuleModule := metricReductionRuleModuleCallback(sqlstore, telemetrystore, dashboard, queryParser, licensing, flagger, telemetryMetadataStore, providerSettings, config.MetricsExplorer.TelemetryStore.Threads)
|
||||
|
||||
// Initialize all modules
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule)
|
||||
// The default mapping group registry is parsed here so a malformed embedded
|
||||
// definition fails startup instead of a request.
|
||||
spanMapperRegistry, err := implspanmapper.NewSystemGroupRegistry()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spanMapperModule := implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), flagger, spanMapperRegistry, providerSettings)
|
||||
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule, spanMapperModule)
|
||||
|
||||
// Initialize ruler from the variant-specific provider factories
|
||||
rulerInstance, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Ruler, rulerProviderFactories(cache, alertmanager, sqlstore, telemetrystore, telemetryMetadataStore, prometheus, orgGetter, modules.RuleStateHistory, querier, queryParser), "signoz")
|
||||
@@ -619,6 +628,7 @@ func New(
|
||||
factory.NewNamedService(factory.MustNewName("meterreporter"), meterReporter, factory.MustNewName("licensing")),
|
||||
factory.NewNamedService(factory.MustNewName("ruler"), rulerInstance),
|
||||
factory.NewNamedService(factory.MustNewName("systemdashboard"), impldashboard.NewService(providerSettings, dashboard, orgGetter)),
|
||||
factory.NewNamedService(factory.MustNewName("spanmappergroup"), implspanmapper.NewService(providerSettings, spanMapperModule, orgGetter)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
93
pkg/sqlmigration/127_add_span_mapper_origin.go
Normal file
93
pkg/sqlmigration/127_add_span_mapper_origin.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addSpanMapperOrigin struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
func NewAddSpanMapperOriginFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("add_span_mapper_origin"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addSpanMapperOrigin{sqlstore: sqlstore, sqlschema: sqlschema}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (migration *addSpanMapperOrigin) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
// Up adds the ownership columns that let SigNoz ship default mapping groups
|
||||
// alongside user ones.
|
||||
func (migration *addSpanMapperOrigin) Up(ctx context.Context, db *bun.DB) error {
|
||||
// span_mapper references span_mapper_group and both have foreign keys, so
|
||||
// enforcement must be off for the SQLite recreate-table fallback.
|
||||
if err := migration.sqlschema.ToggleFKEnforcement(ctx, db, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
groupTable, groupUniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("span_mapper_group"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqls := migration.sqlschema.Operator().AddColumn(groupTable, groupUniqueConstraints, &sqlschema.Column{
|
||||
Name: sqlschema.ColumnName("origin"),
|
||||
DataType: sqlschema.DataTypeText,
|
||||
Nullable: false,
|
||||
Default: "'user'",
|
||||
}, "user")
|
||||
sqls = append(sqls, migration.sqlschema.Operator().AddColumn(groupTable, groupUniqueConstraints, &sqlschema.Column{
|
||||
Name: sqlschema.ColumnName("version"),
|
||||
DataType: sqlschema.DataTypeBigInt,
|
||||
Nullable: false,
|
||||
Default: "0",
|
||||
}, 0)...)
|
||||
|
||||
mapperTable, mapperUniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("span_mapper"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqls = append(sqls, migration.sqlschema.Operator().AddColumn(mapperTable, mapperUniqueConstraints, &sqlschema.Column{
|
||||
Name: sqlschema.ColumnName("origin"),
|
||||
DataType: sqlschema.DataTypeText,
|
||||
Nullable: false,
|
||||
Default: "'user'",
|
||||
}, "user")...)
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return migration.sqlschema.ToggleFKEnforcement(ctx, db, true)
|
||||
}
|
||||
|
||||
func (migration *addSpanMapperOrigin) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1467,264 +1467,6 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaChartPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/AreaChartPanel",
|
||||
"spec": {}
|
||||
},
|
||||
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
d, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &AreaChartPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*AreaChartPanelSpec)
|
||||
|
||||
assert.Equal(t, "solid", spec.ChartAppearance.FillMode.ValueOrDefault(), "area fillMode defaults to solid, where the TimeSeries FillMode defaults to none")
|
||||
assert.Nil(t, spec.ChartAppearance.FillOpacity, "an omitted fillOpacity stays nil so the renderer applies the kind default")
|
||||
assert.Equal(t, "none", spec.Visualization.Stack.ValueOrDefault(), "expected Stack default none")
|
||||
assert.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
|
||||
assert.Equal(t, "spline", spec.ChartAppearance.LineInterpolation.ValueOrDefault(), "expected LineInterpolation default spline")
|
||||
assert.Equal(t, "solid", spec.ChartAppearance.LineStyle.ValueOrDefault(), "expected LineStyle default solid")
|
||||
assert.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
|
||||
assert.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
|
||||
assert.Equal(t, "list", spec.Legend.Mode.ValueOrDefault(), "expected LegendMode default list")
|
||||
|
||||
output, err := json.Marshal(d)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
outputStr := string(output)
|
||||
for field, want := range map[string]string{
|
||||
"fillMode": `"solid"`,
|
||||
"stack": `"none"`,
|
||||
"fillOpacity": `null`,
|
||||
} {
|
||||
assert.Contains(t, outputStr, `"`+field+`":`+want, "expected stored/response JSON to contain %s:%s", field, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaChartPanelRoundTrip(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/AreaChartPanel",
|
||||
"spec": {
|
||||
"visualization": {"timePreference": "global_time", "fillSpans": false, "stack": "percent"},
|
||||
"chartAppearance": {"fillMode": "gradient", "fillOpacity": 0.4}
|
||||
}
|
||||
},
|
||||
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
d, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &AreaChartPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*AreaChartPanelSpec)
|
||||
|
||||
assert.Equal(t, "percent", spec.Visualization.Stack.ValueOrDefault(), "expected stack percent")
|
||||
assert.Equal(t, "gradient", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected fillMode gradient")
|
||||
|
||||
output, err := json.Marshal(d)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
assert.Contains(t, string(output), `"stack":"percent"`, "expected stack in stored/response JSON")
|
||||
assert.Contains(t, string(output), `"fillMode":"gradient"`, "expected fillMode in stored/response JSON")
|
||||
}
|
||||
|
||||
func TestAreaChartPanelFillOpacity(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
chartAppearance string
|
||||
expectedFillOpacitySet bool
|
||||
expectedFillOpacityValue FillOpacity
|
||||
expectedMarshalledJSON string
|
||||
}{
|
||||
{
|
||||
scenario: "zero is a set value, not an absent one",
|
||||
chartAppearance: `{"fillOpacity": 0}`,
|
||||
expectedFillOpacitySet: true,
|
||||
expectedFillOpacityValue: 0,
|
||||
expectedMarshalledJSON: `"fillOpacity":0`,
|
||||
},
|
||||
{
|
||||
scenario: "fully opaque upper bound",
|
||||
chartAppearance: `{"fillOpacity": 1}`,
|
||||
expectedFillOpacitySet: true,
|
||||
expectedFillOpacityValue: 1,
|
||||
expectedMarshalledJSON: `"fillOpacity":1`,
|
||||
},
|
||||
{
|
||||
scenario: "typical fractional value",
|
||||
chartAppearance: `{"fillOpacity": 0.4}`,
|
||||
expectedFillOpacitySet: true,
|
||||
expectedFillOpacityValue: 0.4,
|
||||
expectedMarshalledJSON: `"fillOpacity":0.4`,
|
||||
},
|
||||
{
|
||||
scenario: "precision beyond one decimal place survives",
|
||||
chartAppearance: `{"fillOpacity": 0.125}`,
|
||||
expectedFillOpacitySet: true,
|
||||
expectedFillOpacityValue: 0.125,
|
||||
expectedMarshalledJSON: `"fillOpacity":0.125`,
|
||||
},
|
||||
{
|
||||
scenario: "omitted field stays nil so the renderer applies the kind default",
|
||||
chartAppearance: `{}`,
|
||||
expectedFillOpacitySet: false,
|
||||
expectedMarshalledJSON: `"fillOpacity":null`,
|
||||
},
|
||||
{
|
||||
scenario: "explicit null stays nil rather than decoding as zero",
|
||||
chartAppearance: `{"fillOpacity": null}`,
|
||||
expectedFillOpacitySet: false,
|
||||
expectedMarshalledJSON: `"fillOpacity":null`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.scenario, func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/AreaChartPanel", "spec": {"chartAppearance": ` + test.chartAppearance + `}},
|
||||
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
d, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &AreaChartPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*AreaChartPanelSpec)
|
||||
|
||||
if !test.expectedFillOpacitySet {
|
||||
assert.Nil(t, spec.ChartAppearance.FillOpacity, "expected fillOpacity to stay unset")
|
||||
} else {
|
||||
require.NotNil(t, spec.ChartAppearance.FillOpacity, "expected fillOpacity to decode as a set value")
|
||||
assert.Equal(t, test.expectedFillOpacityValue, *spec.ChartAppearance.FillOpacity, "unexpected decoded fillOpacity")
|
||||
}
|
||||
|
||||
output, err := json.Marshal(d)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
assert.Contains(t, string(output), test.expectedMarshalledJSON, "unexpected fillOpacity in stored/response JSON")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateAreaChartPanelSpecValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
panelKind string
|
||||
panelSpec string
|
||||
expectedErrorSubstring string
|
||||
}{
|
||||
{
|
||||
scenario: "unknown stack mode",
|
||||
panelKind: "signoz/AreaChartPanel",
|
||||
panelSpec: `{"visualization": {"stack": "stacked"}}`,
|
||||
expectedErrorSubstring: "stack mode",
|
||||
},
|
||||
{
|
||||
scenario: "unknown area fill mode",
|
||||
panelKind: "signoz/AreaChartPanel",
|
||||
panelSpec: `{"chartAppearance": {"fillMode": "striped"}}`,
|
||||
expectedErrorSubstring: "fill mode",
|
||||
},
|
||||
{
|
||||
scenario: "fill opacity on a 0-100 scale",
|
||||
panelKind: "signoz/AreaChartPanel",
|
||||
panelSpec: `{"chartAppearance": {"fillOpacity": 40}}`,
|
||||
expectedErrorSubstring: "invalid fillOpacity 40: must be between 0 and 1",
|
||||
},
|
||||
{
|
||||
scenario: "negative fill opacity",
|
||||
panelKind: "signoz/AreaChartPanel",
|
||||
panelSpec: `{"chartAppearance": {"fillOpacity": -0.5}}`,
|
||||
expectedErrorSubstring: "invalid fillOpacity -0.5: must be between 0 and 1",
|
||||
},
|
||||
{
|
||||
scenario: "non-numeric fill opacity",
|
||||
panelKind: "signoz/AreaChartPanel",
|
||||
panelSpec: `{"chartAppearance": {"fillOpacity": "0.4"}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal string",
|
||||
},
|
||||
{
|
||||
scenario: "stack on a time series panel",
|
||||
panelKind: "signoz/TimeSeriesPanel",
|
||||
panelSpec: `{"visualization": {"stack": "normal"}}`,
|
||||
expectedErrorSubstring: `unknown field`,
|
||||
},
|
||||
{
|
||||
scenario: "fill opacity on a time series panel",
|
||||
panelKind: "signoz/TimeSeriesPanel",
|
||||
panelSpec: `{"chartAppearance": {"fillOpacity": 0.4}}`,
|
||||
expectedErrorSubstring: `unknown field`,
|
||||
},
|
||||
{
|
||||
scenario: "stacked bar chart on an area panel",
|
||||
panelKind: "signoz/AreaChartPanel",
|
||||
panelSpec: `{"visualization": {"stackedBarChart": true}}`,
|
||||
expectedErrorSubstring: `unknown field`,
|
||||
},
|
||||
{
|
||||
scenario: "stack on a bar chart panel",
|
||||
panelKind: "signoz/BarChartPanel",
|
||||
panelSpec: `{"visualization": {"stack": "percent"}}`,
|
||||
expectedErrorSubstring: `unknown field`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.scenario, func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "` + test.panelKind + `", "spec": ` + test.panelSpec + `},
|
||||
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected the spec to be rejected")
|
||||
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumberPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
|
||||
@@ -30,7 +30,6 @@ func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return markDiscriminator(s, "kind", map[string]string{
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
@@ -62,7 +61,6 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
PanelPluginVariant[TimeSeriesPanelSpec]{Kind: string(PanelKindTimeSeries)},
|
||||
PanelPluginVariant[BarChartPanelSpec]{Kind: string(PanelKindBarChart)},
|
||||
PanelPluginVariant[AreaChartPanelSpec]{Kind: string(PanelKindAreaChart)},
|
||||
PanelPluginVariant[NumberPanelSpec]{Kind: string(PanelKindNumber)},
|
||||
PanelPluginVariant[PieChartPanelSpec]{Kind: string(PanelKindPieChart)},
|
||||
PanelPluginVariant[TablePanelSpec]{Kind: string(PanelKindTable)},
|
||||
@@ -227,7 +225,6 @@ var (
|
||||
panelPluginSpecs = map[PanelPluginKind]func() any{
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
@@ -251,7 +248,6 @@ var (
|
||||
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
|
||||
@@ -188,8 +188,7 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// fillGaps lives on the panel visualization; only timeseries, bar chart and
|
||||
// area chart carry it.
|
||||
// fillGaps lives on the panel visualization; only timeseries and bar chart carry it.
|
||||
fillGaps := false
|
||||
switch panelSpec := panel.Spec.Plugin.Spec.(type) {
|
||||
case *TimeSeriesPanelSpec:
|
||||
@@ -200,10 +199,6 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
|
||||
if panelSpec != nil {
|
||||
fillGaps = panelSpec.Visualization.FillSpans
|
||||
}
|
||||
case *AreaChartPanelSpec:
|
||||
if panelSpec != nil {
|
||||
fillGaps = panelSpec.Visualization.FillSpans
|
||||
}
|
||||
}
|
||||
|
||||
return &qb.QueryRangeRequest{
|
||||
|
||||
@@ -168,7 +168,6 @@ type PanelPluginKind string
|
||||
const (
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
@@ -178,7 +177,7 @@ const (
|
||||
)
|
||||
|
||||
func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText}
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText}
|
||||
}
|
||||
|
||||
func (k PanelPluginKind) rendersWithoutQuery() bool {
|
||||
@@ -210,30 +209,6 @@ type BarChartPanelSpec struct {
|
||||
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
type AreaChartPanelSpec struct {
|
||||
Visualization AreaChartVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
ChartAppearance AreaChartAppearance `json:"chartAppearance"`
|
||||
Axes Axes `json:"axes"`
|
||||
Legend Legend `json:"legend"`
|
||||
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
// AreaChartAppearance repeats the line-drawing fields rather than embedding
|
||||
// TimeSeriesChartAppearance: both carry a `fillMode` under different enums, and
|
||||
// a duplicated json tag across an embed boundary is resolved by depth, which the
|
||||
// schema reflector does not model.
|
||||
type AreaChartAppearance struct {
|
||||
LineInterpolation LineInterpolation `json:"lineInterpolation"`
|
||||
ShowPoints bool `json:"showPoints"`
|
||||
LineStyle LineStyle `json:"lineStyle"`
|
||||
FillMode AreaFillMode `json:"fillMode"`
|
||||
// FillOpacity is a pointer so an omitted field resolves to the kind default at
|
||||
// render time; a plain value would make the Go zero value a transparent fill.
|
||||
FillOpacity *FillOpacity `json:"fillOpacity"`
|
||||
SpanGaps SpanGaps `json:"spanGaps"`
|
||||
}
|
||||
|
||||
type NumberPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
@@ -312,12 +287,6 @@ type BarChartVisualization struct {
|
||||
StackedBarChart bool `json:"stackedBarChart"`
|
||||
}
|
||||
|
||||
type AreaChartVisualization struct {
|
||||
BasicVisualization
|
||||
FillSpans bool `json:"fillSpans"`
|
||||
Stack StackMode `json:"stack"`
|
||||
}
|
||||
|
||||
type PanelFormatting struct {
|
||||
Unit string `json:"unit"`
|
||||
DecimalPrecision PrecisionOption `json:"decimalPrecision"`
|
||||
@@ -678,106 +647,6 @@ func (fm *FillMode) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
type AreaFillMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
AreaFillModeSolid = AreaFillMode{valuer.NewString("solid")} // default
|
||||
AreaFillModeGradient = AreaFillMode{valuer.NewString("gradient")}
|
||||
)
|
||||
|
||||
func (AreaFillMode) Enum() []any {
|
||||
return []any{AreaFillModeSolid, AreaFillModeGradient}
|
||||
}
|
||||
|
||||
func (fm AreaFillMode) ValueOrDefault() string {
|
||||
if fm.IsZero() {
|
||||
return AreaFillModeSolid.StringValue()
|
||||
}
|
||||
return fm.StringValue()
|
||||
}
|
||||
|
||||
func (fm AreaFillMode) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(fm.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (fm *AreaFillMode) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid fill mode: must be a string, one of `solid`, `gradient`, or `none`")
|
||||
}
|
||||
val := AreaFillMode{valuer.NewString(v)}
|
||||
switch val {
|
||||
case AreaFillModeSolid, AreaFillModeGradient:
|
||||
*fm = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid fill mode %q: must be `solid`, `gradient`, or `none`", v)
|
||||
}
|
||||
}
|
||||
|
||||
// StackMode is area-only. Bar stacking stays on BarChartVisualization.StackedBarChart,
|
||||
// so `percent` is not reachable from a bar panel.
|
||||
type StackMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
StackModeNone = StackMode{valuer.NewString("none")} // default
|
||||
StackModeNormal = StackMode{valuer.NewString("normal")}
|
||||
StackModePercent = StackMode{valuer.NewString("percent")}
|
||||
)
|
||||
|
||||
func (StackMode) Enum() []any {
|
||||
return []any{StackModeNone, StackModeNormal, StackModePercent}
|
||||
}
|
||||
|
||||
func (sm StackMode) ValueOrDefault() string {
|
||||
if sm.IsZero() {
|
||||
return StackModeNone.StringValue()
|
||||
}
|
||||
return sm.StringValue()
|
||||
}
|
||||
|
||||
func (sm StackMode) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(sm.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (sm *StackMode) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid stack mode: must be a string, one of `none`, `normal`, or `percent`")
|
||||
}
|
||||
val := StackMode{valuer.NewString(v)}
|
||||
switch val {
|
||||
case StackModeNone, StackModeNormal, StackModePercent:
|
||||
*sm = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid stack mode %q: must be `none`, `normal`, or `percent`", v)
|
||||
}
|
||||
}
|
||||
|
||||
// FillOpacity is the alpha of an area fill, in 0–1 because that is what the
|
||||
// chart layer consumes directly. Unlike the enums in this section it has no
|
||||
// ValueOrDefault: 0 is a legitimate value, so the kind default lives at render
|
||||
// time behind a nil pointer.
|
||||
type FillOpacity float64
|
||||
|
||||
func (FillOpacity) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
s.WithMinimum(0).WithMaximum(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *FillOpacity) UnmarshalJSON(data []byte) error {
|
||||
var v float64
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid fillOpacity: must be a number between 0 and 1")
|
||||
}
|
||||
if v < 0 || v > 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid fillOpacity %v: must be between 0 and 1", v)
|
||||
}
|
||||
*o = FillOpacity(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
type SpanGaps struct {
|
||||
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
|
||||
FillLessThan string `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`
|
||||
|
||||
@@ -14,11 +14,6 @@ import (
|
||||
// (transition.dashboardMigrateV5). Pre-v5 builder queries will produce
|
||||
// invalid v2 envelopes — run the v4→v5 migration first.
|
||||
//
|
||||
// The v1 input shape is closed: nothing writes v1 dashboards any more, so these
|
||||
// files only ever convert what v1 could already express. Panel kinds and spec
|
||||
// fields added to v2 from here on need no converter entry — change these files
|
||||
// only when a v2 type edit breaks the build.
|
||||
//
|
||||
// The conversion is split across sibling files by concern:
|
||||
// - perses_v1_to_v2_tags.go tags
|
||||
// - perses_v1_to_v2_panels.go widgets → panels (+ panel field mappers)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
var (
|
||||
ErrCodeMapperNotFound = errors.MustNewCode("span_attribute_mapper_not_found")
|
||||
ErrCodeMapperAlreadyExists = errors.MustNewCode("span_attribute_mapper_already_exists")
|
||||
ErrCodeMapperNotDeletable = errors.MustNewCode("span_attribute_mapper_not_deletable")
|
||||
ErrCodeMappingInvalidInput = errors.MustNewCode("span_attribute_mapping_invalid_input")
|
||||
)
|
||||
|
||||
@@ -34,12 +37,25 @@ var (
|
||||
SpanMapperOperationCopy = SpanMapperOperation{valuer.NewString("copy")}
|
||||
)
|
||||
|
||||
// SpanMapperOrigin tells shipped (system) items apart from user-created ones.
|
||||
// System items are read-only apart from their enabled toggle.
|
||||
type SpanMapperOrigin struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
SpanMapperOriginUser = SpanMapperOrigin{valuer.NewString("user")}
|
||||
SpanMapperOriginSystem = SpanMapperOrigin{valuer.NewString("system")}
|
||||
)
|
||||
|
||||
// MapperSource describes one candidate source for a target attribute.
|
||||
type SpanMapperSource struct {
|
||||
Key string `json:"key" required:"true"`
|
||||
Context FieldContext `json:"context" required:"true"`
|
||||
Operation SpanMapperOperation `json:"operation" required:"true"`
|
||||
Priority int `json:"priority" required:"true"`
|
||||
Enabled bool `json:"enabled" required:"true"`
|
||||
Origin SpanMapperOrigin `json:"origin"`
|
||||
}
|
||||
|
||||
// MapperConfig holds the mapping logic for a single target attribute.
|
||||
@@ -59,6 +75,7 @@ type SpanMapper struct {
|
||||
FieldContext FieldContext `json:"fieldContext" required:"true"`
|
||||
Config SpanMapperConfig `json:"config" required:"true"`
|
||||
Enabled bool `json:"enabled" required:"true"`
|
||||
Origin SpanMapperOrigin `json:"origin" required:"true"`
|
||||
}
|
||||
|
||||
type PostableSpanMapper struct {
|
||||
@@ -90,6 +107,63 @@ func (SpanMapperOperation) Enum() []any {
|
||||
return []any{SpanMapperOperationMove, SpanMapperOperationCopy}
|
||||
}
|
||||
|
||||
func (SpanMapperOrigin) Enum() []any {
|
||||
return []any{SpanMapperOriginUser, SpanMapperOriginSystem}
|
||||
}
|
||||
|
||||
func (p *PostableSpanMapper) Validate() error {
|
||||
if strings.TrimSpace(p.Name) == "" {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "mapper name must not be blank")
|
||||
}
|
||||
if err := p.FieldContext.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Config.Validate()
|
||||
}
|
||||
|
||||
func (f FieldContext) Validate() error {
|
||||
if f != FieldContextSpanAttribute && f != FieldContextResource {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "field context must be one of %q or %q, got %q", FieldContextSpanAttribute, FieldContextResource, f.StringValue())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks every source and rejects duplicate priorities within an
|
||||
// origin. Shipped and user sources are never compared with each other: a user
|
||||
// re-adding a shipped key with another operation is the supported override.
|
||||
func (c *SpanMapperConfig) Validate() error {
|
||||
if len(c.Sources) == 0 {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "config.sources must contain at least one source")
|
||||
}
|
||||
seen := map[SpanMapperOrigin]map[int]struct{}{}
|
||||
for _, s := range c.Sources {
|
||||
if strings.TrimSpace(s.Key) == "" {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "source key must not be blank")
|
||||
}
|
||||
if err := s.Context.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Operation != SpanMapperOperationCopy && s.Operation != SpanMapperOperationMove {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "source operation must be one of %q or %q, got %q", SpanMapperOperationCopy, SpanMapperOperationMove, s.Operation.StringValue())
|
||||
}
|
||||
if !s.Origin.IsZero() && s.Origin != SpanMapperOriginUser && s.Origin != SpanMapperOriginSystem {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "source origin must be one of %q or %q, got %q", SpanMapperOriginUser, SpanMapperOriginSystem, s.Origin.StringValue())
|
||||
}
|
||||
origin := s.Origin
|
||||
if origin.IsZero() {
|
||||
origin = SpanMapperOriginUser
|
||||
}
|
||||
if seen[origin] == nil {
|
||||
seen[origin] = map[int]struct{}{}
|
||||
}
|
||||
if _, dup := seen[origin][s.Priority]; dup {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "source priority %d is used more than once", s.Priority)
|
||||
}
|
||||
seen[origin][s.Priority] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewSpanMapper(groupID valuer.UUID, createdBy string, p *PostableSpanMapper) *SpanMapper {
|
||||
now := time.Now()
|
||||
return &SpanMapper{
|
||||
@@ -97,8 +171,9 @@ func NewSpanMapper(groupID valuer.UUID, createdBy string, p *PostableSpanMapper)
|
||||
GroupID: groupID,
|
||||
Name: p.Name,
|
||||
FieldContext: p.FieldContext,
|
||||
Config: p.Config,
|
||||
Config: SpanMapperConfig{Sources: withOrigin(p.Config.Sources, SpanMapperOriginUser)},
|
||||
Enabled: p.Enabled,
|
||||
Origin: SpanMapperOriginUser,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -110,16 +185,42 @@ func NewSpanMapper(groupID valuer.UUID, createdBy string, p *PostableSpanMapper)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SpanMapper) Update(fieldContext FieldContext, config *SpanMapperConfig, enabled *bool, updatedBy string) {
|
||||
m.FieldContext = fieldContext
|
||||
// Update applies a user edit; a zero fieldContext means it was omitted. On a
|
||||
// system mapper the field context is fixed and the stored system sources are
|
||||
// kept; see nextSources.
|
||||
func (m *SpanMapper) Update(fieldContext FieldContext, config *SpanMapperConfig, enabled *bool, updatedBy string) error {
|
||||
if !fieldContext.IsZero() {
|
||||
if m.Origin == SpanMapperOriginSystem && fieldContext != m.FieldContext {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "field context of system mapper %q cannot be changed", m.Name)
|
||||
}
|
||||
if err := fieldContext.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
m.FieldContext = fieldContext
|
||||
}
|
||||
if config != nil {
|
||||
m.Config = *config
|
||||
sources, err := m.nextSources(config.Sources)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Config = SpanMapperConfig{Sources: sources}
|
||||
if err := m.Config.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if enabled != nil {
|
||||
m.Enabled = *enabled
|
||||
}
|
||||
m.UpdatedAt = time.Now()
|
||||
m.UpdatedBy = updatedBy
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *SpanMapper) ErrIfNotDeletable() error {
|
||||
if m.Origin == SpanMapperOriginSystem {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeMapperNotDeletable, "system mapper %q cannot be deleted, disable it instead", m.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *SpanMapper) ToStorable() *StorableSpanMapper {
|
||||
@@ -132,6 +233,7 @@ func (m *SpanMapper) ToStorable() *StorableSpanMapper {
|
||||
FieldContext: m.FieldContext,
|
||||
Config: m.Config,
|
||||
Enabled: m.Enabled,
|
||||
Origin: m.Origin,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +247,7 @@ func (s *StorableSpanMapper) ToSpanMapper() *SpanMapper {
|
||||
FieldContext: s.FieldContext,
|
||||
Config: s.Config,
|
||||
Enabled: s.Enabled,
|
||||
Origin: s.Origin,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,3 +262,39 @@ func NewSpanMappersFromStorable(ss []*StorableSpanMapper) []*SpanMapper {
|
||||
func NewGettableSpanMappers(m []*SpanMapper) *GettableSpanMappers {
|
||||
return &GettableSpanMappers{Items: m}
|
||||
}
|
||||
|
||||
// nextSources builds the source list from an edit: user sources are taken from
|
||||
// the edit as sent, system sources stay as stored and the edit can only flip
|
||||
// their enabled flag.
|
||||
func (m *SpanMapper) nextSources(edit []SpanMapperSource) ([]SpanMapperSource, error) {
|
||||
var systemSources, userSources []SpanMapperSource
|
||||
for _, s := range m.Config.Sources {
|
||||
if s.Origin == SpanMapperOriginSystem {
|
||||
systemSources = append(systemSources, s)
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range edit {
|
||||
if s.Origin != SpanMapperOriginSystem {
|
||||
s.Origin = SpanMapperOriginUser
|
||||
userSources = append(userSources, s)
|
||||
continue
|
||||
}
|
||||
idx := slices.IndexFunc(systemSources, func(o SpanMapperSource) bool { return o.Key == s.Key && o.Context == s.Context })
|
||||
if idx == -1 {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "system source %q does not exist on this mapper; only its enabled flag can change", s.Key)
|
||||
}
|
||||
systemSources[idx].Enabled = s.Enabled
|
||||
}
|
||||
|
||||
return append(systemSources, userSources...), nil
|
||||
}
|
||||
|
||||
func withOrigin(sources []SpanMapperSource, origin SpanMapperOrigin) []SpanMapperSource {
|
||||
out := make([]SpanMapperSource, len(sources))
|
||||
for i, s := range sources {
|
||||
s.Origin = origin
|
||||
out[i] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -11,17 +13,28 @@ import (
|
||||
var (
|
||||
ErrCodeMappingGroupNotFound = errors.MustNewCode("span_attribute_mapping_group_not_found")
|
||||
ErrCodeMappingGroupAlreadyExists = errors.MustNewCode("span_attribute_mapping_group_already_exists")
|
||||
ErrCodeMappingGroupNameReserved = errors.MustNewCode("span_attribute_mapping_group_name_reserved")
|
||||
ErrCodeMappingGroupNotDeletable = errors.MustNewCode("span_attribute_mapping_group_not_deletable")
|
||||
)
|
||||
|
||||
// SpanMapperGroupConditionKey is one substring a span's attribute or resource
|
||||
// keys are matched against.
|
||||
type SpanMapperGroupConditionKey struct {
|
||||
Value string `json:"value" required:"true"`
|
||||
Enabled bool `json:"enabled" required:"true"`
|
||||
Origin SpanMapperOrigin `json:"origin"`
|
||||
}
|
||||
|
||||
// SpanMapperGroupCondition gates whether a group's rules run for a given span.
|
||||
// A group runs when any attribute or resource key on the span CONTAINS one of
|
||||
// the listed substrings (plain substring match — no glob syntax).
|
||||
type SpanMapperGroupCondition struct {
|
||||
Attributes []string `json:"attributes" required:"true" nullable:"true"`
|
||||
Resource []string `json:"resource" required:"true" nullable:"true"`
|
||||
Attributes []SpanMapperGroupConditionKey `json:"attributes" required:"true" nullable:"true"`
|
||||
Resource []SpanMapperGroupConditionKey `json:"resource" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
// SpanMapperGroup is the domain model for a span attribute mapping group.
|
||||
// Version is the shipped definition version for system groups and 0 otherwise.
|
||||
type SpanMapperGroup struct {
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
@@ -31,6 +44,8 @@ type SpanMapperGroup struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
Condition SpanMapperGroupCondition `json:"condition" required:"true"`
|
||||
Enabled bool `json:"enabled" required:"true"`
|
||||
Origin SpanMapperOrigin `json:"origin" required:"true"`
|
||||
Version int `json:"version" required:"true"`
|
||||
}
|
||||
|
||||
// GettableSpanMapperGroup is the HTTP response representation of a mapping group.
|
||||
@@ -58,14 +73,42 @@ type GettableSpanMapperGroups struct {
|
||||
Items []*GettableSpanMapperGroup `json:"items" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
// Validate requires at least one substring overall and rejects blank ones.
|
||||
// All-off is allowed: a group with every substring disabled simply never runs.
|
||||
func (c *SpanMapperGroupCondition) Validate() error {
|
||||
if len(c.Attributes)+len(c.Resource) == 0 {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "condition must list at least one attribute or resource substring")
|
||||
}
|
||||
for _, k := range slices.Concat(c.Attributes, c.Resource) {
|
||||
if strings.TrimSpace(k.Value) == "" {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "condition substrings must not be blank")
|
||||
}
|
||||
if !k.Origin.IsZero() && k.Origin != SpanMapperOriginUser && k.Origin != SpanMapperOriginSystem {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "condition origin must be one of %q or %q, got %q", SpanMapperOriginUser, SpanMapperOriginSystem, k.Origin.StringValue())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PostableSpanMapperGroup) Validate() error {
|
||||
if strings.TrimSpace(p.Name) == "" {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "group name must not be blank")
|
||||
}
|
||||
return p.Condition.Validate()
|
||||
}
|
||||
|
||||
func NewSpanMapperGroup(orgID valuer.UUID, createdBy string, p *PostableSpanMapperGroup) *SpanMapperGroup {
|
||||
now := time.Now()
|
||||
return &SpanMapperGroup{
|
||||
ID: valuer.GenerateUUID(),
|
||||
OrgID: orgID,
|
||||
Name: p.Name,
|
||||
Condition: p.Condition,
|
||||
Enabled: p.Enabled,
|
||||
ID: valuer.GenerateUUID(),
|
||||
OrgID: orgID,
|
||||
Name: p.Name,
|
||||
Condition: SpanMapperGroupCondition{
|
||||
Attributes: conditionKeysWithOrigin(p.Condition.Attributes, SpanMapperOriginUser),
|
||||
Resource: conditionKeysWithOrigin(p.Condition.Resource, SpanMapperOriginUser),
|
||||
},
|
||||
Enabled: p.Enabled,
|
||||
Origin: SpanMapperOriginUser,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -77,18 +120,45 @@ func NewSpanMapperGroup(orgID valuer.UUID, createdBy string, p *PostableSpanMapp
|
||||
}
|
||||
}
|
||||
|
||||
func (g *SpanMapperGroup) Update(name *string, condition *SpanMapperGroupCondition, enabled *bool, updatedBy string) {
|
||||
// Update applies a user edit. A system group keeps its name and its system
|
||||
// substrings; see nextConditionKeys.
|
||||
func (g *SpanMapperGroup) Update(name *string, condition *SpanMapperGroupCondition, enabled *bool, updatedBy string) error {
|
||||
if name != nil {
|
||||
if g.Origin == SpanMapperOriginSystem && *name != g.Name {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "system group %q cannot be renamed", g.Name)
|
||||
}
|
||||
if strings.TrimSpace(*name) == "" {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "group name must not be blank")
|
||||
}
|
||||
g.Name = *name
|
||||
}
|
||||
if condition != nil {
|
||||
g.Condition = *condition
|
||||
attrs, err := nextConditionKeys(g.Condition.Attributes, condition.Attributes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := nextConditionKeys(g.Condition.Resource, condition.Resource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.Condition = SpanMapperGroupCondition{Attributes: attrs, Resource: res}
|
||||
if err := g.Condition.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if enabled != nil {
|
||||
g.Enabled = *enabled
|
||||
}
|
||||
g.UpdatedAt = time.Now()
|
||||
g.UpdatedBy = updatedBy
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *SpanMapperGroup) ErrIfNotDeletable() error {
|
||||
if g.Origin == SpanMapperOriginSystem {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingGroupNotDeletable, "system group %q cannot be deleted, disable it instead", g.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *SpanMapperGroup) ToStorable() *StorableSpanMapperGroup {
|
||||
@@ -100,6 +170,8 @@ func (g *SpanMapperGroup) ToStorable() *StorableSpanMapperGroup {
|
||||
Name: g.Name,
|
||||
Condition: g.Condition,
|
||||
Enabled: g.Enabled,
|
||||
Origin: g.Origin,
|
||||
Version: g.Version,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +184,8 @@ func (s *StorableSpanMapperGroup) ToSpanMapperGroup() *SpanMapperGroup {
|
||||
Name: s.Name,
|
||||
Condition: s.Condition,
|
||||
Enabled: s.Enabled,
|
||||
Origin: s.Origin,
|
||||
Version: s.Version,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,3 +200,39 @@ func NewSpanMapperGroupsFromStorable(ss []*StorableSpanMapperGroup) []*SpanMappe
|
||||
func NewGettableSpanMapperGroups(g []*SpanMapperGroup) *GettableSpanMapperGroups {
|
||||
return &GettableSpanMapperGroups{Items: g}
|
||||
}
|
||||
|
||||
// nextConditionKeys builds a substring list from an edit: user substrings are
|
||||
// taken from the edit as sent, system substrings stay as stored and the edit
|
||||
// can only flip their enabled flag.
|
||||
func nextConditionKeys(stored, edit []SpanMapperGroupConditionKey) ([]SpanMapperGroupConditionKey, error) {
|
||||
var systemKeys, userKeys []SpanMapperGroupConditionKey
|
||||
for _, k := range stored {
|
||||
if k.Origin == SpanMapperOriginSystem {
|
||||
systemKeys = append(systemKeys, k)
|
||||
}
|
||||
}
|
||||
|
||||
for _, k := range edit {
|
||||
if k.Origin != SpanMapperOriginSystem {
|
||||
k.Origin = SpanMapperOriginUser
|
||||
userKeys = append(userKeys, k)
|
||||
continue
|
||||
}
|
||||
idx := slices.IndexFunc(systemKeys, func(s SpanMapperGroupConditionKey) bool { return s.Value == k.Value })
|
||||
if idx == -1 {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "system substring %q does not exist on this group; only its enabled flag can change", k.Value)
|
||||
}
|
||||
systemKeys[idx].Enabled = k.Enabled
|
||||
}
|
||||
|
||||
return append(systemKeys, userKeys...), nil
|
||||
}
|
||||
|
||||
func conditionKeysWithOrigin(keys []SpanMapperGroupConditionKey, origin SpanMapperOrigin) []SpanMapperGroupConditionKey {
|
||||
out := make([]SpanMapperGroupConditionKey, len(keys))
|
||||
for i, k := range keys {
|
||||
k.Origin = origin
|
||||
out[i] = k
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
121
pkg/types/spantypes/spanmapperdefinition.go
Normal file
121
pkg/types/spantypes/spanmapperdefinition.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
var ErrCodeMappingDefinitionInvalid = errors.MustNewCode("span_attribute_mapping_definition_invalid")
|
||||
|
||||
// ProvisionerIdentity is stamped into created_by/updated_by by the reconciler.
|
||||
const ProvisionerIdentity = "signoz"
|
||||
|
||||
// SpanMapperGroupDefinition is one shipped mapping group. Version is bumped on
|
||||
// every content change and drives upgrades; the group name is the stable key
|
||||
// and never changes. Once parsed, every substring and source carries the
|
||||
// system origin and is enabled.
|
||||
type SpanMapperGroupDefinition struct {
|
||||
Version int `json:"version"`
|
||||
Definition PostableSpanMapperTestGroup `json:"definition"`
|
||||
}
|
||||
|
||||
func (d SpanMapperGroupDefinition) Name() string {
|
||||
return d.Definition.Name
|
||||
}
|
||||
|
||||
func NewSpanMapperGroupDefinition(raw []byte) (SpanMapperGroupDefinition, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
var d SpanMapperGroupDefinition
|
||||
if err := decoder.Decode(&d); err != nil {
|
||||
return SpanMapperGroupDefinition{}, errors.WrapInvalidInputf(err, ErrCodeMappingDefinitionInvalid, "%s", err.Error())
|
||||
}
|
||||
if err := d.validate(); err != nil {
|
||||
return SpanMapperGroupDefinition{}, err
|
||||
}
|
||||
|
||||
for _, keys := range [][]SpanMapperGroupConditionKey{d.Definition.Condition.Attributes, d.Definition.Condition.Resource} {
|
||||
for i := range keys {
|
||||
keys[i].Enabled = true
|
||||
keys[i].Origin = SpanMapperOriginSystem
|
||||
}
|
||||
}
|
||||
for i := range d.Definition.Mappers {
|
||||
sources := d.Definition.Mappers[i].Config.Sources
|
||||
for j := range sources {
|
||||
sources[j].Enabled = true
|
||||
sources[j].Origin = SpanMapperOriginSystem
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// SpanMapperGroupRegistry holds every definition embedded in the binary, keyed by name.
|
||||
type SpanMapperGroupRegistry struct {
|
||||
definitions map[string]SpanMapperGroupDefinition
|
||||
}
|
||||
|
||||
func NewSpanMapperGroupRegistry(definitions []SpanMapperGroupDefinition) (SpanMapperGroupRegistry, error) {
|
||||
byName := make(map[string]SpanMapperGroupDefinition, len(definitions))
|
||||
for _, d := range definitions {
|
||||
if _, dup := byName[d.Name()]; dup {
|
||||
return SpanMapperGroupRegistry{}, errors.NewInvalidInputf(ErrCodeMappingDefinitionInvalid, "duplicate span mapper group name %q", d.Name())
|
||||
}
|
||||
byName[d.Name()] = d
|
||||
}
|
||||
return SpanMapperGroupRegistry{definitions: byName}, nil
|
||||
}
|
||||
|
||||
func (r SpanMapperGroupRegistry) IsReserved(name string) bool {
|
||||
_, ok := r.definitions[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// List returns the definitions sorted by name so provisioning order is stable.
|
||||
func (r SpanMapperGroupRegistry) List() []SpanMapperGroupDefinition {
|
||||
out := make([]SpanMapperGroupDefinition, 0, len(r.definitions))
|
||||
for _, d := range r.definitions {
|
||||
out = append(out, d)
|
||||
}
|
||||
slices.SortFunc(out, func(a, b SpanMapperGroupDefinition) int { return strings.Compare(a.Name(), b.Name()) })
|
||||
return out
|
||||
}
|
||||
|
||||
func (d SpanMapperGroupDefinition) validate() error {
|
||||
if d.Version < 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeMappingDefinitionInvalid, "version must be at least 1, got %d", d.Version)
|
||||
}
|
||||
if err := d.Definition.Validate(); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeMappingDefinitionInvalid, "%s", d.Name())
|
||||
}
|
||||
if len(d.Definition.Mappers) == 0 {
|
||||
return errors.NewInvalidInputf(ErrCodeMappingDefinitionInvalid, "%s: at least one mapper is required", d.Name())
|
||||
}
|
||||
names := make(map[string]struct{}, len(d.Definition.Mappers))
|
||||
for i := range d.Definition.Mappers {
|
||||
m := &d.Definition.Mappers[i]
|
||||
if err := m.Validate(); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeMappingDefinitionInvalid, "%s: mapper %q", d.Name(), m.Name)
|
||||
}
|
||||
if _, dup := names[m.Name]; dup {
|
||||
return errors.NewInvalidInputf(ErrCodeMappingDefinitionInvalid, "%s: duplicate mapper %q", d.Name(), m.Name)
|
||||
}
|
||||
names[m.Name] = struct{}{}
|
||||
for _, s := range m.Config.Sources {
|
||||
if !s.Origin.IsZero() || s.Enabled {
|
||||
return errors.NewInvalidInputf(ErrCodeMappingDefinitionInvalid, "%s: mapper %q: sources must not set origin or enabled", d.Name(), m.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range slices.Concat(d.Definition.Condition.Attributes, d.Definition.Condition.Resource) {
|
||||
if !k.Origin.IsZero() || k.Enabled {
|
||||
return errors.NewInvalidInputf(ErrCodeMappingDefinitionInvalid, "%s: condition substrings must not set origin or enabled", d.Name())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -86,17 +86,31 @@ func buildProcessorConfig(groups []*SpanMapperGroupWithMappers) *spanMapperProce
|
||||
out := make([]spanMapperProcessorGroup, 0, len(groups))
|
||||
|
||||
for _, gm := range groups {
|
||||
existsAny := spanMapperProcessorExistsAny{
|
||||
Attributes: enabledConditionValues(gm.Group.Condition.Attributes),
|
||||
Resource: enabledConditionValues(gm.Group.Condition.Resource),
|
||||
}
|
||||
// The collector rejects an empty exists_any and empty sources; with
|
||||
// per-item toggles, all-off is valid stored state and means "never runs".
|
||||
if len(existsAny.Attributes)+len(existsAny.Resource) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
rules := make([]spanMapperProcessorAttribute, 0, len(gm.Mappers))
|
||||
for _, m := range gm.Mappers {
|
||||
rules = append(rules, buildAttributeRule(m))
|
||||
rule := buildAttributeRule(m)
|
||||
if len(rule.Sources) == 0 {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, spanMapperProcessorGroup{
|
||||
ID: gm.Group.Name,
|
||||
ExistsAny: spanMapperProcessorExistsAny{
|
||||
Attributes: gm.Group.Condition.Attributes,
|
||||
Resource: gm.Group.Condition.Resource,
|
||||
},
|
||||
ID: gm.Group.Name,
|
||||
ExistsAny: existsAny,
|
||||
Attributes: rules,
|
||||
})
|
||||
}
|
||||
@@ -104,14 +118,31 @@ func buildProcessorConfig(groups []*SpanMapperGroupWithMappers) *spanMapperProce
|
||||
return &spanMapperProcessorConfig{Groups: out}
|
||||
}
|
||||
|
||||
func enabledConditionValues(keys []SpanMapperGroupConditionKey) []string {
|
||||
out := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if k.Enabled {
|
||||
out = append(out, k.Value)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildAttributeRule maps a single SpanMapper to a collector attribute rule.
|
||||
// Sources are sorted by Priority DESC (highest-priority first); read-from-
|
||||
// resource sources are encoded via the "resource." prefix on the key. Each
|
||||
// source carries its own action — "copy" is omitted to keep the emitted YAML
|
||||
// compact, and only "move" is set explicitly.
|
||||
// Disabled sources are skipped and the rest are sorted by Priority DESC
|
||||
// (highest-priority first); read-from-resource sources are encoded via the
|
||||
// "resource." prefix on the key. Each source carries its own action — "copy"
|
||||
// is omitted to keep the emitted YAML compact, and only "move" is set explicitly.
|
||||
func buildAttributeRule(m *SpanMapper) spanMapperProcessorAttribute {
|
||||
sources := make([]SpanMapperSource, len(m.Config.Sources))
|
||||
copy(sources, m.Config.Sources)
|
||||
sources := make([]SpanMapperSource, 0, len(m.Config.Sources))
|
||||
for _, s := range m.Config.Sources {
|
||||
if s.Enabled {
|
||||
sources = append(sources, s)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(sources, func(i, j int) bool { return sources[i].Priority > sources[j].Priority })
|
||||
|
||||
out := make([]spanMapperProcessorSource, 0, len(sources))
|
||||
|
||||
@@ -145,6 +145,22 @@ func TestBuildAttributeRule(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "disabled_sources_skipped",
|
||||
mapper: newMapper("gen_ai.input.messages", FieldContextSpanAttribute,
|
||||
systemSrc("gen_ai.prompt", SpanMapperOperationCopy, 30, false),
|
||||
systemSrc("input.value", SpanMapperOperationCopy, 20, true),
|
||||
attrSrc("gen_ai.prompt", SpanMapperOperationMove, 40),
|
||||
),
|
||||
want: spanMapperProcessorAttribute{
|
||||
Target: "gen_ai.input.messages",
|
||||
Context: FieldContextSpanAttribute.StringValue(),
|
||||
Sources: []spanMapperProcessorSource{
|
||||
{Key: "gen_ai.prompt", Action: SpanMapperOperationMove.StringValue()},
|
||||
{Key: "input.value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
@@ -155,6 +171,33 @@ func TestBuildAttributeRule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProcessorConfigDropsAllOffItems(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
offGroup := newGroup("all-off", nil, nil)
|
||||
offGroup.Condition.Attributes = []SpanMapperGroupConditionKey{{Value: "model", Enabled: false, Origin: SpanMapperOriginSystem}}
|
||||
|
||||
mixed := newGroup("llm", nil, nil)
|
||||
mixed.Condition.Attributes = []SpanMapperGroupConditionKey{
|
||||
{Value: "model", Enabled: false, Origin: SpanMapperOriginSystem},
|
||||
{Value: "gen_ai.request.model", Enabled: true, Origin: SpanMapperOriginUser},
|
||||
}
|
||||
|
||||
got := buildProcessorConfig([]*SpanMapperGroupWithMappers{
|
||||
{Group: offGroup, Mappers: []*SpanMapper{newMapper("gen_ai.request.model", FieldContextSpanAttribute, attrSrc("llm.model", SpanMapperOperationCopy, 1))}},
|
||||
{Group: mixed, Mappers: []*SpanMapper{
|
||||
newMapper("gen_ai.request.model", FieldContextSpanAttribute, systemSrc("llm.model", SpanMapperOperationCopy, 10, false)),
|
||||
newMapper("gen_ai.provider.name", FieldContextSpanAttribute, systemSrc("llm.vendor", SpanMapperOperationCopy, 10, true)),
|
||||
}},
|
||||
})
|
||||
|
||||
require.Len(t, got.Groups, 1)
|
||||
assert.Equal(t, "llm", got.Groups[0].ID)
|
||||
assert.Equal(t, []string{"gen_ai.request.model"}, got.Groups[0].ExistsAny.Attributes)
|
||||
require.Len(t, got.Groups[0].Attributes, 1)
|
||||
assert.Equal(t, "gen_ai.provider.name", got.Groups[0].Attributes[0].Target)
|
||||
}
|
||||
|
||||
func loadFixture(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
@@ -174,12 +217,23 @@ func assertYAMLEqual(t *testing.T, want, got []byte) {
|
||||
|
||||
func newGroup(name string, attrs, res []string) *SpanMapperGroup {
|
||||
return &SpanMapperGroup{
|
||||
Name: name,
|
||||
Condition: SpanMapperGroupCondition{Attributes: attrs, Resource: res},
|
||||
Enabled: true,
|
||||
Name: name,
|
||||
Condition: SpanMapperGroupCondition{
|
||||
Attributes: userConditionKeys(attrs),
|
||||
Resource: userConditionKeys(res),
|
||||
},
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func userConditionKeys(values []string) []SpanMapperGroupConditionKey {
|
||||
out := make([]SpanMapperGroupConditionKey, len(values))
|
||||
for i, v := range values {
|
||||
out[i] = SpanMapperGroupConditionKey{Value: v, Enabled: true, Origin: SpanMapperOriginUser}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newMapper(name string, target FieldContext, sources ...SpanMapperSource) *SpanMapper {
|
||||
return &SpanMapper{
|
||||
Name: name,
|
||||
@@ -190,9 +244,13 @@ func newMapper(name string, target FieldContext, sources ...SpanMapperSource) *S
|
||||
}
|
||||
|
||||
func attrSrc(key string, op SpanMapperOperation, priority int) SpanMapperSource {
|
||||
return SpanMapperSource{Key: key, Context: FieldContextSpanAttribute, Operation: op, Priority: priority}
|
||||
return SpanMapperSource{Key: key, Context: FieldContextSpanAttribute, Operation: op, Priority: priority, Enabled: true, Origin: SpanMapperOriginUser}
|
||||
}
|
||||
|
||||
func resSrc(key string, op SpanMapperOperation, priority int) SpanMapperSource {
|
||||
return SpanMapperSource{Key: key, Context: FieldContextResource, Operation: op, Priority: priority}
|
||||
return SpanMapperSource{Key: key, Context: FieldContextResource, Operation: op, Priority: priority, Enabled: true, Origin: SpanMapperOriginUser}
|
||||
}
|
||||
|
||||
func systemSrc(key string, op SpanMapperOperation, priority int, enabled bool) SpanMapperSource {
|
||||
return SpanMapperSource{Key: key, Context: FieldContextSpanAttribute, Operation: op, Priority: priority, Enabled: enabled, Origin: SpanMapperOriginSystem}
|
||||
}
|
||||
|
||||
@@ -15,14 +15,14 @@ func TestSimulateSpanMappersProcessing_EndToEnd(t *testing.T) {
|
||||
groups := []*SpanMapperGroupWithMappers{{
|
||||
Group: &SpanMapperGroup{
|
||||
Name: "llm",
|
||||
Condition: SpanMapperGroupCondition{Attributes: []string{"model"}},
|
||||
Condition: SpanMapperGroupCondition{Attributes: userConditionKeys([]string{"model"})},
|
||||
Enabled: true,
|
||||
},
|
||||
Mappers: []*SpanMapper{{
|
||||
Name: "gen_ai.request.model",
|
||||
FieldContext: FieldContextSpanAttribute,
|
||||
Config: SpanMapperConfig{Sources: []SpanMapperSource{
|
||||
{Key: "llm.model", Context: FieldContextSpanAttribute, Operation: SpanMapperOperationCopy, Priority: 1},
|
||||
{Key: "llm.model", Context: FieldContextSpanAttribute, Operation: SpanMapperOperationCopy, Priority: 1, Enabled: true, Origin: SpanMapperOriginUser},
|
||||
}},
|
||||
Enabled: true,
|
||||
}},
|
||||
|
||||
@@ -20,7 +20,9 @@ type StorableSpanMapperGroup struct {
|
||||
OrgID valuer.UUID `bun:"org_id,type:text,notnull"`
|
||||
Name string `bun:"name,type:text,notnull"`
|
||||
Condition SpanMapperGroupCondition `bun:"condition,type:jsonb,notnull"`
|
||||
Enabled bool `bun:"enabled,notnull,default:true"`
|
||||
Enabled bool `bun:"enabled,notnull"`
|
||||
Origin SpanMapperOrigin `bun:"origin,type:text,notnull"`
|
||||
Version int `bun:"version,notnull"`
|
||||
}
|
||||
|
||||
type StorableSpanMapper struct {
|
||||
@@ -34,7 +36,8 @@ type StorableSpanMapper struct {
|
||||
Name string `bun:"name,type:text,notnull"`
|
||||
FieldContext FieldContext `bun:"field_context,type:text,notnull"`
|
||||
Config SpanMapperConfig `bun:"config,type:jsonb,notnull"`
|
||||
Enabled bool `bun:"enabled,notnull,default:true"`
|
||||
Enabled bool `bun:"enabled,notnull"`
|
||||
Origin SpanMapperOrigin `bun:"origin,type:text,notnull"`
|
||||
}
|
||||
|
||||
func (c SpanMapperGroupCondition) Value() (driver.Value, error) {
|
||||
|
||||
@@ -9,9 +9,14 @@ import (
|
||||
)
|
||||
|
||||
type SpanMapperStore interface {
|
||||
// RunInTx runs cb in one transaction; every store call made with the
|
||||
// callback's ctx joins it.
|
||||
RunInTx(ctx context.Context, cb func(ctx context.Context) error) error
|
||||
|
||||
// Group operations
|
||||
ListGroups(ctx context.Context, orgID valuer.UUID, q *ListSpanMapperGroupsQuery) ([]*SpanMapperGroup, error)
|
||||
GetGroup(ctx context.Context, orgID, id valuer.UUID) (*SpanMapperGroup, error)
|
||||
GetGroupByName(ctx context.Context, orgID valuer.UUID, name string) (*SpanMapperGroup, error)
|
||||
CreateGroup(ctx context.Context, group *SpanMapperGroup) error
|
||||
UpdateGroup(ctx context.Context, group *SpanMapperGroup) error
|
||||
DeleteGroup(ctx context.Context, orgID, id valuer.UUID) error
|
||||
@@ -21,7 +26,7 @@ type SpanMapperStore interface {
|
||||
GetMapper(ctx context.Context, orgID, groupID, id valuer.UUID) (*SpanMapper, error)
|
||||
CreateMapper(ctx context.Context, mapper *SpanMapper) error
|
||||
UpdateMapper(ctx context.Context, mapper *SpanMapper) error
|
||||
DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error
|
||||
DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID, origin SpanMapperOrigin) error
|
||||
}
|
||||
|
||||
// TraceStore defines the data access interface for trace detail queries.
|
||||
|
||||
@@ -41,7 +41,7 @@ def test_create_groups_and_simulate_with_backfill(
|
||||
},
|
||||
json={
|
||||
"name": "llm-backfill",
|
||||
"condition": {"attributes": ["model"], "resource": []},
|
||||
"condition": {"attributes": [{"value": "model", "enabled": True}], "resource": []},
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
@@ -69,6 +69,7 @@ def test_create_groups_and_simulate_with_backfill(
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 1,
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -126,13 +127,13 @@ def test_create_groups_and_simulate_with_backfill(
|
||||
# No "mappers" key: the server backfills them from the saved group.
|
||||
{
|
||||
"name": "llm-backfill",
|
||||
"condition": {"attributes": ["model"], "resource": []},
|
||||
"condition": {"attributes": [{"value": "model", "enabled": True}], "resource": []},
|
||||
"enabled": True,
|
||||
},
|
||||
# Unsaved group; mappers provided inline.
|
||||
{
|
||||
"name": "db-inline",
|
||||
"condition": {"attributes": ["db"], "resource": []},
|
||||
"condition": {"attributes": [{"value": "db", "enabled": True}], "resource": []},
|
||||
"enabled": True,
|
||||
"mappers": [
|
||||
{
|
||||
@@ -145,6 +146,7 @@ def test_create_groups_and_simulate_with_backfill(
|
||||
"context": "attribute",
|
||||
"operation": "move",
|
||||
"priority": 1,
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
152
tests/integration/tests/spanmapper/02_default_groups.py
Normal file
152
tests/integration/tests/spanmapper/02_default_groups.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
|
||||
GROUPS_PATH = "/api/v1/span_mapper_groups"
|
||||
|
||||
|
||||
def test_default_groups_are_seeded_and_shipped_items_are_toggle_only(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
A fresh org. The reconciler seeds the shipped mapping groups at startup
|
||||
and on org creation, so nothing has to be created here.
|
||||
|
||||
Tests:
|
||||
1. The list contains llm, agent and tool as system groups with shipped
|
||||
substrings
|
||||
2. Shipped mappers and their sources are system-owned and enabled
|
||||
3. A shipped name cannot be taken by a user group, and system groups and
|
||||
mappers cannot be deleted
|
||||
4. A shipped source can be switched off and a user override added; both
|
||||
round-trip through PATCH and the simulator honours them
|
||||
5. A shipped substring can be switched off and a user one added
|
||||
"""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"authorization": f"Bearer {token}", "content-type": "application/json"}
|
||||
|
||||
list_groups = requests.get(signoz.self.host_configs["8080"].get(GROUPS_PATH), timeout=10, headers=headers)
|
||||
assert list_groups.status_code == HTTPStatus.OK
|
||||
groups = {g["name"]: g for g in list_groups.json()["data"]["items"]}
|
||||
assert {"gen_ai.llm", "gen_ai.agent", "gen_ai.tool"} <= set(groups)
|
||||
for name in ("gen_ai.llm", "gen_ai.agent", "gen_ai.tool"):
|
||||
assert groups[name]["origin"] == "system"
|
||||
assert groups[name]["version"] >= 1
|
||||
assert groups[name]["createdBy"] == "signoz"
|
||||
llm = groups["gen_ai.llm"]
|
||||
assert llm["condition"]["attributes"] == [{"value": "model", "enabled": True, "origin": "system"}]
|
||||
|
||||
list_mappers = requests.get(signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}/span_mappers"), timeout=10, headers=headers)
|
||||
assert list_mappers.status_code == HTTPStatus.OK
|
||||
mappers = {m["name"]: m for m in list_mappers.json()["data"]["items"]}
|
||||
model = mappers["gen_ai.request.model"]
|
||||
assert model["origin"] == "system"
|
||||
assert model["enabled"] is True
|
||||
assert all(s["origin"] == "system" and s["enabled"] is True for s in model["config"]["sources"])
|
||||
assert "llm.model_name" in [s["key"] for s in model["config"]["sources"]]
|
||||
|
||||
reserved = requests.post(
|
||||
signoz.self.host_configs["8080"].get(GROUPS_PATH),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={"name": "gen_ai.tool", "condition": {"attributes": [{"value": "tool", "enabled": True}], "resource": []}, "enabled": True},
|
||||
)
|
||||
assert reserved.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert reserved.json()["error"]["code"] == "span_attribute_mapping_group_name_reserved"
|
||||
|
||||
delete_group = requests.delete(signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}"), timeout=10, headers=headers)
|
||||
assert delete_group.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert delete_group.json()["error"]["code"] == "span_attribute_mapping_group_not_deletable"
|
||||
|
||||
delete_mapper = requests.delete(signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}/span_mappers/{model['id']}"), timeout=10, headers=headers)
|
||||
assert delete_mapper.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert delete_mapper.json()["error"]["code"] == "span_attribute_mapper_not_deletable"
|
||||
|
||||
# Switch the shipped llm.model_name source off and re-add it as a user move.
|
||||
sources = [{**s, "enabled": s["key"] != "llm.model_name"} for s in model["config"]["sources"]] + [{"key": "llm.model_name", "context": "attribute", "operation": "move", "priority": 1, "enabled": True}]
|
||||
patch_mapper = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}/span_mappers/{model['id']}"),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={"config": {"sources": sources}},
|
||||
)
|
||||
assert patch_mapper.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
list_mappers = requests.get(signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}/span_mappers"), timeout=10, headers=headers)
|
||||
updated = {m["name"]: m for m in list_mappers.json()["data"]["items"]}["gen_ai.request.model"]
|
||||
by_origin = {(s["key"], s["origin"]): s for s in updated["config"]["sources"]}
|
||||
assert by_origin[("llm.model_name", "system")]["enabled"] is False
|
||||
assert by_origin[("llm.model_name", "user")]["operation"] == "move"
|
||||
assert len(updated["config"]["sources"]) == len(model["config"]["sources"]) + 1
|
||||
|
||||
# The user override wins: the source is moved, not copied.
|
||||
simulate = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/test"),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={
|
||||
"spans": [{"attributes": {"llm.model_name": "gpt-4o"}, "resource": {}}],
|
||||
"groups": [{"name": "gen_ai.llm", "condition": llm["condition"], "enabled": True}],
|
||||
},
|
||||
)
|
||||
assert simulate.status_code == HTTPStatus.OK
|
||||
attrs = simulate.json()["data"]["spans"][0]["attributes"]
|
||||
assert attrs["gen_ai.request.model"] == "gpt-4o"
|
||||
assert "llm.model_name" not in attrs
|
||||
|
||||
# A shipped substring that does not exist is rejected; toggling one is not.
|
||||
bad_condition = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}"),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={"condition": {"attributes": [{"value": "nope", "enabled": True, "origin": "system"}], "resource": []}},
|
||||
)
|
||||
assert bad_condition.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
patch_group = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}"),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={
|
||||
"condition": {
|
||||
"attributes": [
|
||||
{"value": "model", "enabled": False, "origin": "system"},
|
||||
{"value": "gen_ai.request.model", "enabled": True},
|
||||
],
|
||||
"resource": [],
|
||||
}
|
||||
},
|
||||
)
|
||||
assert patch_group.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
list_groups = requests.get(signoz.self.host_configs["8080"].get(GROUPS_PATH), timeout=10, headers=headers)
|
||||
llm_after = {g["name"]: g for g in list_groups.json()["data"]["items"]}["gen_ai.llm"]
|
||||
assert llm_after["condition"]["attributes"] == [
|
||||
{"value": "model", "enabled": False, "origin": "system"},
|
||||
{"value": "gen_ai.request.model", "enabled": True, "origin": "user"},
|
||||
]
|
||||
assert llm_after["origin"] == "system"
|
||||
assert llm_after["name"] == "gen_ai.llm"
|
||||
|
||||
# Leave the shipped group as seeded for the other suites.
|
||||
restore_group = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}"),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={"condition": {"attributes": [{"value": "model", "enabled": True, "origin": "system"}], "resource": []}},
|
||||
)
|
||||
assert restore_group.status_code == HTTPStatus.NO_CONTENT
|
||||
restore_mapper = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}/span_mappers/{model['id']}"),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={"config": {"sources": model["config"]["sources"]}},
|
||||
)
|
||||
assert restore_mapper.status_code == HTTPStatus.NO_CONTENT
|
||||
Reference in New Issue
Block a user