mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-21 11:20:28 +01:00
Compare commits
3 Commits
add-exampl
...
feat/mcp-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4572f3c0b | ||
|
|
bdd155e59a | ||
|
|
498bf54204 |
3140
docs/api/openapi.yml
3140
docs/api/openapi.yml
File diff suppressed because it is too large
Load Diff
52
frontend/public/locales/en-GB/mcpServer.json
Normal file
52
frontend/public/locales/en-GB/mcpServer.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"page_title": "SigNoz MCP Server",
|
||||
"page_subtitle": "Connect AI assistants like Claude, Cursor, VS Code, and Codex to your SigNoz data via the Model Context Protocol. Authenticate from your MCP client with a service-account API key.",
|
||||
|
||||
"fallback_title": "MCP Server is available on SigNoz Cloud",
|
||||
"fallback_body": "This in-product setup guide is for SigNoz Cloud. Self-hosted users can follow the docs to run the MCP server against their instance.",
|
||||
"fallback_docs_link": "View MCP Server docs",
|
||||
|
||||
"region_warning_prefix": "We couldn't detect your Cloud region from this URL. Find it at ",
|
||||
"region_warning_link": "Settings → Ingestion",
|
||||
"region_warning_suffix": " (the label between your workspace name and signoz.cloud in the ingestion URL), then enter it here.",
|
||||
"region_input_label": "SigNoz Cloud region",
|
||||
"region_input_placeholder": "Your SigNoz Cloud region",
|
||||
|
||||
"step1_title": "Configure your client",
|
||||
"step1_description": "Add SigNoz to your MCP client. Use a one-click install where available, or copy the config for manual setup. On first connect, the client will open a SigNoz authorization page — use the instance URL and API key from step 2.",
|
||||
"step1_manual_fallback": "Or copy the config below for manual setup.",
|
||||
"step1_client_docs_suffix": " setup docs",
|
||||
"step1_add_to_client_prefix": "Add to ",
|
||||
|
||||
"client_cursor_install_label": "Add to Cursor",
|
||||
"client_vscode_install_label": "Add to VS Code",
|
||||
"client_claude_desktop_instructions": "Open Claude Desktop, go to Settings → Connectors → Add custom connector, and paste the endpoint URL above. Claude Desktop does not read remote MCP servers from claude_desktop_config.json — the connector UI is the only supported path.",
|
||||
"client_other_instructions": "Most MCP clients that support remote HTTP servers will accept the endpoint URL above. Add it as a new MCP server in your client and paste your SigNoz API key when the client prompts for authentication. See the docs for client-specific instructions.",
|
||||
|
||||
"step2_title": "Authenticate from your client",
|
||||
"step2_description": "On first connect, your client opens a SigNoz authorization page asking for two values:",
|
||||
"step2_instance_url_label": "SigNoz Instance URL",
|
||||
"step2_api_key_label": "API Key",
|
||||
"step2_admin_cta": "Create service account",
|
||||
"step2_admin_helper": "Create a service account, then add a new key inside it — paste that key into the API Key field.",
|
||||
"step2_viewer_helper": "Only admins can create API keys. Ask your workspace admin for a key with read access, then paste it into the API Key field.",
|
||||
|
||||
"use_cases_title": "What you can do with it",
|
||||
"use_cases_item_1": "Ask your AI assistant to investigate a spiking error rate.",
|
||||
"use_cases_item_2": "Debug a slow service by walking through recent traces.",
|
||||
"use_cases_item_3": "Summarize an alert and suggest likely root causes.",
|
||||
"use_cases_item_4": "Generate dashboards or queries from a natural-language description.",
|
||||
"use_cases_docs_link": "See more use cases",
|
||||
|
||||
"copy_tooltip_enabled": "Copy to clipboard",
|
||||
"copy_tooltip_disabled": "Enter your Cloud region first",
|
||||
"copy_aria_endpoint": "Copy MCP endpoint",
|
||||
"copy_aria_instance_url": "Copy SigNoz instance URL",
|
||||
"copy_aria_snippet_prefix": "Copy ",
|
||||
"copy_aria_snippet_suffix": " config",
|
||||
|
||||
"toast_endpoint_copied": "Endpoint copied to clipboard",
|
||||
"toast_snippet_copied": "Snippet copied to clipboard",
|
||||
"toast_instance_url_copied": "Instance URL copied to clipboard",
|
||||
"toast_region_required": "Enter your Cloud region before copying"
|
||||
}
|
||||
@@ -16,5 +16,6 @@
|
||||
"roles": "Roles",
|
||||
"role_details": "Role Details",
|
||||
"members": "Members",
|
||||
"service_accounts": "Service Accounts"
|
||||
"service_accounts": "Service Accounts",
|
||||
"mcp_server": "MCP Server"
|
||||
}
|
||||
|
||||
@@ -53,5 +53,6 @@
|
||||
"METER": "SigNoz | Meter",
|
||||
"ROLES_SETTINGS": "SigNoz | Roles",
|
||||
"MEMBERS_SETTINGS": "SigNoz | Members",
|
||||
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts"
|
||||
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts",
|
||||
"MCP_SERVER": "SigNoz | MCP Server"
|
||||
}
|
||||
|
||||
52
frontend/public/locales/en/mcpServer.json
Normal file
52
frontend/public/locales/en/mcpServer.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"page_title": "SigNoz MCP Server",
|
||||
"page_subtitle": "Connect AI assistants like Claude, Cursor, VS Code, and Codex to your SigNoz data via the Model Context Protocol. Authenticate from your MCP client with a service-account API key.",
|
||||
|
||||
"fallback_title": "MCP Server is available on SigNoz Cloud",
|
||||
"fallback_body": "This in-product setup guide is for SigNoz Cloud. Self-hosted users can follow the docs to run the MCP server against their instance.",
|
||||
"fallback_docs_link": "View MCP Server docs",
|
||||
|
||||
"region_warning_prefix": "We couldn't detect your Cloud region from this URL. Find it at ",
|
||||
"region_warning_link": "Settings → Ingestion",
|
||||
"region_warning_suffix": " (the label between your workspace name and signoz.cloud in the ingestion URL), then enter it here.",
|
||||
"region_input_label": "SigNoz Cloud region",
|
||||
"region_input_placeholder": "Your SigNoz Cloud region",
|
||||
|
||||
"step1_title": "Configure your client",
|
||||
"step1_description": "Add SigNoz to your MCP client. Use a one-click install where available, or copy the config for manual setup. On first connect, the client will open a SigNoz authorization page — use the instance URL and API key from step 2.",
|
||||
"step1_manual_fallback": "Or copy the config below for manual setup.",
|
||||
"step1_client_docs_suffix": " setup docs",
|
||||
"step1_add_to_client_prefix": "Add to ",
|
||||
|
||||
"client_cursor_install_label": "Add to Cursor",
|
||||
"client_vscode_install_label": "Add to VS Code",
|
||||
"client_claude_desktop_instructions": "Open Claude Desktop, go to Settings → Connectors → Add custom connector, and paste the endpoint URL above. Claude Desktop does not read remote MCP servers from claude_desktop_config.json — the connector UI is the only supported path.",
|
||||
"client_other_instructions": "Most MCP clients that support remote HTTP servers will accept the endpoint URL above. Add it as a new MCP server in your client and paste your SigNoz API key when the client prompts for authentication. See the docs for client-specific instructions.",
|
||||
|
||||
"step2_title": "Authenticate from your client",
|
||||
"step2_description": "On first connect, your client opens a SigNoz authorization page asking for two values:",
|
||||
"step2_instance_url_label": "SigNoz Instance URL",
|
||||
"step2_api_key_label": "API Key",
|
||||
"step2_admin_cta": "Create service account",
|
||||
"step2_admin_helper": "Create a service account, then add a new key inside it — paste that key into the API Key field.",
|
||||
"step2_viewer_helper": "Only admins can create API keys. Ask your workspace admin for a key with read access, then paste it into the API Key field.",
|
||||
|
||||
"use_cases_title": "What you can do with it",
|
||||
"use_cases_item_1": "Ask your AI assistant to investigate a spiking error rate.",
|
||||
"use_cases_item_2": "Debug a slow service by walking through recent traces.",
|
||||
"use_cases_item_3": "Summarize an alert and suggest likely root causes.",
|
||||
"use_cases_item_4": "Generate dashboards or queries from a natural-language description.",
|
||||
"use_cases_docs_link": "See more use cases",
|
||||
|
||||
"copy_tooltip_enabled": "Copy to clipboard",
|
||||
"copy_tooltip_disabled": "Enter your Cloud region first",
|
||||
"copy_aria_endpoint": "Copy MCP endpoint",
|
||||
"copy_aria_instance_url": "Copy SigNoz instance URL",
|
||||
"copy_aria_snippet_prefix": "Copy ",
|
||||
"copy_aria_snippet_suffix": " config",
|
||||
|
||||
"toast_endpoint_copied": "Endpoint copied to clipboard",
|
||||
"toast_snippet_copied": "Snippet copied to clipboard",
|
||||
"toast_instance_url_copied": "Instance URL copied to clipboard",
|
||||
"toast_region_required": "Enter your Cloud region before copying"
|
||||
}
|
||||
@@ -16,5 +16,6 @@
|
||||
"roles": "Roles",
|
||||
"role_details": "Role Details",
|
||||
"members": "Members",
|
||||
"service_accounts": "Service Accounts"
|
||||
"service_accounts": "Service Accounts",
|
||||
"mcp_server": "MCP Server"
|
||||
}
|
||||
|
||||
@@ -76,5 +76,6 @@
|
||||
"METER": "SigNoz | Meter",
|
||||
"ROLES_SETTINGS": "SigNoz | Roles",
|
||||
"MEMBERS_SETTINGS": "SigNoz | Members",
|
||||
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts"
|
||||
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts",
|
||||
"MCP_SERVER": "SigNoz | MCP Server"
|
||||
}
|
||||
|
||||
@@ -4774,7 +4774,7 @@ export interface RuletypesPostableRuleDTO {
|
||||
* @type string
|
||||
*/
|
||||
alert: string;
|
||||
alertType: RuletypesAlertTypeDTO;
|
||||
alertType?: RuletypesAlertTypeDTO;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
@@ -4899,7 +4899,7 @@ export interface RuletypesRuleDTO {
|
||||
* @type string
|
||||
*/
|
||||
alert: string;
|
||||
alertType: RuletypesAlertTypeDTO;
|
||||
alertType?: RuletypesAlertTypeDTO;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
@@ -4984,8 +4984,8 @@ export interface RuletypesRuleConditionDTO {
|
||||
*/
|
||||
algorithm?: string;
|
||||
compositeQuery: RuletypesAlertCompositeQueryDTO;
|
||||
matchType?: RuletypesMatchTypeDTO;
|
||||
op?: RuletypesCompareOperatorDTO;
|
||||
matchType: RuletypesMatchTypeDTO;
|
||||
op: RuletypesCompareOperatorDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
|
||||
@@ -87,6 +87,7 @@ const ROUTES = {
|
||||
HOME_PAGE: '/',
|
||||
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
|
||||
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import {
|
||||
@@ -30,7 +30,6 @@ export default function ChartWrapper({
|
||||
onDestroy = noop,
|
||||
children,
|
||||
layoutChildren,
|
||||
yAxisUnit,
|
||||
customTooltip,
|
||||
pinnedTooltipElement,
|
||||
'data-testid': testId,
|
||||
@@ -63,13 +62,6 @@ export default function ChartWrapper({
|
||||
[customTooltip],
|
||||
);
|
||||
|
||||
const syncMetadata = useMemo(
|
||||
() => ({
|
||||
yAxisUnit,
|
||||
}),
|
||||
[yAxisUnit],
|
||||
);
|
||||
|
||||
return (
|
||||
<PlotContextProvider>
|
||||
<ChartLayout
|
||||
@@ -107,7 +99,6 @@ export default function ChartWrapper({
|
||||
averageLegendWidth + TOOLTIP_WIDTH_PADDING,
|
||||
)}
|
||||
syncKey={syncKey}
|
||||
syncMetadata={syncMetadata}
|
||||
render={renderTooltipCallback}
|
||||
pinnedTooltipElement={pinnedTooltipElement}
|
||||
/>
|
||||
|
||||
@@ -24,12 +24,13 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
|
||||
}
|
||||
const tooltipProps: HistogramTooltipProps = {
|
||||
...props,
|
||||
timezone: rest.timezone,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
};
|
||||
return <HistogramTooltip {...tooltipProps} />;
|
||||
},
|
||||
[customTooltip, rest.yAxisUnit, rest.decimalPrecision],
|
||||
[customTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,7 +12,10 @@ interface BaseChartProps {
|
||||
height: number;
|
||||
showTooltip?: boolean;
|
||||
showLegend?: boolean;
|
||||
timezone?: Timezone;
|
||||
canPinTooltip?: boolean;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
|
||||
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
@@ -29,31 +32,18 @@ interface UPlotBasedChartProps {
|
||||
layoutChildren?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface UPlotChartDataProps {
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}
|
||||
|
||||
export interface TimeSeriesChartProps
|
||||
extends BaseChartProps,
|
||||
UPlotBasedChartProps,
|
||||
UPlotChartDataProps {
|
||||
timezone?: Timezone;
|
||||
}
|
||||
UPlotBasedChartProps {}
|
||||
|
||||
export interface HistogramChartProps
|
||||
extends BaseChartProps,
|
||||
UPlotBasedChartProps,
|
||||
UPlotChartDataProps {
|
||||
UPlotBasedChartProps {
|
||||
isQueriesMerged?: boolean;
|
||||
}
|
||||
|
||||
export interface BarChartProps
|
||||
extends BaseChartProps,
|
||||
UPlotBasedChartProps,
|
||||
UPlotChartDataProps {
|
||||
export interface BarChartProps extends BaseChartProps, UPlotBasedChartProps {
|
||||
isStackedBarChart?: boolean;
|
||||
timezone?: Timezone;
|
||||
}
|
||||
|
||||
export type ChartProps =
|
||||
|
||||
@@ -123,13 +123,13 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
}}
|
||||
plotRef={onPlotRef}
|
||||
onDestroy={onPlotDestroy}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
layoutChildren={layoutChildren}
|
||||
isStackedBarChart={widget.stackedBarChart ?? false}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
timezone={timezone}
|
||||
>
|
||||
<ContextMenu
|
||||
|
||||
@@ -3,6 +3,8 @@ import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import Histogram from '../../charts/Histogram/Histogram';
|
||||
@@ -27,6 +29,7 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const config = useMemo(() => {
|
||||
return prepareHistogramPanelConfig({
|
||||
@@ -89,9 +92,11 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
onDestroy={(): void => {
|
||||
uPlotRef.current = null;
|
||||
}}
|
||||
isQueriesMerged={widget.mergeAllActiveQueries}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
isQueriesMerged={widget.mergeAllActiveQueries}
|
||||
syncMode={DashboardCursorSync.Crosshair}
|
||||
timezone={timezone}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
|
||||
@@ -48,8 +48,8 @@ jest.mock(
|
||||
{JSON.stringify({
|
||||
legendPosition: props.legendConfig?.position,
|
||||
isQueriesMerged: props.isQueriesMerged,
|
||||
yAxisUnit: props?.yAxisUnit,
|
||||
decimalPrecision: props?.decimalPrecision,
|
||||
yAxisUnit: props.yAxisUnit,
|
||||
decimalPrecision: props.decimalPrecision,
|
||||
})}
|
||||
</div>
|
||||
{props.layoutChildren}
|
||||
|
||||
@@ -112,9 +112,9 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
}}
|
||||
timezone={timezone}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
timezone={timezone}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
.mcp-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
max-width: 55rem;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
&-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--l0-foreground);
|
||||
}
|
||||
|
||||
&-subtitle {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 400;
|
||||
color: var(--l2-foreground);
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
border: 0.0625rem solid var(--l3-background);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
&__card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--l0-foreground);
|
||||
}
|
||||
|
||||
&__step-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-robin-400);
|
||||
color: var(--l0-background);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__card-description {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--l2-foreground);
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
&__endpoint-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__endpoint-field {
|
||||
flex: 1;
|
||||
min-width: 20rem;
|
||||
}
|
||||
|
||||
&__endpoint-value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 0.0625rem solid var(--l3-background);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--l2-background);
|
||||
font-family: var(--font-family-monospace, monospace);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--l0-foreground);
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
cursor: pointer;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-robin-400);
|
||||
}
|
||||
}
|
||||
|
||||
&__region-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border: 0.0625rem solid var(--bg-amber-500);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--bg-amber-500-a10, rgba(255, 193, 7, 0.08));
|
||||
font-size: 0.75rem;
|
||||
color: var(--l2-foreground);
|
||||
line-height: 1.125rem;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--bg-amber-500);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__region-card,
|
||||
&__cta-card {
|
||||
padding: 1rem 1.25rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
&__auth-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
&__auth-field-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--l2-foreground);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
&__info-banner-inline {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-left: 0.1875rem solid var(--bg-robin-400);
|
||||
background: var(--l2-background);
|
||||
border-radius: 0.25rem;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--bg-robin-400);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__endpoint-input {
|
||||
margin-top: 0.5rem;
|
||||
font-family: var(--font-family-monospace, monospace);
|
||||
}
|
||||
|
||||
&__cta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__helper-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--l2-foreground);
|
||||
line-height: 1.125rem;
|
||||
}
|
||||
|
||||
&__tabs-container {
|
||||
.ant-tabs-nav {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__snippet-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__install-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
&__snippet-pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__use-cases {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
|
||||
&-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
|
||||
li {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--l1-foreground);
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__fallback {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border: 0.0625rem dashed var(--l3-background);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--l1-background);
|
||||
max-width: 40rem;
|
||||
|
||||
&-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--l0-foreground);
|
||||
}
|
||||
|
||||
&-body {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--l2-foreground);
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
536
frontend/src/container/MCPServerSettings/MCPServerSettings.tsx
Normal file
536
frontend/src/container/MCPServerSettings/MCPServerSettings.tsx
Normal file
@@ -0,0 +1,536 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { TFunction, useTranslation } from 'react-i18next';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button, Input, Tabs, Tooltip, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import LearnMore from 'components/LearnMore/LearnMore';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import { useGetGlobalConfig } from 'hooks/globalConfig/useGetGlobalConfig';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
Info,
|
||||
KeyRound,
|
||||
Sparkles,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import {
|
||||
docsUrl,
|
||||
MCP_CLIENTS,
|
||||
MCP_DOCS_URL,
|
||||
MCP_USE_CASES_URL,
|
||||
McpClient,
|
||||
} from './clients';
|
||||
import {
|
||||
buildMcpEndpoint,
|
||||
getCloudRegion,
|
||||
normalizeRegion,
|
||||
parseRegionFromUrl,
|
||||
} from './getCloudRegion';
|
||||
|
||||
import './MCPServerSettings.styles.scss';
|
||||
|
||||
const ANALYTICS = {
|
||||
PAGE_VIEWED: 'MCP Settings: Page viewed',
|
||||
CREATE_SA_CLICKED: 'MCP Settings: Create service account clicked',
|
||||
CLIENT_TAB_SELECTED: 'MCP Settings: Client tab selected',
|
||||
SNIPPET_COPIED: 'MCP Settings: Client snippet copied',
|
||||
ONE_CLICK_INSTALL_CLICKED: 'MCP Settings: One-click install clicked',
|
||||
INSTANCE_URL_COPIED: 'MCP Settings: Instance URL copied',
|
||||
DOCS_LINK_CLICKED: 'MCP Settings: Docs link clicked',
|
||||
} as const;
|
||||
|
||||
const ENDPOINT_PLACEHOLDER = 'https://mcp.<region>.signoz.cloud/mcp';
|
||||
|
||||
function NotCloudFallback(): JSX.Element {
|
||||
const { t } = useTranslation('mcpServer');
|
||||
const onClick = useCallback(() => {
|
||||
logEvent(ANALYTICS.DOCS_LINK_CLICKED, { target: 'fallback' });
|
||||
}, []);
|
||||
return (
|
||||
<div className="mcp-settings">
|
||||
<div className="mcp-settings__fallback">
|
||||
<div className="mcp-settings__fallback-title">
|
||||
<Sparkles size={18} /> {t('fallback_title')}
|
||||
</div>
|
||||
<Typography.Text className="mcp-settings__fallback-body">
|
||||
{t('fallback_body')}
|
||||
</Typography.Text>
|
||||
<LearnMore
|
||||
text={t('fallback_docs_link')}
|
||||
url={MCP_DOCS_URL}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CopyIconButtonProps {
|
||||
ariaLabel: string;
|
||||
onCopy: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function CopyIconButton({
|
||||
ariaLabel,
|
||||
onCopy,
|
||||
disabled,
|
||||
}: CopyIconButtonProps): JSX.Element {
|
||||
const { t } = useTranslation('mcpServer');
|
||||
const tooltipTitle = disabled
|
||||
? t('copy_tooltip_disabled')
|
||||
: t('copy_tooltip_enabled');
|
||||
const button = (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
className="mcp-settings__copy-btn"
|
||||
icon={<Copy size={14} />}
|
||||
onClick={onCopy}
|
||||
/>
|
||||
);
|
||||
// Ant Design Tooltip doesn't reliably surface for a disabled Button —
|
||||
// wrap in a span so hover/focus still reaches the Tooltip.
|
||||
return (
|
||||
<Tooltip title={tooltipTitle}>
|
||||
{disabled ? <span>{button}</span> : button}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
CopyIconButton.defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
interface RegionFallbackCardProps {
|
||||
manualRegion: string;
|
||||
onRegionChange: (value: string) => void;
|
||||
onIngestionLinkClick: () => void;
|
||||
t: TFunction<'mcpServer'>;
|
||||
}
|
||||
|
||||
function RegionFallbackCard({
|
||||
manualRegion,
|
||||
onRegionChange,
|
||||
onIngestionLinkClick,
|
||||
t,
|
||||
}: RegionFallbackCardProps): JSX.Element {
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => onRegionChange(e.target.value),
|
||||
[onRegionChange],
|
||||
);
|
||||
return (
|
||||
<div className="mcp-settings__card mcp-settings__region-card">
|
||||
<div className="mcp-settings__region-warning">
|
||||
<TriangleAlert size={14} />
|
||||
<span>
|
||||
{t('region_warning_prefix')}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
className="mcp-settings__inline-link"
|
||||
onClick={onIngestionLinkClick}
|
||||
>
|
||||
{t('region_warning_link')}
|
||||
</Button>
|
||||
{t('region_warning_suffix')}
|
||||
</span>
|
||||
</div>
|
||||
<label
|
||||
className="mcp-settings__auth-field-label"
|
||||
htmlFor="mcp-settings-manual-region"
|
||||
>
|
||||
{t('region_input_label')}
|
||||
</label>
|
||||
<Input
|
||||
id="mcp-settings-manual-region"
|
||||
className="mcp-settings__endpoint-input"
|
||||
size="small"
|
||||
value={manualRegion}
|
||||
placeholder={t('region_input_placeholder')}
|
||||
aria-label={t('region_input_label')}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ClientTabsProps {
|
||||
endpoint: string;
|
||||
activeTab: string;
|
||||
onTabChange: (key: string) => void;
|
||||
onCopySnippet: (clientKey: string, snippet: string) => void;
|
||||
onInstallClick: (clientKey: string) => void;
|
||||
onDocsLinkClick: (target: string) => void;
|
||||
t: TFunction<'mcpServer'>;
|
||||
}
|
||||
|
||||
interface ClientTabChildrenProps {
|
||||
client: McpClient;
|
||||
endpoint: string;
|
||||
onCopySnippet: (clientKey: string, snippet: string) => void;
|
||||
onInstallClick: (clientKey: string) => void;
|
||||
onDocsLinkClick: (target: string) => void;
|
||||
t: TFunction<'mcpServer'>;
|
||||
}
|
||||
|
||||
function ClientTabChildren({
|
||||
client,
|
||||
endpoint,
|
||||
onCopySnippet,
|
||||
onInstallClick,
|
||||
onDocsLinkClick,
|
||||
t,
|
||||
}: ClientTabChildrenProps): JSX.Element {
|
||||
const snippet = client.snippet
|
||||
? client.snippet(endpoint || ENDPOINT_PLACEHOLDER)
|
||||
: null;
|
||||
const installHref =
|
||||
client.installUrl && endpoint ? client.installUrl(endpoint) : null;
|
||||
|
||||
const handleInstallClick = useCallback(() => onInstallClick(client.key), [
|
||||
onInstallClick,
|
||||
client.key,
|
||||
]);
|
||||
const handleDocsClick = useCallback(
|
||||
() => onDocsLinkClick(`client-${client.key}`),
|
||||
[onDocsLinkClick, client.key],
|
||||
);
|
||||
const handleSnippetCopy = useCallback(() => {
|
||||
if (snippet) {
|
||||
onCopySnippet(client.key, snippet);
|
||||
}
|
||||
}, [onCopySnippet, client.key, snippet]);
|
||||
|
||||
const installLabel = client.installLabelKey
|
||||
? t(client.installLabelKey)
|
||||
: `${t('step1_add_to_client_prefix')}${client.label}`;
|
||||
const instructions = client.instructionsKey ? t(client.instructionsKey) : '';
|
||||
|
||||
return (
|
||||
<div className="mcp-settings__snippet-wrapper">
|
||||
{client.installUrl && (
|
||||
<div className="mcp-settings__install-row">
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!installHref}
|
||||
icon={<Download size={14} />}
|
||||
href={installHref ?? undefined}
|
||||
onClick={handleInstallClick}
|
||||
>
|
||||
{installLabel}
|
||||
</Button>
|
||||
<Typography.Text className="mcp-settings__helper-text">
|
||||
{t('step1_manual_fallback')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
{snippet !== null ? (
|
||||
<div className="mcp-settings__endpoint-value mcp-settings__snippet">
|
||||
<pre className="mcp-settings__snippet-pre">{snippet}</pre>
|
||||
<CopyIconButton
|
||||
ariaLabel={`${t('copy_aria_snippet_prefix')}${client.label}${t(
|
||||
'copy_aria_snippet_suffix',
|
||||
)}`}
|
||||
disabled={!endpoint}
|
||||
onCopy={handleSnippetCopy}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Typography.Text className="mcp-settings__card-description">
|
||||
{instructions}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<LearnMore
|
||||
text={`${client.label}${t('step1_client_docs_suffix')}`}
|
||||
url={docsUrl(client.docsPath)}
|
||||
onClick={handleDocsClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientTabs({
|
||||
endpoint,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
onCopySnippet,
|
||||
onInstallClick,
|
||||
onDocsLinkClick,
|
||||
t,
|
||||
}: ClientTabsProps): JSX.Element {
|
||||
const items = useMemo(
|
||||
() =>
|
||||
MCP_CLIENTS.map((client: McpClient) => ({
|
||||
key: client.key,
|
||||
label: client.label,
|
||||
children: (
|
||||
<ClientTabChildren
|
||||
client={client}
|
||||
endpoint={endpoint}
|
||||
onCopySnippet={onCopySnippet}
|
||||
onInstallClick={onInstallClick}
|
||||
onDocsLinkClick={onDocsLinkClick}
|
||||
t={t}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
[endpoint, onCopySnippet, onInstallClick, onDocsLinkClick, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
className="mcp-settings__tabs-container"
|
||||
activeKey={activeTab}
|
||||
onChange={onTabChange}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AuthCardProps {
|
||||
isAdmin: boolean;
|
||||
instanceUrl: string;
|
||||
onCopyInstanceUrl: () => void;
|
||||
onCreateServiceAccount: () => void;
|
||||
t: TFunction<'mcpServer'>;
|
||||
}
|
||||
|
||||
function AuthCard({
|
||||
isAdmin,
|
||||
instanceUrl,
|
||||
onCopyInstanceUrl,
|
||||
onCreateServiceAccount,
|
||||
t,
|
||||
}: AuthCardProps): JSX.Element {
|
||||
return (
|
||||
<section className="mcp-settings__card mcp-settings__cta-card">
|
||||
<h3 className="mcp-settings__card-title">
|
||||
<span className="mcp-settings__step-badge">2</span> {t('step2_title')}
|
||||
</h3>
|
||||
<Typography.Text className="mcp-settings__card-description">
|
||||
{t('step2_description')}
|
||||
</Typography.Text>
|
||||
<div className="mcp-settings__auth-field">
|
||||
<Typography.Text className="mcp-settings__auth-field-label">
|
||||
{t('step2_instance_url_label')}
|
||||
</Typography.Text>
|
||||
<div className="mcp-settings__endpoint-value">
|
||||
<span data-testid="mcp-instance-url">{instanceUrl}</span>
|
||||
<CopyIconButton
|
||||
ariaLabel={t('copy_aria_instance_url')}
|
||||
onCopy={onCopyInstanceUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mcp-settings__auth-field">
|
||||
<Typography.Text className="mcp-settings__auth-field-label">
|
||||
{t('step2_api_key_label')}
|
||||
</Typography.Text>
|
||||
{isAdmin ? (
|
||||
<div className="mcp-settings__cta-row">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<KeyRound size={14} />}
|
||||
onClick={onCreateServiceAccount}
|
||||
>
|
||||
{t('step2_admin_cta')}
|
||||
</Button>
|
||||
<Typography.Text className="mcp-settings__helper-text">
|
||||
{t('step2_admin_helper')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mcp-settings__info-banner-inline">
|
||||
<Info size={14} />
|
||||
<Typography.Text className="mcp-settings__helper-text">
|
||||
{t('step2_viewer_helper')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface UseCasesCardProps {
|
||||
onDocsLinkClick: (target: string) => void;
|
||||
t: TFunction<'mcpServer'>;
|
||||
}
|
||||
|
||||
function UseCasesCard({ onDocsLinkClick, t }: UseCasesCardProps): JSX.Element {
|
||||
const handleClick = useCallback(() => onDocsLinkClick('use-cases'), [
|
||||
onDocsLinkClick,
|
||||
]);
|
||||
return (
|
||||
<section className="mcp-settings__card mcp-settings__use-cases">
|
||||
<h3 className="mcp-settings__card-title">{t('use_cases_title')}</h3>
|
||||
<ul className="mcp-settings__use-cases-list">
|
||||
<li>{t('use_cases_item_1')}</li>
|
||||
<li>{t('use_cases_item_2')}</li>
|
||||
<li>{t('use_cases_item_3')}</li>
|
||||
<li>{t('use_cases_item_4')}</li>
|
||||
</ul>
|
||||
<LearnMore
|
||||
text={t('use_cases_docs_link')}
|
||||
url={MCP_USE_CASES_URL}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MCPServerSettings(): JSX.Element {
|
||||
const { t } = useTranslation('mcpServer');
|
||||
const { user } = useAppContext();
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
const { notifications } = useNotifications();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const instanceUrl = window.location.origin;
|
||||
|
||||
const { data: globalConfig } = useGetGlobalConfig();
|
||||
const regionFromHost = useMemo(() => getCloudRegion(), []);
|
||||
const regionFromIngestion = useMemo(
|
||||
() =>
|
||||
globalConfig?.data?.ingestion_url
|
||||
? parseRegionFromUrl(globalConfig.data.ingestion_url)
|
||||
: null,
|
||||
[globalConfig?.data?.ingestion_url],
|
||||
);
|
||||
|
||||
const autoDetectedRegion = regionFromHost.region ?? regionFromIngestion;
|
||||
|
||||
const [manualRegion, setManualRegion] = useState<string>('');
|
||||
const [activeTab, setActiveTab] = useState<string>(MCP_CLIENTS[0]?.key ?? '');
|
||||
|
||||
const resolvedRegion: string | null =
|
||||
autoDetectedRegion ?? normalizeRegion(manualRegion);
|
||||
|
||||
const endpoint = resolvedRegion ? buildMcpEndpoint(resolvedRegion) : '';
|
||||
|
||||
// Fire once on mount so we reliably capture every visit, even if the
|
||||
// globalConfig fetch is slow or fails. Region is best-effort from the
|
||||
// hostname at mount time; if ingestion_url resolves later we skip logging
|
||||
// again to avoid double-fires.
|
||||
useEffect(() => {
|
||||
logEvent(ANALYTICS.PAGE_VIEWED, {
|
||||
isCloudUser,
|
||||
role: user.role,
|
||||
region: regionFromHost.region,
|
||||
isAutoDetected: Boolean(regionFromHost.region),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleCopySnippet = useCallback(
|
||||
(clientKey: string, snippet: string) => {
|
||||
if (!endpoint) {
|
||||
notifications.warning({ message: t('toast_region_required') });
|
||||
return;
|
||||
}
|
||||
copyToClipboard(snippet);
|
||||
notifications.success({ message: t('toast_snippet_copied') });
|
||||
logEvent(ANALYTICS.SNIPPET_COPIED, { client: clientKey });
|
||||
},
|
||||
[endpoint, copyToClipboard, notifications, t],
|
||||
);
|
||||
|
||||
const handleCreateServiceAccount = useCallback(() => {
|
||||
logEvent(ANALYTICS.CREATE_SA_CLICKED, {});
|
||||
history.push(
|
||||
`${ROUTES.SERVICE_ACCOUNTS_SETTINGS}?${SA_QUERY_PARAMS.CREATE_SA}=true`,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleCopyInstanceUrl = useCallback(() => {
|
||||
copyToClipboard(instanceUrl);
|
||||
notifications.success({ message: t('toast_instance_url_copied') });
|
||||
logEvent(ANALYTICS.INSTANCE_URL_COPIED, {});
|
||||
}, [copyToClipboard, instanceUrl, notifications, t]);
|
||||
|
||||
const handleDocsLinkClick = useCallback((target: string) => {
|
||||
logEvent(ANALYTICS.DOCS_LINK_CLICKED, { target });
|
||||
}, []);
|
||||
|
||||
const handleIngestionLinkClick = useCallback(() => {
|
||||
logEvent(ANALYTICS.DOCS_LINK_CLICKED, { target: 'ingestion-settings' });
|
||||
history.push(ROUTES.INGESTION_SETTINGS);
|
||||
}, []);
|
||||
|
||||
const handleInstallClick = useCallback((clientKey: string) => {
|
||||
logEvent(ANALYTICS.ONE_CLICK_INSTALL_CLICKED, { client: clientKey });
|
||||
}, []);
|
||||
|
||||
const handleTabChange = useCallback((key: string) => {
|
||||
setActiveTab(key);
|
||||
logEvent(ANALYTICS.CLIENT_TAB_SELECTED, { client: key });
|
||||
}, []);
|
||||
|
||||
if (!isCloudUser) {
|
||||
return <NotCloudFallback />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mcp-settings" data-testid="mcp-settings">
|
||||
<header className="mcp-settings__header">
|
||||
<h2 className="mcp-settings__header-title">
|
||||
<Sparkles size={20} /> {t('page_title')}
|
||||
</h2>
|
||||
<Typography.Text className="mcp-settings__header-subtitle">
|
||||
{t('page_subtitle')}
|
||||
</Typography.Text>
|
||||
</header>
|
||||
|
||||
{!autoDetectedRegion && (
|
||||
<RegionFallbackCard
|
||||
manualRegion={manualRegion}
|
||||
onRegionChange={setManualRegion}
|
||||
onIngestionLinkClick={handleIngestionLinkClick}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="mcp-settings__card">
|
||||
<h3 className="mcp-settings__card-title">
|
||||
<span className="mcp-settings__step-badge">1</span> {t('step1_title')}
|
||||
</h3>
|
||||
<Typography.Text className="mcp-settings__card-description">
|
||||
{t('step1_description')}
|
||||
</Typography.Text>
|
||||
<ClientTabs
|
||||
endpoint={endpoint}
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
onCopySnippet={handleCopySnippet}
|
||||
onInstallClick={handleInstallClick}
|
||||
onDocsLinkClick={handleDocsLinkClick}
|
||||
t={t}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<AuthCard
|
||||
isAdmin={isAdmin}
|
||||
instanceUrl={instanceUrl}
|
||||
onCopyInstanceUrl={handleCopyInstanceUrl}
|
||||
onCreateServiceAccount={handleCreateServiceAccount}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<UseCasesCard onDocsLinkClick={handleDocsLinkClick} t={t} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MCPServerSettings;
|
||||
111
frontend/src/container/MCPServerSettings/clients.ts
Normal file
111
frontend/src/container/MCPServerSettings/clients.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { DOCS_BASE_URL } from 'constants/app';
|
||||
|
||||
export interface McpClient {
|
||||
key: string;
|
||||
// `label` is the client brand name (Cursor, VS Code, Claude Desktop …).
|
||||
// Brand names are not translated.
|
||||
label: string;
|
||||
docsPath: string;
|
||||
snippet: ((endpoint: string) => string) | null;
|
||||
// i18n key under the `mcpServer` namespace. Resolved at render time via t().
|
||||
instructionsKey?: string;
|
||||
installUrl?: (endpoint: string) => string;
|
||||
// i18n key for the install button label. Falls back to
|
||||
// `step1_add_to_client_prefix` + `label` when not set.
|
||||
installLabelKey?: string;
|
||||
}
|
||||
|
||||
function b64url(input: string): string {
|
||||
if (typeof btoa === 'function') {
|
||||
return btoa(input);
|
||||
}
|
||||
// fallback for non-browser TS contexts (never hit at runtime)
|
||||
return Buffer.from(input, 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
export const MCP_CLIENTS: McpClient[] = [
|
||||
{
|
||||
key: 'cursor',
|
||||
label: 'Cursor',
|
||||
docsPath: '/docs/ai/signoz-mcp-server/#cursor',
|
||||
snippet: (endpoint): string =>
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
signoz: {
|
||||
url: endpoint,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
installUrl: (endpoint): string => {
|
||||
const config = b64url(JSON.stringify({ url: endpoint }));
|
||||
return `cursor://anysphere.cursor-deeplink/mcp/install?name=SigNoz&config=${config}`;
|
||||
},
|
||||
installLabelKey: 'client_cursor_install_label',
|
||||
},
|
||||
{
|
||||
key: 'vscode',
|
||||
label: 'VS Code',
|
||||
docsPath: '/docs/ai/signoz-mcp-server/#vs-code',
|
||||
snippet: (endpoint): string =>
|
||||
JSON.stringify(
|
||||
{
|
||||
servers: {
|
||||
signoz: {
|
||||
type: 'http',
|
||||
url: endpoint,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
installUrl: (endpoint): string => {
|
||||
const payload = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
name: 'signoz',
|
||||
config: { type: 'http', url: endpoint },
|
||||
}),
|
||||
);
|
||||
return `vscode:mcp/install?${payload}`;
|
||||
},
|
||||
installLabelKey: 'client_vscode_install_label',
|
||||
},
|
||||
{
|
||||
key: 'claude-desktop',
|
||||
label: 'Claude Desktop',
|
||||
docsPath: '/docs/ai/signoz-mcp-server/#claude-desktop',
|
||||
snippet: null,
|
||||
instructionsKey: 'client_claude_desktop_instructions',
|
||||
},
|
||||
{
|
||||
key: 'claude-code',
|
||||
label: 'Claude Code',
|
||||
docsPath: '/docs/ai/signoz-mcp-server/#claude-code',
|
||||
snippet: (endpoint): string =>
|
||||
`claude mcp add --scope user --transport http signoz ${endpoint}`,
|
||||
},
|
||||
{
|
||||
key: 'codex',
|
||||
label: 'Codex',
|
||||
docsPath: '/docs/ai/signoz-mcp-server/#codex',
|
||||
snippet: (endpoint): string => `codex mcp add signoz --url ${endpoint}`,
|
||||
},
|
||||
{
|
||||
key: 'other',
|
||||
label: 'Other',
|
||||
docsPath: '/docs/ai/signoz-mcp-server/',
|
||||
snippet: null,
|
||||
instructionsKey: 'client_other_instructions',
|
||||
},
|
||||
];
|
||||
|
||||
export function docsUrl(path: string): string {
|
||||
return `${DOCS_BASE_URL}${path}`;
|
||||
}
|
||||
|
||||
export const MCP_DOCS_URL = `${DOCS_BASE_URL}/docs/ai/signoz-mcp-server/`;
|
||||
export const MCP_USE_CASES_URL = `${DOCS_BASE_URL}/docs/ai/use-cases/`;
|
||||
51
frontend/src/container/MCPServerSettings/getCloudRegion.ts
Normal file
51
frontend/src/container/MCPServerSettings/getCloudRegion.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface CloudRegionResult {
|
||||
region: string | null;
|
||||
isKnown: boolean;
|
||||
}
|
||||
|
||||
const VALID_REGION_LABEL = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
export function parseRegionFromSignozCloudHost(host: string): string | null {
|
||||
const parts = host.split('.');
|
||||
const len = parts.length;
|
||||
// SigNoz Cloud tenant hosts follow `<tenant>.<region>.signoz.cloud`
|
||||
// (4 labels). 3-label hosts like `app.signoz.cloud` would wrongly
|
||||
// resolve to region=`app`, so require at least 4 labels.
|
||||
if (len < 4 || parts[len - 1] !== 'cloud' || parts[len - 2] !== 'signoz') {
|
||||
return null;
|
||||
}
|
||||
const region = parts[len - 3]?.toLowerCase() ?? '';
|
||||
if (!VALID_REGION_LABEL.test(region)) {
|
||||
return null;
|
||||
}
|
||||
return region;
|
||||
}
|
||||
|
||||
export function parseRegionFromUrl(url: string): string | null {
|
||||
try {
|
||||
return parseRegionFromSignozCloudHost(new URL(url).hostname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCloudRegion(hostname: string): CloudRegionResult {
|
||||
const region = parseRegionFromSignozCloudHost(hostname);
|
||||
return region ? { region, isKnown: true } : { region: null, isKnown: false };
|
||||
}
|
||||
|
||||
export function normalizeRegion(input: string): string | null {
|
||||
const value = input.trim().toLowerCase();
|
||||
if (!VALID_REGION_LABEL.test(value)) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildMcpEndpoint(region: string): string {
|
||||
return `https://mcp.${region}.signoz.cloud/mcp`;
|
||||
}
|
||||
|
||||
export function getCloudRegion(): CloudRegionResult {
|
||||
return parseCloudRegion(window.location.hostname);
|
||||
}
|
||||
@@ -192,7 +192,8 @@ const onboardingConfigWithLinks = [
|
||||
'setup',
|
||||
],
|
||||
imgUrl: signozBrandLogoUrl,
|
||||
link: '/docs/ai/signoz-mcp-server/',
|
||||
link: '/settings/mcp-server',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'migrate-from-datadog',
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
Settings,
|
||||
Shield,
|
||||
Slack,
|
||||
Sparkles,
|
||||
Unplug,
|
||||
User,
|
||||
UserPlus,
|
||||
@@ -337,6 +338,13 @@ export const settingsNavSections: SettingsNavSection[] = [
|
||||
isEnabled: false,
|
||||
itemKey: 'integrations',
|
||||
},
|
||||
{
|
||||
key: ROUTES.MCP_SERVER,
|
||||
label: 'MCP Server',
|
||||
icon: <Sparkles size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'mcp-server',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -157,6 +157,7 @@ export const routesToSkip = [
|
||||
ROUTES.ORG_SETTINGS,
|
||||
ROUTES.MEMBERS_SETTINGS,
|
||||
ROUTES.SERVICE_ACCOUNTS_SETTINGS,
|
||||
ROUTES.MCP_SERVER,
|
||||
ROUTES.INGESTION_SETTINGS,
|
||||
ROUTES.ERROR_DETAIL,
|
||||
ROUTES.LOGS_PIPELINES,
|
||||
|
||||
@@ -62,10 +62,10 @@ export interface TooltipRenderArgs {
|
||||
|
||||
export interface BaseTooltipProps {
|
||||
showTooltipHeader?: boolean;
|
||||
timezone?: Timezone;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
content?: TooltipContentItem[];
|
||||
timezone?: Timezone;
|
||||
}
|
||||
|
||||
export interface TimeSeriesTooltipProps
|
||||
|
||||
@@ -4,7 +4,6 @@ import cx from 'classnames';
|
||||
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { syncCursorRegistry } from './syncCursorRegistry';
|
||||
import {
|
||||
createInitialControllerState,
|
||||
createSetCursorHandler,
|
||||
@@ -41,7 +40,6 @@ export default function TooltipPlugin({
|
||||
maxHeight = 600,
|
||||
syncMode = DashboardCursorSync.None,
|
||||
syncKey = '_tooltip_sync_global_',
|
||||
syncMetadata,
|
||||
pinnedTooltipElement,
|
||||
canPinTooltip = false,
|
||||
}: TooltipPluginProps): JSX.Element | null {
|
||||
@@ -102,29 +100,7 @@ export default function TooltipPlugin({
|
||||
// crosshair / tooltip can follow the dashboard-wide cursor.
|
||||
if (syncMode !== DashboardCursorSync.None && config.scales[0]?.props.time) {
|
||||
config.setCursor({
|
||||
sync: { key: syncKey, scales: ['x', 'y'] },
|
||||
});
|
||||
|
||||
// Show the horizontal crosshair only when the receiving panel shares
|
||||
// the same y-axis unit as the source panel. When this panel is the
|
||||
// source (cursor.event != null) the line is always shown and this
|
||||
// panel's metadata is written to the registry so receivers can read it.
|
||||
config.addHook('setCursor', (u: uPlot): void => {
|
||||
const yCursorEl = u.root.querySelector<HTMLElement>('.u-cursor-y');
|
||||
if (!yCursorEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (u.cursor.event != null) {
|
||||
// This panel is the source — publish metadata and always show line.
|
||||
syncCursorRegistry.setMetadata(syncKey, syncMetadata);
|
||||
yCursorEl.style.display = '';
|
||||
} else {
|
||||
// This panel is receiving sync — show only if units match.
|
||||
const sourceMeta = syncCursorRegistry.getMetadata(syncKey);
|
||||
yCursorEl.style.display =
|
||||
sourceMeta?.yAxisUnit === syncMetadata?.yAxisUnit ? '' : 'none';
|
||||
}
|
||||
sync: { key: syncKey, scales: ['x', null] },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { TooltipSyncMetadata } from './types';
|
||||
|
||||
/**
|
||||
* Module-level registry that tracks the metadata of the panel currently
|
||||
* acting as the cursor source (the one being hovered) per sync group.
|
||||
*
|
||||
* uPlot fires the source panel's setCursor hook before broadcasting to
|
||||
* receivers, so the registry is always populated before receivers read it.
|
||||
*
|
||||
* Receivers use this to make decisions such as:
|
||||
* - Whether to show the horizontal crosshair line (matching yAxisUnit)
|
||||
* - Future: what to render inside the tooltip (matching groupBy, etc.)
|
||||
*/
|
||||
const metadataBySyncKey = new Map<string, TooltipSyncMetadata | undefined>();
|
||||
|
||||
export const syncCursorRegistry = {
|
||||
setMetadata(syncKey: string, metadata: TooltipSyncMetadata | undefined): void {
|
||||
metadataBySyncKey.set(syncKey, metadata);
|
||||
},
|
||||
|
||||
getMetadata(syncKey: string): TooltipSyncMetadata | undefined {
|
||||
return metadataBySyncKey.get(syncKey);
|
||||
},
|
||||
};
|
||||
@@ -34,16 +34,11 @@ export interface TooltipLayoutInfo {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface TooltipSyncMetadata {
|
||||
yAxisUnit?: string;
|
||||
}
|
||||
|
||||
export interface TooltipPluginProps {
|
||||
config: UPlotConfigBuilder;
|
||||
canPinTooltip?: boolean;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncKey?: string;
|
||||
syncMetadata?: TooltipSyncMetadata;
|
||||
render: (args: TooltipRenderArgs) => ReactNode;
|
||||
pinnedTooltipElement?: (clickData: TooltipClickData) => ReactNode;
|
||||
maxWidth?: number;
|
||||
|
||||
@@ -516,7 +516,7 @@ describe('TooltipPlugin', () => {
|
||||
);
|
||||
|
||||
expect(setCursorSpy).toHaveBeenCalledWith({
|
||||
sync: { key: 'dashboard-sync', scales: ['x', 'y'] },
|
||||
sync: { key: 'dashboard-sync', scales: ['x', null] },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -75,6 +75,11 @@ function SettingsPage(): JSX.Element {
|
||||
}
|
||||
|
||||
if (isCloudUser) {
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled: item.key === ROUTES.MCP_SERVER ? true : item.isEnabled,
|
||||
}));
|
||||
|
||||
if (isAdmin) {
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
|
||||
@@ -8,6 +8,7 @@ import GeneralSettings from 'container/GeneralSettings';
|
||||
import GeneralSettingsCloud from 'container/GeneralSettingsCloud';
|
||||
import IngestionSettings from 'container/IngestionSettings/IngestionSettings';
|
||||
import MultiIngestionSettings from 'container/IngestionSettings/MultiIngestionSettings';
|
||||
import MCPServerSettings from 'container/MCPServerSettings/MCPServerSettings';
|
||||
import MySettings from 'container/MySettings';
|
||||
import OrganizationSettings from 'container/OrganizationSettings';
|
||||
import RolesSettings from 'container/RolesSettings';
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
Pencil,
|
||||
Plus,
|
||||
Shield,
|
||||
Sparkles,
|
||||
User,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
@@ -205,6 +207,19 @@ export const serviceAccountsSettings = (
|
||||
},
|
||||
];
|
||||
|
||||
export const mcpServerSettings = (t: TFunction): RouteTabProps['routes'] => [
|
||||
{
|
||||
Component: MCPServerSettings,
|
||||
name: (
|
||||
<div className="periscope-tab">
|
||||
<Sparkles size={16} /> {t('routes:mcp_server').toString()}
|
||||
</div>
|
||||
),
|
||||
route: ROUTES.MCP_SERVER,
|
||||
key: ROUTES.MCP_SERVER,
|
||||
},
|
||||
];
|
||||
|
||||
export const createAlertChannels = (t: TFunction): RouteTabProps['routes'] => [
|
||||
{
|
||||
Component: (): JSX.Element => (
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
generalSettings,
|
||||
ingestionSettings,
|
||||
keyboardShortcuts,
|
||||
mcpServerSettings,
|
||||
membersSettings,
|
||||
multiIngestionSettings,
|
||||
mySettings,
|
||||
@@ -79,6 +80,10 @@ export const getRoutes = (
|
||||
...createAlertChannels(t),
|
||||
...editAlertChannels(t),
|
||||
...keyboardShortcuts(t),
|
||||
// Route is registered for everyone so direct-URL visitors see the
|
||||
// in-page fallback. Sidebar visibility is still Cloud-only, gated in
|
||||
// Settings.tsx.
|
||||
...mcpServerSettings(t),
|
||||
);
|
||||
|
||||
return settings;
|
||||
|
||||
@@ -132,4 +132,5 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
METER_EXPLORER_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
PUBLIC_DASHBOARD: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ALERT_TYPE_SELECTION: ['ADMIN', 'EDITOR'],
|
||||
MCP_SERVER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
};
|
||||
|
||||
@@ -44,7 +44,6 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
Description: "This endpoint creates a new alert rule",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
@@ -55,28 +54,27 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.UpdateRuleByID), handler.OpenAPIDef{
|
||||
ID: "UpdateRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Update alert rule",
|
||||
Description: "This endpoint updates an alert rule by ID",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
ID: "UpdateRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Update alert rule",
|
||||
Description: "This endpoint updates an alert rule by ID",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.DeleteRuleByID), handler.OpenAPIDef{
|
||||
ID: "DeleteRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Delete alert rule",
|
||||
Description: "This endpoint deletes an alert rule by ID",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
ID: "DeleteRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Delete alert rule",
|
||||
Description: "This endpoint deletes an alert rule by ID",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -88,7 +86,6 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
Description: "This endpoint applies a partial update to an alert rule by ID",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
@@ -105,7 +102,6 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
Description: "This endpoint fires a test notification for the given rule definition",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
Response: new(ruletypes.GettableTestRule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
@@ -160,27 +156,27 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.UpdateDowntimeScheduleByID), handler.OpenAPIDef{
|
||||
ID: "UpdateDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Update downtime schedule",
|
||||
Description: "This endpoint updates a downtime schedule by ID",
|
||||
Request: new(ruletypes.PostablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
ID: "UpdateDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Update downtime schedule",
|
||||
Description: "This endpoint updates a downtime schedule by ID",
|
||||
Request: new(ruletypes.PostablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.DeleteDowntimeScheduleByID), handler.OpenAPIDef{
|
||||
ID: "DeleteDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Delete downtime schedule",
|
||||
Description: "This endpoint deletes a downtime schedule by ID",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
ID: "DeleteDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Delete downtime schedule",
|
||||
Description: "This endpoint deletes a downtime schedule by ID",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,733 +0,0 @@
|
||||
package signozapiserver
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/http/handler"
|
||||
|
||||
// postableRuleExamples returns example payloads attached to every rule-write
|
||||
// endpoint. They cover each alert type, rule type, and composite-query shape.
|
||||
func postableRuleExamples() []handler.OpenAPIExample {
|
||||
rolling := func(evalWindow, frequency string) map[string]any {
|
||||
return map[string]any{
|
||||
"kind": "rolling",
|
||||
"spec": map[string]any{"evalWindow": evalWindow, "frequency": frequency},
|
||||
}
|
||||
}
|
||||
renotify := func(interval string, states ...string) map[string]any {
|
||||
s := make([]any, 0, len(states))
|
||||
for _, v := range states {
|
||||
s = append(s, v)
|
||||
}
|
||||
return map[string]any{
|
||||
"enabled": true,
|
||||
"interval": interval,
|
||||
"alertStates": s,
|
||||
}
|
||||
}
|
||||
|
||||
return []handler.OpenAPIExample{
|
||||
{
|
||||
Name: "metric_threshold_single",
|
||||
Summary: "Metric threshold single builder query",
|
||||
Description: "Fires when a pod consumes more than 80% of its requested CPU for the whole evaluation window. Uses `k8s.pod.cpu_request_utilization`.",
|
||||
Value: map[string]any{
|
||||
"alert": "Pod CPU above 80% of request",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"description": "CPU usage for api-service pods exceeds 80% of the requested CPU",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"condition": map[string]any{
|
||||
"compositeQuery": map[string]any{
|
||||
"queryType": "builder",
|
||||
"panelType": "graph",
|
||||
"unit": "percentunit",
|
||||
"queries": []any{
|
||||
map[string]any{
|
||||
"type": "builder_query",
|
||||
"spec": map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"stepInterval": 60,
|
||||
"aggregations": []any{map[string]any{"metricName": "k8s.pod.cpu_request_utilization", "timeAggregation": "avg", "spaceAggregation": "max"}},
|
||||
"filter": map[string]any{"expression": "k8s.deployment.name = 'api-service'"},
|
||||
"groupBy": []any{
|
||||
map[string]any{"name": "k8s.pod.name", "fieldContext": "resource", "fieldDataType": "string"},
|
||||
map[string]any{"name": "deployment.environment", "fieldContext": "resource", "fieldDataType": "string"},
|
||||
},
|
||||
"legend": "{{k8s.pod.name}} ({{deployment.environment}})",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"selectedQueryName": "A",
|
||||
"thresholds": map[string]any{
|
||||
"kind": "basic",
|
||||
"spec": []any{
|
||||
map[string]any{
|
||||
"name": "critical",
|
||||
"op": "above",
|
||||
"matchType": "all_the_times",
|
||||
"target": 0.8,
|
||||
"channels": []any{"slack-platform", "pagerduty-oncall"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"evaluation": rolling("15m", "1m"),
|
||||
"notificationSettings": map[string]any{
|
||||
"groupBy": []any{"k8s.pod.name", "deployment.environment"},
|
||||
"renotify": renotify("4h", "firing"),
|
||||
},
|
||||
"labels": map[string]any{"severity": "critical", "team": "platform"},
|
||||
"annotations": map[string]any{
|
||||
"description": "Pod {{$k8s.pod.name}} CPU is at {{$value}} of request in {{$deployment.environment}}.",
|
||||
"summary": "Pod CPU above {{$threshold}} of request",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "metric_threshold_formula",
|
||||
Summary: "Metric threshold multi-query formula",
|
||||
Description: "Computes disk utilization as (1 - available/capacity) * 100 by combining two disabled base queries with a builder_formula. The formula emits 0–100, so compositeQuery.unit is set to \"percent\" and the target is a bare number.",
|
||||
Value: map[string]any{
|
||||
"alert": "PersistentVolume above 80% utilization",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"description": "Disk utilization for a persistent volume is above 80%",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"condition": map[string]any{
|
||||
"compositeQuery": map[string]any{
|
||||
"queryType": "builder",
|
||||
"panelType": "graph",
|
||||
"unit": "percent",
|
||||
"queries": []any{
|
||||
map[string]any{
|
||||
"type": "builder_query",
|
||||
"spec": map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"stepInterval": 60,
|
||||
"disabled": true,
|
||||
"aggregations": []any{map[string]any{"metricName": "k8s.volume.available", "timeAggregation": "max", "spaceAggregation": "max"}},
|
||||
"filter": map[string]any{"expression": "k8s.volume.type = 'persistentVolumeClaim'"},
|
||||
"groupBy": []any{
|
||||
map[string]any{"name": "k8s.persistentvolumeclaim.name", "fieldContext": "resource", "fieldDataType": "string"},
|
||||
map[string]any{"name": "k8s.namespace.name", "fieldContext": "resource", "fieldDataType": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "builder_query",
|
||||
"spec": map[string]any{
|
||||
"name": "B",
|
||||
"signal": "metrics",
|
||||
"stepInterval": 60,
|
||||
"disabled": true,
|
||||
"aggregations": []any{map[string]any{"metricName": "k8s.volume.capacity", "timeAggregation": "max", "spaceAggregation": "max"}},
|
||||
"filter": map[string]any{"expression": "k8s.volume.type = 'persistentVolumeClaim'"},
|
||||
"groupBy": []any{
|
||||
map[string]any{"name": "k8s.persistentvolumeclaim.name", "fieldContext": "resource", "fieldDataType": "string"},
|
||||
map[string]any{"name": "k8s.namespace.name", "fieldContext": "resource", "fieldDataType": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "builder_formula",
|
||||
"spec": map[string]any{
|
||||
"name": "F1",
|
||||
"expression": "(1 - A/B) * 100",
|
||||
"legend": "{{k8s.persistentvolumeclaim.name}} ({{k8s.namespace.name}})",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"selectedQueryName": "F1",
|
||||
"thresholds": map[string]any{
|
||||
"kind": "basic",
|
||||
"spec": []any{
|
||||
map[string]any{
|
||||
"name": "critical",
|
||||
"op": "above",
|
||||
"matchType": "at_least_once",
|
||||
"target": 80,
|
||||
"channels": []any{"slack-storage"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"evaluation": rolling("30m", "5m"),
|
||||
"notificationSettings": map[string]any{
|
||||
"groupBy": []any{"k8s.namespace.name", "k8s.persistentvolumeclaim.name"},
|
||||
"renotify": renotify("2h", "firing"),
|
||||
},
|
||||
"labels": map[string]any{"severity": "critical"},
|
||||
"annotations": map[string]any{
|
||||
"description": "Volume {{$k8s.persistentvolumeclaim.name}} in {{$k8s.namespace.name}} is {{$value}}% full.",
|
||||
"summary": "Disk utilization above {{$threshold}}%",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "metric_promql",
|
||||
Summary: "Metric threshold PromQL rule",
|
||||
Description: "PromQL expression instead of the builder. Dotted OTEL resource attributes are quoted (\"deployment.environment\"). Useful for queries that combine series with group_right or other Prom operators.",
|
||||
Value: map[string]any{
|
||||
"alert": "Kafka consumer group lag above 1000",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"description": "Consumer group lag computed via PromQL",
|
||||
"ruleType": "promql_rule",
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"condition": map[string]any{
|
||||
"compositeQuery": map[string]any{
|
||||
"queryType": "promql",
|
||||
"panelType": "graph",
|
||||
"queries": []any{
|
||||
map[string]any{
|
||||
"type": "promql",
|
||||
"spec": map[string]any{
|
||||
"name": "A",
|
||||
"query": "(max by(topic, partition, \"deployment.environment\")(kafka_log_end_offset) - on(topic, partition, \"deployment.environment\") group_right max by(group, topic, partition, \"deployment.environment\")(kafka_consumer_committed_offset)) > 0",
|
||||
"legend": "{{topic}}/{{partition}} ({{group}})",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"selectedQueryName": "A",
|
||||
"thresholds": map[string]any{
|
||||
"kind": "basic",
|
||||
"spec": []any{
|
||||
map[string]any{
|
||||
"name": "critical",
|
||||
"op": "above",
|
||||
"matchType": "all_the_times",
|
||||
"target": 1000,
|
||||
"channels": []any{"slack-data-platform", "pagerduty-data"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"evaluation": rolling("10m", "1m"),
|
||||
"notificationSettings": map[string]any{
|
||||
"groupBy": []any{"group", "topic"},
|
||||
"renotify": renotify("1h", "firing"),
|
||||
},
|
||||
"labels": map[string]any{"severity": "critical"},
|
||||
"annotations": map[string]any{
|
||||
"description": "Consumer group {{$group}} is {{$value}} messages behind on {{$topic}}/{{$partition}}.",
|
||||
"summary": "Kafka consumer lag high",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "metric_anomaly",
|
||||
Summary: "Metric anomaly rule (v1 only)",
|
||||
Description: "Anomaly rules are not yet supported under schemaVersion v2alpha1, so this example uses the v1 shape. Wraps a builder query in the `anomaly` function with daily seasonality SigNoz compares each point against the forecast for that time of day. Fires when the anomaly score stays below the threshold for the entire window; `requireMinPoints` guards against noisy intervals.",
|
||||
Value: map[string]any{
|
||||
"alert": "Anomalous drop in ingested spans",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"description": "Detect an abrupt drop in span ingestion using a z-score anomaly function",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"evalWindow": "24h",
|
||||
"frequency": "3h",
|
||||
"condition": map[string]any{
|
||||
"compositeQuery": map[string]any{
|
||||
"queryType": "builder",
|
||||
"panelType": "graph",
|
||||
"queries": []any{
|
||||
map[string]any{
|
||||
"type": "builder_query",
|
||||
"spec": map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"stepInterval": 21600,
|
||||
"aggregations": []any{map[string]any{"metricName": "otelcol_receiver_accepted_spans", "timeAggregation": "rate", "spaceAggregation": "sum"}},
|
||||
"filter": map[string]any{"expression": "tenant_tier = 'premium'"},
|
||||
"groupBy": []any{map[string]any{"name": "tenant_id", "fieldContext": "attribute", "fieldDataType": "string"}},
|
||||
"functions": []any{
|
||||
map[string]any{
|
||||
"name": "anomaly",
|
||||
"args": []any{map[string]any{"name": "z_score_threshold", "value": 2}},
|
||||
},
|
||||
},
|
||||
"legend": "{{tenant_id}}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"op": "below",
|
||||
"matchType": "all_the_times",
|
||||
"target": 2,
|
||||
"algorithm": "standard",
|
||||
"seasonality": "daily",
|
||||
"selectedQueryName": "A",
|
||||
"requireMinPoints": true,
|
||||
"requiredNumPoints": 3,
|
||||
},
|
||||
"labels": map[string]any{"severity": "warning"},
|
||||
"preferredChannels": []any{"slack-ingestion"},
|
||||
"annotations": map[string]any{
|
||||
"description": "Ingestion rate for tenant {{$tenant_id}} is anomalously low (z-score {{$value}}).",
|
||||
"summary": "Span ingestion anomaly",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "logs_threshold",
|
||||
Summary: "Logs threshold count() over filter",
|
||||
Description: "Counts matching log records (ERROR severity + body contains) over a rolling window. Fires at least once per evaluation when the count exceeds zero.",
|
||||
Value: map[string]any{
|
||||
"alert": "Payments service panic logs",
|
||||
"alertType": "LOGS_BASED_ALERT",
|
||||
"description": "Any panic log line emitted by the payments service",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"condition": map[string]any{
|
||||
"compositeQuery": map[string]any{
|
||||
"queryType": "builder",
|
||||
"panelType": "graph",
|
||||
"queries": []any{
|
||||
map[string]any{
|
||||
"type": "builder_query",
|
||||
"spec": map[string]any{
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"stepInterval": 60,
|
||||
"aggregations": []any{map[string]any{"expression": "count()"}},
|
||||
"filter": map[string]any{"expression": "service.name = 'payments-api' AND severity_text = 'ERROR' AND body CONTAINS 'panic'"},
|
||||
"groupBy": []any{
|
||||
map[string]any{"name": "k8s.pod.name", "fieldContext": "resource", "fieldDataType": "string"},
|
||||
map[string]any{"name": "deployment.environment", "fieldContext": "resource", "fieldDataType": "string"},
|
||||
},
|
||||
"legend": "{{k8s.pod.name}} ({{deployment.environment}})",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"selectedQueryName": "A",
|
||||
"thresholds": map[string]any{
|
||||
"kind": "basic",
|
||||
"spec": []any{
|
||||
map[string]any{
|
||||
"name": "critical",
|
||||
"op": "above",
|
||||
"matchType": "at_least_once",
|
||||
"target": 0,
|
||||
"channels": []any{"slack-payments", "pagerduty-payments"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"evaluation": rolling("5m", "1m"),
|
||||
"notificationSettings": map[string]any{
|
||||
"groupBy": []any{"k8s.pod.name", "deployment.environment"},
|
||||
"renotify": renotify("15m", "firing"),
|
||||
},
|
||||
"labels": map[string]any{"severity": "critical", "team": "payments"},
|
||||
"annotations": map[string]any{
|
||||
"description": "{{$k8s.pod.name}} emitted {{$value}} panic log(s) in {{$deployment.environment}}.",
|
||||
"summary": "Payments service panic",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "logs_error_rate_formula",
|
||||
Summary: "Logs error rate error count / total count × 100",
|
||||
Description: "Two disabled log count queries (A = errors, B = total) combined via a builder_formula into a percentage. Classic service-level error-rate alert pattern for log-based signals.",
|
||||
Value: map[string]any{
|
||||
"alert": "Payments-api error log rate above 1%",
|
||||
"alertType": "LOGS_BASED_ALERT",
|
||||
"description": "Error log ratio as a percentage of total logs for payments-api",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"condition": map[string]any{
|
||||
"compositeQuery": map[string]any{
|
||||
"queryType": "builder",
|
||||
"panelType": "graph",
|
||||
"unit": "percent",
|
||||
"queries": []any{
|
||||
map[string]any{
|
||||
"type": "builder_query",
|
||||
"spec": map[string]any{
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"stepInterval": 60,
|
||||
"disabled": true,
|
||||
"aggregations": []any{map[string]any{"expression": "count()"}},
|
||||
"filter": map[string]any{"expression": "service.name = 'payments-api' AND severity_text IN ['ERROR', 'FATAL']"},
|
||||
"groupBy": []any{map[string]any{"name": "deployment.environment", "fieldContext": "resource", "fieldDataType": "string"}},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "builder_query",
|
||||
"spec": map[string]any{
|
||||
"name": "B",
|
||||
"signal": "logs",
|
||||
"stepInterval": 60,
|
||||
"disabled": true,
|
||||
"aggregations": []any{map[string]any{"expression": "count()"}},
|
||||
"filter": map[string]any{"expression": "service.name = 'payments-api'"},
|
||||
"groupBy": []any{map[string]any{"name": "deployment.environment", "fieldContext": "resource", "fieldDataType": "string"}},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "builder_formula",
|
||||
"spec": map[string]any{
|
||||
"name": "F1",
|
||||
"expression": "(A / B) * 100",
|
||||
"legend": "{{deployment.environment}}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"selectedQueryName": "F1",
|
||||
"thresholds": map[string]any{
|
||||
"kind": "basic",
|
||||
"spec": []any{
|
||||
map[string]any{
|
||||
"name": "critical",
|
||||
"op": "above",
|
||||
"matchType": "at_least_once",
|
||||
"target": 1,
|
||||
"channels": []any{"slack-payments"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"evaluation": rolling("5m", "1m"),
|
||||
"notificationSettings": map[string]any{
|
||||
"groupBy": []any{"deployment.environment"},
|
||||
"renotify": renotify("30m", "firing"),
|
||||
},
|
||||
"labels": map[string]any{"severity": "critical", "team": "payments"},
|
||||
"annotations": map[string]any{
|
||||
"description": "Error log rate in {{$deployment.environment}} is {{$value}}%",
|
||||
"summary": "Payments-api error rate above {{$threshold}}%",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "traces_threshold_latency",
|
||||
Summary: "Traces threshold p99 latency (ns → s conversion)",
|
||||
Description: "Builder query against the traces signal with p99(duration_nano). The series unit is ns (compositeQuery.unit), the target is in seconds (threshold.targetUnit) SigNoz converts before comparing. Canonical shape when series and target live in different units.",
|
||||
Value: map[string]any{
|
||||
"alert": "Search API p99 latency above 5s",
|
||||
"alertType": "TRACES_BASED_ALERT",
|
||||
"description": "p99 duration of the search endpoint exceeds 5s",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"condition": map[string]any{
|
||||
"compositeQuery": map[string]any{
|
||||
"queryType": "builder",
|
||||
"panelType": "graph",
|
||||
"unit": "ns",
|
||||
"queries": []any{
|
||||
map[string]any{
|
||||
"type": "builder_query",
|
||||
"spec": map[string]any{
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"stepInterval": 60,
|
||||
"aggregations": []any{map[string]any{"expression": "p99(duration_nano)"}},
|
||||
"filter": map[string]any{"expression": "service.name = 'search-api' AND name = 'GET /api/v1/search'"},
|
||||
"groupBy": []any{
|
||||
map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"},
|
||||
map[string]any{"name": "http.route", "fieldContext": "attribute", "fieldDataType": "string"},
|
||||
},
|
||||
"legend": "{{service.name}} {{http.route}}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"selectedQueryName": "A",
|
||||
"thresholds": map[string]any{
|
||||
"kind": "basic",
|
||||
"spec": []any{
|
||||
map[string]any{
|
||||
"name": "warning",
|
||||
"op": "above",
|
||||
"matchType": "at_least_once",
|
||||
"target": 5,
|
||||
"targetUnit": "s",
|
||||
"channels": []any{"slack-search"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"evaluation": rolling("5m", "1m"),
|
||||
"notificationSettings": map[string]any{
|
||||
"groupBy": []any{"service.name", "http.route"},
|
||||
"renotify": renotify("30m", "firing"),
|
||||
},
|
||||
"labels": map[string]any{"severity": "warning", "team": "search"},
|
||||
"annotations": map[string]any{
|
||||
"description": "p99 latency for {{$service.name}} on {{$http.route}} crossed {{$threshold}}s.",
|
||||
"summary": "Search-api latency degraded",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "traces_error_rate_formula",
|
||||
Summary: "Traces error rate error spans / total spans × 100",
|
||||
Description: "Two disabled trace count queries (A = error spans where hasError=true, B = total spans) combined via a builder_formula into a percentage. Mirrors the common request-error-rate dashboard shape.",
|
||||
Value: map[string]any{
|
||||
"alert": "Search-api error rate above 5%",
|
||||
"alertType": "TRACES_BASED_ALERT",
|
||||
"description": "Request error rate for search-api, grouped by route",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"condition": map[string]any{
|
||||
"compositeQuery": map[string]any{
|
||||
"queryType": "builder",
|
||||
"panelType": "graph",
|
||||
"unit": "percent",
|
||||
"queries": []any{
|
||||
map[string]any{
|
||||
"type": "builder_query",
|
||||
"spec": map[string]any{
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"stepInterval": 60,
|
||||
"disabled": true,
|
||||
"aggregations": []any{map[string]any{"expression": "count()"}},
|
||||
"filter": map[string]any{"expression": "service.name = 'search-api' AND hasError = true"},
|
||||
"groupBy": []any{
|
||||
map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"},
|
||||
map[string]any{"name": "http.route", "fieldContext": "attribute", "fieldDataType": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "builder_query",
|
||||
"spec": map[string]any{
|
||||
"name": "B",
|
||||
"signal": "traces",
|
||||
"stepInterval": 60,
|
||||
"disabled": true,
|
||||
"aggregations": []any{map[string]any{"expression": "count()"}},
|
||||
"filter": map[string]any{"expression": "service.name = 'search-api'"},
|
||||
"groupBy": []any{
|
||||
map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"},
|
||||
map[string]any{"name": "http.route", "fieldContext": "attribute", "fieldDataType": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "builder_formula",
|
||||
"spec": map[string]any{
|
||||
"name": "F1",
|
||||
"expression": "(A / B) * 100",
|
||||
"legend": "{{service.name}} {{http.route}}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"selectedQueryName": "F1",
|
||||
"thresholds": map[string]any{
|
||||
"kind": "basic",
|
||||
"spec": []any{
|
||||
map[string]any{
|
||||
"name": "critical",
|
||||
"op": "above",
|
||||
"matchType": "at_least_once",
|
||||
"target": 5,
|
||||
"channels": []any{"slack-search", "pagerduty-search"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"evaluation": rolling("5m", "1m"),
|
||||
"notificationSettings": map[string]any{
|
||||
"groupBy": []any{"service.name", "http.route"},
|
||||
"renotify": renotify("15m", "firing"),
|
||||
},
|
||||
"labels": map[string]any{"severity": "critical", "team": "search"},
|
||||
"annotations": map[string]any{
|
||||
"description": "Error rate on {{$service.name}} {{$http.route}} is {{$value}}%",
|
||||
"summary": "Search-api error rate above {{$threshold}}%",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "tiered_thresholds",
|
||||
Summary: "Tiered thresholds with per-tier channels",
|
||||
Description: "Two tiers (warning and critical) in a single rule, each with its own target, op, matchType, and channels so warnings and pages route to different receivers. `alertOnAbsent` + `absentFor` fires a no-data alert when the query returns no series for 15 consecutive evaluations.",
|
||||
Value: map[string]any{
|
||||
"alert": "Kafka consumer lag warn / critical",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"description": "Warn at lag ≥ 50 and page at ≥ 200, tiered via thresholds.spec.",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"condition": map[string]any{
|
||||
"compositeQuery": map[string]any{
|
||||
"queryType": "builder",
|
||||
"panelType": "graph",
|
||||
"queries": []any{
|
||||
map[string]any{
|
||||
"type": "builder_query",
|
||||
"spec": map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"stepInterval": 60,
|
||||
"disabled": true,
|
||||
"aggregations": []any{map[string]any{"metricName": "kafka_log_end_offset", "timeAggregation": "max", "spaceAggregation": "max"}},
|
||||
"filter": map[string]any{"expression": "topic != '__consumer_offsets'"},
|
||||
"groupBy": []any{
|
||||
map[string]any{"name": "topic", "fieldContext": "attribute", "fieldDataType": "string"},
|
||||
map[string]any{"name": "partition", "fieldContext": "attribute", "fieldDataType": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "builder_query",
|
||||
"spec": map[string]any{
|
||||
"name": "B",
|
||||
"signal": "metrics",
|
||||
"stepInterval": 60,
|
||||
"disabled": true,
|
||||
"aggregations": []any{map[string]any{"metricName": "kafka_consumer_committed_offset", "timeAggregation": "max", "spaceAggregation": "max"}},
|
||||
"filter": map[string]any{"expression": "topic != '__consumer_offsets'"},
|
||||
"groupBy": []any{
|
||||
map[string]any{"name": "topic", "fieldContext": "attribute", "fieldDataType": "string"},
|
||||
map[string]any{"name": "partition", "fieldContext": "attribute", "fieldDataType": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "builder_formula",
|
||||
"spec": map[string]any{
|
||||
"name": "F1",
|
||||
"expression": "A - B",
|
||||
"legend": "{{topic}}/{{partition}}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"alertOnAbsent": true,
|
||||
"absentFor": 15,
|
||||
"selectedQueryName": "F1",
|
||||
"thresholds": map[string]any{
|
||||
"kind": "basic",
|
||||
"spec": []any{
|
||||
map[string]any{
|
||||
"name": "warning",
|
||||
"op": "above",
|
||||
"matchType": "all_the_times",
|
||||
"target": 50,
|
||||
"channels": []any{"slack-kafka-info"},
|
||||
},
|
||||
map[string]any{
|
||||
"name": "critical",
|
||||
"op": "above",
|
||||
"matchType": "all_the_times",
|
||||
"target": 200,
|
||||
"channels": []any{"slack-kafka-alerts", "pagerduty-kafka"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"evaluation": rolling("5m", "1m"),
|
||||
"notificationSettings": map[string]any{
|
||||
"groupBy": []any{"topic"},
|
||||
"renotify": renotify("15m", "firing"),
|
||||
},
|
||||
"labels": map[string]any{"team": "data-platform"},
|
||||
"annotations": map[string]any{
|
||||
"description": "Consumer lag for {{$topic}} partition {{$partition}} is {{$value}}.",
|
||||
"summary": "Kafka consumer lag",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "notification_settings",
|
||||
Summary: "Full notification settings (grouping, nodata renotify, grace period)",
|
||||
Description: "Demonstrates the full notificationSettings surface: `groupBy` merges alerts across labels to cut noise, `newGroupEvalDelay` gives newly-appearing series a grace period before firing, `renotify` re-alerts every 30m while firing OR while the alert is in nodata (missing data is treated as actionable), and `usePolicy: false` means channels come from the threshold entries rather than global routing policies. Set `usePolicy: true` to skip per-threshold channels and route via the org-level notification policy instead.",
|
||||
Value: map[string]any{
|
||||
"alert": "API 5xx error rate above 1%",
|
||||
"alertType": "TRACES_BASED_ALERT",
|
||||
"description": "Noise-controlled 5xx error rate alert with renotify on gaps",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"condition": map[string]any{
|
||||
"compositeQuery": map[string]any{
|
||||
"queryType": "builder",
|
||||
"panelType": "graph",
|
||||
"unit": "percent",
|
||||
"queries": []any{
|
||||
map[string]any{
|
||||
"type": "builder_query",
|
||||
"spec": map[string]any{
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"stepInterval": 60,
|
||||
"disabled": true,
|
||||
"aggregations": []any{map[string]any{"expression": "count()"}},
|
||||
"filter": map[string]any{"expression": "service.name CONTAINS 'api' AND http.status_code >= 500"},
|
||||
"groupBy": []any{
|
||||
map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"},
|
||||
map[string]any{"name": "deployment.environment", "fieldContext": "resource", "fieldDataType": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "builder_query",
|
||||
"spec": map[string]any{
|
||||
"name": "B",
|
||||
"signal": "traces",
|
||||
"stepInterval": 60,
|
||||
"disabled": true,
|
||||
"aggregations": []any{map[string]any{"expression": "count()"}},
|
||||
"filter": map[string]any{"expression": "service.name CONTAINS 'api'"},
|
||||
"groupBy": []any{
|
||||
map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"},
|
||||
map[string]any{"name": "deployment.environment", "fieldContext": "resource", "fieldDataType": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "builder_formula",
|
||||
"spec": map[string]any{
|
||||
"name": "F1",
|
||||
"expression": "(A / B) * 100",
|
||||
"legend": "{{service.name}} ({{deployment.environment}})",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"selectedQueryName": "F1",
|
||||
"thresholds": map[string]any{
|
||||
"kind": "basic",
|
||||
"spec": []any{
|
||||
map[string]any{
|
||||
"name": "critical",
|
||||
"op": "above",
|
||||
"matchType": "at_least_once",
|
||||
"target": 1,
|
||||
"channels": []any{"slack-api-alerts", "pagerduty-oncall"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"evaluation": rolling("5m", "1m"),
|
||||
"notificationSettings": map[string]any{
|
||||
"groupBy": []any{"service.name", "deployment.environment"},
|
||||
"newGroupEvalDelay": "2m",
|
||||
"usePolicy": false,
|
||||
"renotify": renotify("30m", "firing", "nodata"),
|
||||
},
|
||||
"labels": map[string]any{"team": "platform"},
|
||||
"annotations": map[string]any{
|
||||
"description": "{{$service.name}} 5xx rate in {{$deployment.environment}} is {{$value}}%.",
|
||||
"summary": "API service error rate elevated",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
)
|
||||
|
||||
// TestPostableRuleExamplesValidate verifies every example payload returned by
|
||||
// postableRuleExamples() round-trips through PostableRule.UnmarshalJSON and
|
||||
// passes Validate(). If an example drifts from the runtime contract this
|
||||
// breaks loudly so the spec doesn't ship invalid payloads to users.
|
||||
func TestPostableRuleExamplesValidate(t *testing.T) {
|
||||
for _, example := range postableRuleExamples() {
|
||||
t.Run(example.Name, func(t *testing.T) {
|
||||
raw, err := json.Marshal(example.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal example: %v", err)
|
||||
}
|
||||
|
||||
var rule ruletypes.PostableRule
|
||||
if err := json.Unmarshal(raw, &rule); err != nil {
|
||||
t.Fatalf("unmarshal: %v\npayload: %s", err, raw)
|
||||
}
|
||||
|
||||
if err := rule.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v\npayload: %s", err, raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -207,16 +207,23 @@ func AdjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemet
|
||||
indexes := []telemetrytypes.JSONDataTypeIndex{}
|
||||
fieldContextsSeen := map[telemetrytypes.FieldContext]bool{}
|
||||
dataTypesSeen := map[telemetrytypes.FieldDataType]bool{}
|
||||
jsonTypesSeen := map[string]*telemetrytypes.JSONDataType{}
|
||||
for _, matchingKey := range matchingKeys {
|
||||
materialized = materialized && matchingKey.Materialized
|
||||
fieldContextsSeen[matchingKey.FieldContext] = true
|
||||
dataTypesSeen[matchingKey.FieldDataType] = true
|
||||
if matchingKey.JSONDataType != nil {
|
||||
jsonTypesSeen[matchingKey.JSONDataType.StringValue()] = matchingKey.JSONDataType
|
||||
}
|
||||
indexes = append(indexes, matchingKey.Indexes...)
|
||||
}
|
||||
for _, matchingKey := range contextPrefixedMatchingKeys {
|
||||
materialized = materialized && matchingKey.Materialized
|
||||
fieldContextsSeen[matchingKey.FieldContext] = true
|
||||
dataTypesSeen[matchingKey.FieldDataType] = true
|
||||
if matchingKey.JSONDataType != nil {
|
||||
jsonTypesSeen[matchingKey.JSONDataType.StringValue()] = matchingKey.JSONDataType
|
||||
}
|
||||
indexes = append(indexes, matchingKey.Indexes...)
|
||||
}
|
||||
key.Materialized = materialized
|
||||
@@ -241,6 +248,15 @@ func AdjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemet
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(jsonTypesSeen) == 1 && key.JSONDataType == nil {
|
||||
// all matching keys have same JSON data type, use it
|
||||
for _, jt := range jsonTypesSeen {
|
||||
actions = append(actions, fmt.Sprintf("Adjusting key %s to have JSON data type %s", key, jt.StringValue()))
|
||||
key.JSONDataType = jt
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return actions
|
||||
|
||||
@@ -318,7 +318,7 @@ func TestVisitKey(t *testing.T) {
|
||||
{
|
||||
Name: "count",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeInt64,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeNumber,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -326,7 +326,7 @@ func TestVisitKey(t *testing.T) {
|
||||
{
|
||||
Name: "count",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeInt64,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeNumber,
|
||||
},
|
||||
},
|
||||
expectedErrors: nil,
|
||||
|
||||
@@ -44,6 +44,7 @@ func (c *conditionBuilder) conditionFor(
|
||||
}
|
||||
return cond, nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if operator.IsStringSearchOperator() {
|
||||
|
||||
@@ -276,10 +276,14 @@ func (m *fieldMapper) FieldFor(ctx context.Context, tsStart, tsEnd uint64, key *
|
||||
continue
|
||||
}
|
||||
|
||||
if key.FieldDataType == telemetrytypes.FieldDataTypeUnspecified {
|
||||
if key.JSONDataType == nil {
|
||||
return "", qbtypes.ErrColumnNotFound
|
||||
}
|
||||
|
||||
if key.KeyNameContainsArray() && !key.JSONDataType.IsArray {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "FieldFor not supported for nested fields; only supported for flat paths (e.g. body.status.detail) and paths of Array type: %s(%s)", key.Name, key.FieldDataType)
|
||||
}
|
||||
|
||||
expr, err := m.buildFieldForJSON(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -350,6 +354,7 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
field *telemetrytypes.TelemetryFieldKey,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
) (string, error) {
|
||||
|
||||
fieldExpression, err := m.FieldFor(ctx, tsStart, tsEnd, field)
|
||||
if errors.Is(err, qbtypes.ErrColumnNotFound) {
|
||||
// the key didn't have the right context to be added to the query
|
||||
@@ -388,8 +393,6 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
}
|
||||
fieldExpression = fmt.Sprintf("multiIf(%s, NULL)", strings.Join(args, ", "))
|
||||
}
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s AS `%s`", sqlbuilder.Escape(fieldExpression), field.Name), nil
|
||||
|
||||
@@ -172,15 +172,30 @@ func (c *jsonConditionBuilder) terminalIndexedCondition(node *telemetrytypes.JSO
|
||||
if strings.Contains(fieldPath, telemetrytypes.ArraySepSuffix) {
|
||||
return "", errors.NewInternalf(CodeArrayNavigationFailed, "can not build index condition for array field %s", fieldPath)
|
||||
}
|
||||
if !node.IsTerminal {
|
||||
return "", errors.NewInternalf(errors.CodeInvalidInput, "can not build index condition for non-terminal node %s", fieldPath)
|
||||
|
||||
elemType := node.TerminalConfig.ElemType
|
||||
dynamicExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, elemType.StringValue())
|
||||
indexedExpr := assumeNotNull(dynamicExpr)
|
||||
|
||||
// switch the operator and value for exists and not exists
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorExists:
|
||||
operator = qbtypes.FilterOperatorNotEqual
|
||||
value = getEmptyValue(elemType)
|
||||
case qbtypes.FilterOperatorNotExists:
|
||||
operator = qbtypes.FilterOperatorEqual
|
||||
value = getEmptyValue(elemType)
|
||||
default:
|
||||
// do nothing
|
||||
}
|
||||
|
||||
indexedExpr := schemamigrator.JSONSubColumnIndexExpr(node.Parent.Name, node.Name, node.TerminalConfig.ElemType.StringValue())
|
||||
// TODO(Piyush): indexedExpr should not be formatted here instead value should be formatted
|
||||
// else ClickHouse may not utilize index
|
||||
indexedExpr, formattedValue := querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, value, indexedExpr, operator)
|
||||
return c.applyOperator(sb, indexedExpr, operator, formattedValue)
|
||||
cond, err := c.applyOperator(sb, indexedExpr, operator, formattedValue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return cond, nil
|
||||
}
|
||||
|
||||
// buildPrimitiveTerminalCondition builds the condition if the terminal node is a primitive type
|
||||
@@ -189,31 +204,32 @@ func (c *jsonConditionBuilder) buildPrimitiveTerminalCondition(node *telemetryty
|
||||
fieldPath := node.FieldPath()
|
||||
conditions := []string{}
|
||||
|
||||
// Utilize indexes when available, except for EXISTS/NOT EXISTS checks.
|
||||
// Indexed columns always store a default empty value for absent fields (e.g. "" for strings,
|
||||
// 0 for numbers), so using the index for existence checks would incorrectly exclude rows where
|
||||
// the field genuinely holds the empty/zero value.
|
||||
// utilize indexes for the condition if available
|
||||
//
|
||||
// Note: indexing is also skipped for Array Nested fields because they cannot be indexed.
|
||||
// Note: Indexing code doesn't get executed for Array Nested fields because they can not be indexed
|
||||
indexed := slices.ContainsFunc(node.TerminalConfig.Key.Indexes, func(index telemetrytypes.JSONDataTypeIndex) bool {
|
||||
return index.Type == node.TerminalConfig.ElemType
|
||||
})
|
||||
isExistsCheck := operator == qbtypes.FilterOperatorExists || operator == qbtypes.FilterOperatorNotExists
|
||||
if node.TerminalConfig.ElemType.IndexSupported && indexed && !isExistsCheck {
|
||||
if node.TerminalConfig.ElemType.IndexSupported && indexed {
|
||||
indexCond, err := c.terminalIndexedCondition(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// With a concrete non-zero value the index condition is self-contained.
|
||||
// if qb has a definitive value, we can skip adding a condition to
|
||||
// check the existence of the path in the json column
|
||||
if value != nil && value != getEmptyValue(node.TerminalConfig.ElemType) {
|
||||
return indexCond, nil
|
||||
}
|
||||
// The value is nil or the type's zero/empty value. Because indexed columns always store
|
||||
// that zero value for absent fields, the index alone cannot distinguish "field is absent"
|
||||
// from "field exists with zero value". Append a path-existence check (IS NOT NULL) as a
|
||||
// second condition and AND them together.
|
||||
|
||||
conditions = append(conditions, indexCond)
|
||||
operator = qbtypes.FilterOperatorExists
|
||||
|
||||
// Switch operator to EXISTS except when operator is NOT EXISTS since
|
||||
// indexed paths on assumedNotNull, indexes will always have a default
|
||||
// value so we flip the operator to Exists and filter the rows that
|
||||
// actually have the value
|
||||
if operator != qbtypes.FilterOperatorNotExists {
|
||||
operator = qbtypes.FilterOperatorExists
|
||||
}
|
||||
}
|
||||
|
||||
var formattedValue = value
|
||||
@@ -223,15 +239,20 @@ func (c *jsonConditionBuilder) buildPrimitiveTerminalCondition(node *telemetryty
|
||||
|
||||
fieldExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, node.TerminalConfig.ElemType.StringValue())
|
||||
|
||||
// For non-nested paths with a negative comparison operator (e.g. !=, NOT LIKE, NOT IN),
|
||||
// wrap in assumeNotNull so ClickHouse treats absent paths as the zero value rather than NULL,
|
||||
// which would otherwise cause them to be silently dropped from results.
|
||||
// NOT EXISTS is excluded: we want a true NULL check there, not a zero-value stand-in.
|
||||
// if operator is negative and has a value comparison i.e. excluding EXISTS and NOT EXISTS, we need to assume that the field exists everywhere
|
||||
//
|
||||
// Note: for nested array paths, buildAccessNodeBranches already inverts the operator before
|
||||
// reaching here, so IsNonNestedPath() guards against double-applying the wrapping.
|
||||
if node.IsNonNestedPath() && operator.IsNegativeOperator() && operator != qbtypes.FilterOperatorNotExists {
|
||||
fieldExpr = assumeNotNull(fieldExpr)
|
||||
// Note: here applyNotCondition will return true only if; top level path is being queried and operator is a negative operator
|
||||
// Otherwise this code will be triggered by buildAccessNodeBranches; Where operator would've been already inverted if needed.
|
||||
if node.IsNonNestedPath() {
|
||||
yes, _ := applyNotCondition(operator)
|
||||
if yes {
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorNotExists:
|
||||
// skip
|
||||
default:
|
||||
fieldExpr = assumeNotNull(fieldExpr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fieldExpr, formattedValue = querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, formattedValue, fieldExpr, operator)
|
||||
|
||||
@@ -220,7 +220,7 @@ func TestJSONStmtBuilder_PrimitivePaths(t *testing.T) {
|
||||
expected: TestExpected{
|
||||
WhereClause: "(((LOWER(toString(dynamicElement(body_v2.`user.age`, 'Int64'))) LIKE LOWER(?)) AND has(JSONAllPaths(body_v2), 'user.age')) OR ((LOWER(dynamicElement(body_v2.`user.age`, 'String')) LIKE LOWER(?)) AND has(JSONAllPaths(body_v2), 'user.age')))",
|
||||
Args: []any{"%25%", "%25%", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `user.age` is ambiguous, found 2 different combinations of field context / data type: [name=user.age,context=body,datatype=int64 name=user.age,context=body,datatype=string]."},
|
||||
Warnings: []string{"Key `user.age` is ambiguous, found 2 different combinations of field context / data type: [name=user.age,context=body,datatype=int64,jsondatatype=Int64 name=user.age,context=body,datatype=string,jsondatatype=String]."},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -413,8 +413,8 @@ func TestStatementBuilderListQueryBodyPromoted(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: TestExpected{
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "%1.65%", 1.65, "%1.65%", 1.65, "%1.65%", 1.65, "%1.65%", 1.65, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,materialized=true name=education[].parameters,context=body,datatype=[]dynamic,materialized=true]."},
|
||||
Args: []any{ "%1.65%", 1.65, "%1.65%", 1.65, "%1.65%", 1.65, "%1.65%", 1.65, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,materialized=true,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,materialized=true,jsondatatype=Array(Dynamic)]."},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
@@ -440,8 +440,8 @@ func TestStatementBuilderListQueryBodyPromoted(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: TestExpected{
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "%true%", true, "%true%", true, "%true%", true, "%true%", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,materialized=true name=education[].parameters,context=body,datatype=[]dynamic,materialized=true]."},
|
||||
Args: []any{ "%true%", true, "%true%", true, "%true%", true, "%true%", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,materialized=true,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,materialized=true,jsondatatype=Array(Dynamic)]."},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
@@ -454,8 +454,8 @@ func TestStatementBuilderListQueryBodyPromoted(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: TestExpected{
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "%passed%", "passed", "%passed%", "passed", "%passed%", "passed", "%passed%", "passed", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,materialized=true name=education[].parameters,context=body,datatype=[]dynamic,materialized=true]."},
|
||||
Args: []any{ "%passed%", "passed", "%passed%", "passed", "%passed%", "passed", "%passed%", "passed", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,materialized=true,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,materialized=true,jsondatatype=Array(Dynamic)]."},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
@@ -549,7 +549,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
expected: TestExpected{
|
||||
WhereClause: "((NOT arrayExists(`body_v2.education`-> toFloat64OrNull(dynamicElement(`body_v2.education`.`type`, 'String')) = ?, dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND (NOT arrayExists(`body_v2.education`-> dynamicElement(`body_v2.education`.`type`, 'Int64') = ?, dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))))",
|
||||
Args: []any{int64(10001), int64(10001), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].type` is ambiguous, found 2 different combinations of field context / data type: [name=education[].type,context=body,datatype=string name=education[].type,context=body,datatype=int64]."},
|
||||
Warnings: []string{"Key `education[].type` is ambiguous, found 2 different combinations of field context / data type: [name=education[].type,context=body,datatype=string,jsondatatype=String name=education[].type,context=body,datatype=int64,jsondatatype=Int64]."},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -576,7 +576,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
expected: TestExpected{
|
||||
WhereClause: "(((arrayExists(`body_v2.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> toFloat64(x) = ?, dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'education')) OR ((arrayExists(`body_v2.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)'))) OR arrayExists(x -> accurateCastOrNull(x, 'Float64') = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)')))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'education')))",
|
||||
Args: []any{"%1.65%", 1.65, "%1.65%", 1.65, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64 name=education[].parameters,context=body,datatype=[]dynamic]."},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -585,7 +585,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
expected: TestExpected{
|
||||
WhereClause: "(((arrayExists(`body_v2.education`-> arrayExists(x -> toString(x) = ?, dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))')), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'education')) OR ((arrayExists(`body_v2.education`-> arrayExists(x -> toString(x) = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)'))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'education')))",
|
||||
Args: []any{"passed", "passed", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64 name=education[].parameters,context=body,datatype=[]dynamic]."},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -594,7 +594,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
expected: TestExpected{
|
||||
WhereClause: "(((arrayExists(`body_v2.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> toString(x) = ?, dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'education')) OR ((arrayExists(`body_v2.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)'))) OR arrayExists(x -> toString(x) = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)')))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'education')))",
|
||||
Args: []any{"%passed%", "passed", "%passed%", "passed", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64 name=education[].parameters,context=body,datatype=[]dynamic]."},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -603,7 +603,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
expected: TestExpected{
|
||||
WhereClause: "(((arrayExists(`body_v2.education`-> arrayExists(x -> toFloat64(x) IN (?, ?), dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))')), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'education')) OR ((arrayExists(`body_v2.education`-> arrayExists(x -> toString(x) IN (?, ?), arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)'))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'education')))",
|
||||
Args: []any{1.65, 1.99, "1.65", "1.99", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64 name=education[].parameters,context=body,datatype=[]dynamic]."},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -612,7 +612,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
expected: TestExpected{
|
||||
WhereClause: "((NOT arrayExists(`body_v2.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> toFloat64(x) = ?, dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND (NOT arrayExists(`body_v2.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)'))) OR arrayExists(x -> accurateCastOrNull(x, 'Float64') = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)')))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))))",
|
||||
Args: []any{"%1.65%", float64(1.65), "%1.65%", float64(1.65), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64 name=education[].parameters,context=body,datatype=[]dynamic]."},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -622,7 +622,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
WhereClause: "(has(arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))'), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?) OR has(arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)'), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?))",
|
||||
Args: []any{1.65, 1.65, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{
|
||||
"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64 name=education[].parameters,context=body,datatype=[]dynamic].",
|
||||
"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)].",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -702,7 +702,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
expected: TestExpected{
|
||||
WhereClause: "(((arrayExists(`body_v2.interests`-> arrayExists(`body_v2.interests[].entities`-> arrayExists(`body_v2.interests[].entities[].reviews`-> arrayExists(`body_v2.interests[].entities[].reviews[].entries`-> arrayExists(`body_v2.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_v2.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_v2.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))')) OR arrayExists(x -> toFloat64(x) = ?, dynamicElement(`body_v2.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))'))), dynamicElement(`body_v2.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_v2.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_v2.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_v2.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_v2.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_v2.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'interests')) OR ((arrayExists(`body_v2.interests`-> arrayExists(`body_v2.interests[].entities`-> arrayExists(`body_v2.interests[].entities[].reviews`-> arrayExists(`body_v2.interests[].entities[].reviews[].entries`-> arrayExists(`body_v2.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_v2.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), dynamicElement(`body_v2.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))')) OR arrayExists(x -> toFloat64OrNull(x) = ?, dynamicElement(`body_v2.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))'))), dynamicElement(`body_v2.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_v2.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_v2.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_v2.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_v2.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_v2.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'interests')))",
|
||||
Args: []any{"%4%", float64(4), "%4%", float64(4), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `interests[].entities[].reviews[].entries[].metadata[].positions[].ratings` is ambiguous, found 2 different combinations of field context / data type: [name=interests[].entities[].reviews[].entries[].metadata[].positions[].ratings,context=body,datatype=[]int64 name=interests[].entities[].reviews[].entries[].metadata[].positions[].ratings,context=body,datatype=[]string]."},
|
||||
Warnings: []string{"Key `interests[].entities[].reviews[].entries[].metadata[].positions[].ratings` is ambiguous, found 2 different combinations of field context / data type: [name=interests[].entities[].reviews[].entries[].metadata[].positions[].ratings,context=body,datatype=[]int64,jsondatatype=Array(Nullable(Int64)) name=interests[].entities[].reviews[].entries[].metadata[].positions[].ratings,context=body,datatype=[]string,jsondatatype=Array(Nullable(String))]."},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -761,35 +761,27 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dynamic_array_element_compare_CONTAINS",
|
||||
filter: "interests[].entities[].product_codes Contains 1",
|
||||
name: "dynamic array element comparison",
|
||||
filter: "ids Contains 1",
|
||||
expected: TestExpected{
|
||||
WhereClause: "((arrayExists(`body_v2.interests`-> arrayExists(`body_v2.interests[].entities`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.interests[].entities`.`product_codes`, 'Array(Dynamic)'))) OR arrayExists(x -> accurateCastOrNull(x, 'Float64') = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.interests[].entities`.`product_codes`, 'Array(Dynamic)')))), dynamicElement(`body_v2.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_v2.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'interests'))",
|
||||
WhereClause: "(((arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(body_v2.`ids`, 'Array(Dynamic)'))) OR arrayExists(x -> accurateCastOrNull(x, 'Float64') = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(body_v2.`ids`, 'Array(Dynamic)'))))) AND has(JSONAllPaths(body_v2), 'ids'))",
|
||||
Args: []any{"%1%", float64(1), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dynamic_array_element_compare_HAS_STRING",
|
||||
filter: "has(interests[].entities[].product_codes, '2002')",
|
||||
name: "dynamic array element comparison",
|
||||
filter: "ids != '1'",
|
||||
expected: TestExpected{
|
||||
WhereClause: "has(arrayFlatten(arrayConcat(arrayMap(`body_v2.interests`->arrayMap(`body_v2.interests[].entities`->dynamicElement(`body_v2.interests[].entities`.`product_codes`, 'Array(Dynamic)'), dynamicElement(`body_v2.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_v2.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?)",
|
||||
Args: []any{"2002", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dynamic_array_element_compare_NOT_EQUAL",
|
||||
filter: "interests[].entities[].product_codes != '1'",
|
||||
expected: TestExpected{
|
||||
WhereClause: "(NOT arrayExists(`body_v2.interests`-> arrayExists(`body_v2.interests[].entities`-> arrayExists(x -> accurateCastOrNull(x, 'Float64') = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.interests[].entities`.`product_codes`, 'Array(Dynamic)'))), dynamicElement(`body_v2.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_v2.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))",
|
||||
WhereClause: "(NOT arrayExists(x -> accurateCastOrNull(x, 'Float64') = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(body_v2.`ids`, 'Array(Dynamic)'))))",
|
||||
Args: []any{int64(1), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dynamic_array_element_compare_HAS_INT",
|
||||
filter: "has(interests[].entities[].product_codes, 1001)",
|
||||
name: "dynamic array element comparison boolean",
|
||||
filter: "ids = true",
|
||||
expected: TestExpected{
|
||||
WhereClause: "has(arrayFlatten(arrayConcat(arrayMap(`body_v2.interests`->arrayMap(`body_v2.interests[].entities`->dynamicElement(`body_v2.interests[].entities`.`product_codes`, 'Array(Dynamic)'), dynamicElement(`body_v2.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_v2.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?)",
|
||||
Args: []any{float64(1001), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
WhereClause: "((arrayExists(x -> accurateCastOrNull(x, 'Bool') = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(body_v2.`ids`, 'Array(Dynamic)')))) AND has(JSONAllPaths(body_v2), 'ids'))",
|
||||
Args: []any{true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -836,7 +828,7 @@ func TestJSONStmtBuilder_IndexedPaths(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: TestExpected{
|
||||
WhereClause: "((lower(assumeNotNull(dynamicElement(body_v2.user.name, 'String'))) = ?) AND has(JSONAllPaths(body_v2), 'user.name'))",
|
||||
WhereClause: "((assumeNotNull(dynamicElement(body_v2.`user.name`, 'String')) = ?) AND has(JSONAllPaths(body_v2), 'user.name'))",
|
||||
Args: []any{"alice", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
@@ -848,14 +840,11 @@ func TestJSONStmtBuilder_IndexedPaths(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: TestExpected{
|
||||
WhereClause: "((toFloat64(assumeNotNull(dynamicElement(body_v2.user.address.zip, 'Int64'))) = ?) AND has(JSONAllPaths(body_v2), 'user.address.zip'))",
|
||||
WhereClause: "((toFloat64(assumeNotNull(dynamicElement(body_v2.`user.address.zip`, 'Int64'))) = ?) AND has(JSONAllPaths(body_v2), 'user.address.zip'))",
|
||||
Args: []any{float64(110001), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
// ── indexed exists: index is skipped; emits plain IS NOT NULL ───────────
|
||||
// EXISTS/NOT EXISTS bypass the index because indexed columns store a default
|
||||
// empty value for absent fields, making != "" unreliable for existence checks
|
||||
// (a field with value "" would be incorrectly excluded).
|
||||
// ── indexed exists: emits assumeNotNull != nil AND dynamicElement IS NOT NULL ─
|
||||
{
|
||||
name: "Indexed String Exists",
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
@@ -864,8 +853,8 @@ func TestJSONStmtBuilder_IndexedPaths(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: TestExpected{
|
||||
WhereClause: "(dynamicElement(body_v2.`user.name`, 'String') IS NOT NULL)",
|
||||
Args: []any{"1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
WhereClause: "((assumeNotNull(dynamicElement(body_v2.`user.name`, 'String')) <> ? AND dynamicElement(body_v2.`user.name`, 'String') IS NOT NULL))",
|
||||
Args: []any{"", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
// ── indexed not-equal: assumeNotNull wrapping + no path index ─────────
|
||||
@@ -877,10 +866,26 @@ func TestJSONStmtBuilder_IndexedPaths(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: TestExpected{
|
||||
WhereClause: "(lower(assumeNotNull(dynamicElement(body_v2.user.name, 'String'))) <> ?)",
|
||||
WhereClause: "(assumeNotNull(dynamicElement(body_v2.`user.name`, 'String')) <> ?)",
|
||||
Args: []any{"alice", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
// ── indexed not-exists: assumeNotNull = "" AND assumeNotNull IS NOT NULL ─
|
||||
// FilterOperatorNotExists → Equal + emptyValue("") in the indexed branch,
|
||||
// then a second condition flipped to Exists (IS NOT NULL) on the same
|
||||
// assumeNotNull expr, producing AND(= "", IS NOT NULL).
|
||||
{
|
||||
name: "Indexed String NotExists",
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Filter: &qbtypes.Filter{Expression: "body.user.name NOT EXISTS"},
|
||||
Limit: 10,
|
||||
},
|
||||
expected: TestExpected{
|
||||
WhereClause: "((assumeNotNull(dynamicElement(body_v2.`user.name`, 'String')) = ? AND dynamicElement(body_v2.`user.name`, 'String') IS NULL))",
|
||||
Args: []any{"", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
|
||||
// ── special-character indexed paths ───────────────────────────────────
|
||||
{
|
||||
@@ -891,7 +896,7 @@ func TestJSONStmtBuilder_IndexedPaths(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: TestExpected{
|
||||
WhereClause: "(((toFloat64(assumeNotNull(dynamicElement(body_v2.`http-status`, 'Int64'))) = ?) AND has(JSONAllPaths(body_v2), 'http-status')) OR ((toFloat64OrNull(lower(assumeNotNull(dynamicElement(body_v2.`http-status`, 'String')))) = ?) AND has(JSONAllPaths(body_v2), 'http-status')))",
|
||||
WhereClause: "(((toFloat64(assumeNotNull(dynamicElement(body_v2.`http-status`, 'Int64'))) = ?) AND has(JSONAllPaths(body_v2), 'http-status')) OR ((toFloat64OrNull(assumeNotNull(dynamicElement(body_v2.`http-status`, 'String'))) = ?) AND has(JSONAllPaths(body_v2), 'http-status')))",
|
||||
Args: []any{float64(200), float64(200), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
@@ -903,7 +908,7 @@ func TestJSONStmtBuilder_IndexedPaths(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: TestExpected{
|
||||
WhereClause: "((toFloat64(assumeNotNull(dynamicElement(body_v2.response.`time-taken`, 'Float64'))) > ?) AND has(JSONAllPaths(body_v2), 'response.time-taken'))",
|
||||
WhereClause: "((toFloat64(assumeNotNull(dynamicElement(body_v2.`response.time-taken`, 'Float64'))) > ?) AND has(JSONAllPaths(body_v2), 'response.time-taken'))",
|
||||
Args: []any{float64(1.5), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
@@ -937,192 +942,20 @@ func TestJSONStmtBuilder_IndexedPaths(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStmtBuilder_SelectField(t *testing.T) {
|
||||
enable, disable := jsonQueryTestUtil(t)
|
||||
enable()
|
||||
defer disable()
|
||||
statementBuilder := buildJSONTestStatementBuilder(t, false)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
expected qbtypes.Statement
|
||||
expectedErrContains string
|
||||
}{
|
||||
{
|
||||
name: "select_x_education[].awards[].participated[].members",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Limit: 10,
|
||||
SelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{
|
||||
Name: "education[].awards[].participated[].members",
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "user.name exists",
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp, id, arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->arrayConcat(arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), dynamicElement(`body_v2.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AS `education[].awards[].participated[].members` FROM signoz_logs.distributed_logs_v2 WHERE (dynamicElement(body_v2.`user.name`, 'String') IS NOT NULL) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "select_x_education[].awards[].type",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Limit: 10,
|
||||
SelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{
|
||||
Name: "education[].awards[].type",
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "user.name exists",
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp, id, arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->arrayConcat(arrayMap(`body_v2.education[].awards`->dynamicElement(`body_v2.education[].awards`.`type`, 'String'), dynamicElement(`body_v2.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards`->dynamicElement(`body_v2.education[].awards`.`type`, 'String'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AS `education[].awards[].type` FROM signoz_logs.distributed_logs_v2 WHERE (dynamicElement(body_v2.`user.name`, 'String') IS NOT NULL) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "select_x_user.name",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Limit: 10,
|
||||
SelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{
|
||||
Name: "user.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp, id, dynamicElement(body_v2.`user.name`, 'String') AS `user.name` FROM signoz_logs.distributed_logs_v2 WHERE timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
|
||||
if c.expectedErrContains != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), c.expectedErrContains)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.expected.Query, q.Query)
|
||||
require.Equal(t, c.expected.Args, q.Args)
|
||||
require.Equal(t, c.expected.Warnings, q.Warnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStmtBuilder_OrderBy(t *testing.T) {
|
||||
enable, disable := jsonQueryTestUtil(t)
|
||||
enable()
|
||||
defer disable()
|
||||
statementBuilder := buildJSONTestStatementBuilder(t, false)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
expected qbtypes.Statement
|
||||
expectedErrContains string
|
||||
}{
|
||||
{
|
||||
name: "order_by_education[].awards[].participated[].members",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Limit: 10,
|
||||
Order: []qbtypes.OrderBy{
|
||||
{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "education[].awards[].participated[].members",
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionAsc,
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "user.name exists",
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE (dynamicElement(body_v2.`user.name`, 'String') IS NOT NULL) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->arrayConcat(arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), dynamicElement(`body_v2.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AS `education[].awards[].participated[].members` asc LIMIT ?",
|
||||
Args: []any{"1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "order_by_user.name",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Limit: 10,
|
||||
Order: []qbtypes.OrderBy{
|
||||
{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.name",
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionAsc,
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY dynamicElement(body_v2.`user.name`, 'String') AS `user.name` asc LIMIT ?",
|
||||
Args: []any{"1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
|
||||
if c.expectedErrContains != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), c.expectedErrContains)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.expected.Query, q.Query)
|
||||
require.Equal(t, c.expected.Args, q.Args)
|
||||
require.Equal(t, c.expected.Warnings, q.Warnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func buildTestTelemetryMetadataStore(t *testing.T, addIndexes bool) *telemetrytypestest.MockMetadataStore {
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.SetStaticFields(IntrinsicFields)
|
||||
types, _ := telemetrytypes.TestJSONTypeSet()
|
||||
for path, fieldDataTypes := range types {
|
||||
for _, fdt := range fieldDataTypes {
|
||||
for path, jsonTypes := range types {
|
||||
for _, jsonType := range jsonTypes {
|
||||
key := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: path,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: telemetrytypes.FieldContextBody,
|
||||
FieldDataType: fdt,
|
||||
FieldDataType: telemetrytypes.MappingJSONDataTypeToFieldDataType[jsonType],
|
||||
JSONDataType: &jsonType,
|
||||
}
|
||||
if addIndexes {
|
||||
jsonType := telemetrytypes.MappingFieldDataTypeToJSONDataType[fdt]
|
||||
idx := slices.IndexFunc(telemetrytypes.TestIndexedPaths, func(entry telemetrytypes.TestIndexedPathEntry) bool {
|
||||
return entry.Path == path && entry.Type == jsonType
|
||||
})
|
||||
|
||||
@@ -875,6 +875,7 @@ func TestAdjustKey(t *testing.T) {
|
||||
require.Equal(t, c.expectedKey.FieldContext, key.FieldContext, "field context should match")
|
||||
require.Equal(t, c.expectedKey.FieldDataType, key.FieldDataType, "field data type should match")
|
||||
require.Equal(t, c.expectedKey.Materialized, key.Materialized, "materialized should match")
|
||||
require.Equal(t, c.expectedKey.JSONDataType, key.JSONDataType, "json data type should match")
|
||||
require.Equal(t, c.expectedKey.Indexes, key.Indexes, "json exists should match")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,84 +21,133 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
defaultPathLimit = 100 // Default limit to prevent full table scans
|
||||
|
||||
CodeUnknownJSONDataType = errors.MustNewCode("unknown_json_data_type")
|
||||
CodeFailLoadPromotedPaths = errors.MustNewCode("fail_load_promoted_paths")
|
||||
CodeFailCheckPathPromoted = errors.MustNewCode("fail_check_path_promoted")
|
||||
CodeFailIterateBodyJSONKeys = errors.MustNewCode("fail_iterate_body_json_keys")
|
||||
CodeFailExtractBodyJSONKeys = errors.MustNewCode("fail_extract_body_json_keys")
|
||||
CodeFailLoadLogsJSONIndexes = errors.MustNewCode("fail_load_logs_json_indexes")
|
||||
CodeFailListJSONValues = errors.MustNewCode("fail_list_json_values")
|
||||
CodeFailScanJSONValue = errors.MustNewCode("fail_scan_json_value")
|
||||
CodeFailScanVariant = errors.MustNewCode("fail_scan_variant")
|
||||
CodeFailBuildJSONPathsQuery = errors.MustNewCode("fail_build_json_paths_query")
|
||||
CodeNoPathsToQueryIndexes = errors.MustNewCode("no_paths_to_query_indexes_provided")
|
||||
|
||||
CodeFailedToPrepareBatch = errors.MustNewCode("failed_to_prepare_batch_promoted_paths")
|
||||
CodeFailedToSendBatch = errors.MustNewCode("failed_to_send_batch_promoted_paths")
|
||||
CodeFailedToAppendPath = errors.MustNewCode("failed_to_append_path_promoted_paths")
|
||||
)
|
||||
|
||||
// enrichJSONKeys enriches body-context keys with promoted path info, indexes,
|
||||
// and JSON access plans. parentTypeCache contains parent array types (ArrayJSON/ArrayDynamic)
|
||||
// pre-fetched in the main UNION query.
|
||||
// fetchBodyJSONPaths extracts body JSON paths from the path_types table
|
||||
// This function can be used by both JSONQueryBuilder and metadata extraction
|
||||
// uniquePathLimit: 0 for no limit, >0 for maximum number of unique paths to return
|
||||
// - For startup load: set to 10000 to get top 10k unique paths
|
||||
// - For lookup: set to 0 (no limit needed for single path)
|
||||
// - For metadata API: set to desired pagination limit
|
||||
//
|
||||
// NOTE: enrichment can not work with FuzzySelectors; QB requests exact matches for query building so
|
||||
// parentTypeCache will actually have proper matches and
|
||||
// FuzzyMatching is for Suggestions API so enrichment is not needed.
|
||||
func (t *telemetryMetaStore) enrichJSONKeys(ctx context.Context, selectors []*telemetrytypes.FieldKeySelector, keys []*telemetrytypes.TelemetryFieldKey, parentTypeCache map[string][]telemetrytypes.FieldDataType) error {
|
||||
mapOfExactSelectors := make(map[string]*telemetrytypes.FieldKeySelector)
|
||||
for _, selector := range selectors {
|
||||
if selector.SelectorMatchType != telemetrytypes.FieldSelectorMatchTypeExact {
|
||||
continue
|
||||
// searchOperator: LIKE for pattern matching, EQUAL for exact match.
|
||||
func (t *telemetryMetaStore) fetchBodyJSONPaths(ctx context.Context,
|
||||
fieldKeySelectors []*telemetrytypes.FieldKeySelector) ([]*telemetrytypes.TelemetryFieldKey, []string, bool, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "metadata",
|
||||
instrumentationtypes.CodeFunctionName: "fetchBodyJSONPaths",
|
||||
})
|
||||
|
||||
query, args, limit := buildGetBodyJSONPathsQuery(fieldKeySelectors)
|
||||
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, nil, false, errors.WrapInternalf(err, CodeFailExtractBodyJSONKeys, "failed to extract body JSON keys")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
fieldKeys := []*telemetrytypes.TelemetryFieldKey{}
|
||||
paths := []string{}
|
||||
rowCount := 0
|
||||
for rows.Next() {
|
||||
var path string
|
||||
var typesArray []string // ClickHouse returns array as []string
|
||||
var lastSeen uint64
|
||||
|
||||
err = rows.Scan(&path, &typesArray, &lastSeen)
|
||||
if err != nil {
|
||||
return nil, nil, false, errors.WrapInternalf(err, CodeFailExtractBodyJSONKeys, "failed to scan body JSON key row")
|
||||
}
|
||||
|
||||
mapOfExactSelectors[selector.Name] = selector
|
||||
}
|
||||
|
||||
var filteredKeys []*telemetrytypes.TelemetryFieldKey
|
||||
for _, key := range keys {
|
||||
if key.FieldContext == telemetrytypes.FieldContextBody && mapOfExactSelectors[key.Name] != nil {
|
||||
filteredKeys = append(filteredKeys, key)
|
||||
for _, typ := range typesArray {
|
||||
mapping, found := telemetrytypes.MappingStringToJSONDataType[typ]
|
||||
if !found {
|
||||
t.logger.ErrorContext(ctx, "failed to map type string to JSON data type", slog.String("type", typ), slog.String("path", path))
|
||||
continue
|
||||
}
|
||||
fieldKeys = append(fieldKeys, &telemetrytypes.TelemetryFieldKey{
|
||||
Name: path,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: telemetrytypes.FieldContextBody,
|
||||
FieldDataType: telemetrytypes.MappingJSONDataTypeToFieldDataType[mapping],
|
||||
JSONDataType: &mapping,
|
||||
})
|
||||
}
|
||||
|
||||
paths = append(paths, path)
|
||||
rowCount++
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
return nil, nil, false, errors.WrapInternalf(rows.Err(), CodeFailIterateBodyJSONKeys, "error iterating body JSON keys")
|
||||
}
|
||||
|
||||
if len(filteredKeys) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
paths := make([]string, 0, len(filteredKeys))
|
||||
for _, key := range filteredKeys {
|
||||
paths = append(paths, key.Name)
|
||||
}
|
||||
|
||||
// fetch promoted paths
|
||||
promoted, err := t.GetPromotedPaths(ctx, paths...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// fetch JSON path indexes
|
||||
indexes, err := t.getJSONPathIndexes(ctx, paths...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// apply promoted/index metadata to keys
|
||||
for _, key := range filteredKeys {
|
||||
promotedKey := strings.Split(key.Name, telemetrytypes.ArraySep)[0]
|
||||
key.Materialized = promoted[promotedKey]
|
||||
key.Indexes = indexes[key.Name]
|
||||
}
|
||||
|
||||
// build JSON access plans using the pre-fetched parent type cache
|
||||
return t.buildJSONPlans(filteredKeys, parentTypeCache)
|
||||
return fieldKeys, paths, rowCount <= limit, nil
|
||||
}
|
||||
|
||||
// buildJSONPlans builds JSON access plans for the given keys
|
||||
// using the provided parent type cache (pre-fetched in the main UNION query).
|
||||
func (t *telemetryMetaStore) buildJSONPlans(keys []*telemetrytypes.TelemetryFieldKey, typeCache map[string][]telemetrytypes.FieldDataType) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
func (t *telemetryMetaStore) buildBodyJSONPaths(ctx context.Context,
|
||||
fieldKeySelectors []*telemetrytypes.FieldKeySelector) ([]*telemetrytypes.TelemetryFieldKey, bool, error) {
|
||||
|
||||
fieldKeys, paths, finished, err := t.fetchBodyJSONPaths(ctx, fieldKeySelectors)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
columnMeta := t.jsonColumnMetadata[telemetrytypes.SignalLogs][telemetrytypes.FieldContextBody]
|
||||
promoted, err := t.GetPromotedPaths(ctx, paths...)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
indexes, err := t.getJSONPathIndexes(ctx, paths...)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
for _, fieldKey := range fieldKeys {
|
||||
promotedKey := strings.Split(fieldKey.Name, telemetrytypes.ArraySep)[0]
|
||||
fieldKey.Materialized = promoted[promotedKey]
|
||||
fieldKey.Indexes = indexes[fieldKey.Name]
|
||||
}
|
||||
|
||||
return fieldKeys, finished, t.buildJSONPlans(ctx, fieldKeys)
|
||||
}
|
||||
|
||||
func (t *telemetryMetaStore) buildJSONPlans(ctx context.Context, keys []*telemetrytypes.TelemetryFieldKey) error {
|
||||
parentSelectors := make([]*telemetrytypes.FieldKeySelector, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
if err := key.SetJSONAccessPlan(columnMeta, typeCache); err != nil {
|
||||
parentSelectors = append(parentSelectors, key.ArrayParentSelectors()...)
|
||||
}
|
||||
|
||||
parentKeys, _, _, err := t.fetchBodyJSONPaths(ctx, parentSelectors)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
typeCache := make(map[string][]telemetrytypes.JSONDataType)
|
||||
for _, key := range parentKeys {
|
||||
typeCache[key.Name] = append(typeCache[key.Name], *key.JSONDataType)
|
||||
}
|
||||
|
||||
// build plans for keys now
|
||||
for _, key := range keys {
|
||||
err = key.SetJSONAccessPlan(t.jsonColumnMetadata[telemetrytypes.SignalLogs][telemetrytypes.FieldContextBody], typeCache)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -106,6 +155,51 @@ func (t *telemetryMetaStore) buildJSONPlans(keys []*telemetrytypes.TelemetryFiel
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildGetBodyJSONPathsQuery(fieldKeySelectors []*telemetrytypes.FieldKeySelector) (string, []any, int) {
|
||||
if len(fieldKeySelectors) == 0 {
|
||||
return "", nil, defaultPathLimit
|
||||
}
|
||||
from := fmt.Sprintf("%s.%s", DBName, PathTypesTableName)
|
||||
|
||||
// Build a better query using GROUP BY to deduplicate at database level
|
||||
// This aggregates all types per path and gets the max last_seen, then applies LIMIT
|
||||
sb := sqlbuilder.Select(
|
||||
"path",
|
||||
"groupArray(DISTINCT type) AS types",
|
||||
"max(last_seen) AS last_seen",
|
||||
).From(from)
|
||||
|
||||
limit := 0
|
||||
// Add search filter if provided
|
||||
orClauses := []string{}
|
||||
for _, fieldKeySelector := range fieldKeySelectors {
|
||||
// replace [*] with []
|
||||
fieldKeySelector.Name = strings.ReplaceAll(fieldKeySelector.Name, telemetrytypes.ArrayAnyIndex, telemetrytypes.ArraySep)
|
||||
// Extract search text for body JSON keys
|
||||
keyName := CleanPathPrefixes(fieldKeySelector.Name)
|
||||
if fieldKeySelector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeExact {
|
||||
orClauses = append(orClauses, sb.Equal("path", keyName))
|
||||
} else {
|
||||
// Pattern matching for metadata API (defaults to LIKE behavior for other operators)
|
||||
orClauses = append(orClauses, sb.ILike("path", fmt.Sprintf("%%%s%%", querybuilder.FormatValueForContains(keyName))))
|
||||
}
|
||||
limit += fieldKeySelector.Limit
|
||||
}
|
||||
sb.Where(sb.Or(orClauses...))
|
||||
// Group by path to get unique paths with aggregated types
|
||||
sb.GroupBy("path")
|
||||
|
||||
// Order by max last_seen to get most recent paths first
|
||||
sb.OrderBy("last_seen DESC")
|
||||
if limit == 0 {
|
||||
limit = defaultPathLimit
|
||||
}
|
||||
sb.Limit(limit)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return query, args, limit
|
||||
}
|
||||
|
||||
func (t *telemetryMetaStore) getJSONPathIndexes(ctx context.Context, paths ...string) (map[string][]telemetrytypes.JSONDataTypeIndex, error) {
|
||||
filteredPaths := []string{}
|
||||
for _, path := range paths {
|
||||
|
||||
@@ -7,9 +7,99 @@ import (
|
||||
"github.com/SigNoz/signoz-otel-collector/constants"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildGetBodyJSONPathsQuery(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
fieldKeySelectors []*telemetrytypes.FieldKeySelector
|
||||
expectedSQL string
|
||||
expectedArgs []any
|
||||
expectedLimit int
|
||||
}{
|
||||
|
||||
{
|
||||
name: "Single search text with EQUAL operator",
|
||||
fieldKeySelectors: []*telemetrytypes.FieldKeySelector{
|
||||
{
|
||||
Name: "user.name",
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
|
||||
},
|
||||
},
|
||||
expectedSQL: "SELECT path, groupArray(DISTINCT type) AS types, max(last_seen) AS last_seen FROM signoz_metadata.distributed_field_keys WHERE (path = ?) GROUP BY path ORDER BY last_seen DESC LIMIT ?",
|
||||
expectedArgs: []any{"user.name", defaultPathLimit},
|
||||
expectedLimit: defaultPathLimit,
|
||||
},
|
||||
{
|
||||
name: "Single search text with LIKE operator",
|
||||
fieldKeySelectors: []*telemetrytypes.FieldKeySelector{
|
||||
{
|
||||
Name: "user",
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy,
|
||||
},
|
||||
},
|
||||
expectedSQL: "SELECT path, groupArray(DISTINCT type) AS types, max(last_seen) AS last_seen FROM signoz_metadata.distributed_field_keys WHERE (LOWER(path) LIKE LOWER(?)) GROUP BY path ORDER BY last_seen DESC LIMIT ?",
|
||||
expectedArgs: []any{"%user%", 100},
|
||||
expectedLimit: 100,
|
||||
},
|
||||
{
|
||||
name: "Multiple search texts with EQUAL operator",
|
||||
fieldKeySelectors: []*telemetrytypes.FieldKeySelector{
|
||||
{
|
||||
Name: "user.name",
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
|
||||
},
|
||||
{
|
||||
Name: "user.age",
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
|
||||
},
|
||||
},
|
||||
expectedSQL: "SELECT path, groupArray(DISTINCT type) AS types, max(last_seen) AS last_seen FROM signoz_metadata.distributed_field_keys WHERE (path = ? OR path = ?) GROUP BY path ORDER BY last_seen DESC LIMIT ?",
|
||||
expectedArgs: []any{"user.name", "user.age", defaultPathLimit},
|
||||
expectedLimit: defaultPathLimit,
|
||||
},
|
||||
{
|
||||
name: "Multiple search texts with LIKE operator",
|
||||
fieldKeySelectors: []*telemetrytypes.FieldKeySelector{
|
||||
{
|
||||
Name: "user",
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy,
|
||||
},
|
||||
{
|
||||
Name: "admin",
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy,
|
||||
},
|
||||
},
|
||||
expectedSQL: "SELECT path, groupArray(DISTINCT type) AS types, max(last_seen) AS last_seen FROM signoz_metadata.distributed_field_keys WHERE (LOWER(path) LIKE LOWER(?) OR LOWER(path) LIKE LOWER(?)) GROUP BY path ORDER BY last_seen DESC LIMIT ?",
|
||||
expectedArgs: []any{"%user%", "%admin%", defaultPathLimit},
|
||||
expectedLimit: defaultPathLimit,
|
||||
},
|
||||
{
|
||||
name: "Search with Contains operator (should default to LIKE)",
|
||||
fieldKeySelectors: []*telemetrytypes.FieldKeySelector{
|
||||
{
|
||||
Name: "test",
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy,
|
||||
},
|
||||
},
|
||||
expectedSQL: "SELECT path, groupArray(DISTINCT type) AS types, max(last_seen) AS last_seen FROM signoz_metadata.distributed_field_keys WHERE (LOWER(path) LIKE LOWER(?)) GROUP BY path ORDER BY last_seen DESC LIMIT ?",
|
||||
expectedArgs: []any{"%test%", defaultPathLimit},
|
||||
expectedLimit: defaultPathLimit,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
query, args, limit := buildGetBodyJSONPathsQuery(tc.fieldKeySelectors)
|
||||
require.Equal(t, tc.expectedSQL, query)
|
||||
require.Equal(t, tc.expectedArgs, args)
|
||||
require.Equal(t, tc.expectedLimit, limit)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListLogsJSONIndexesQuery(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
|
||||
@@ -397,156 +397,90 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, fieldKeySelectors
|
||||
// tables to query based on field selectors
|
||||
queryAttributeTable := false
|
||||
queryResourceTable := false
|
||||
queryBodyTable := false
|
||||
|
||||
for _, selector := range fieldKeySelectors {
|
||||
if selector.FieldContext == telemetrytypes.FieldContextUnspecified {
|
||||
// unspecified context, query all tables
|
||||
// unspecified context, query both tables
|
||||
queryAttributeTable = true
|
||||
queryResourceTable = true
|
||||
queryBodyTable = true
|
||||
break
|
||||
} else if selector.FieldContext == telemetrytypes.FieldContextAttribute {
|
||||
queryAttributeTable = true
|
||||
} else if selector.FieldContext == telemetrytypes.FieldContextResource {
|
||||
queryResourceTable = true
|
||||
} else if selector.FieldContext == telemetrytypes.FieldContextBody {
|
||||
queryBodyTable = true
|
||||
}
|
||||
}
|
||||
|
||||
// body keys are gated behind the feature flag
|
||||
queryBodyTable = queryBodyTable && querybuilder.BodyJSONQueryEnabled
|
||||
|
||||
// requestedFieldKeySelectors is the set of names the user explicitly asked for.
|
||||
// Used to ensure a name that is both a parent path AND a directly requested field still surfaces
|
||||
// in the result keys (e.g. "a.b[].c" is a parent of "a.b[].c[].d" but also a queried field).
|
||||
mapOfRequestedSelectors := make(map[string]bool)
|
||||
for _, sel := range fieldKeySelectors {
|
||||
if sel.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeExact {
|
||||
mapOfRequestedSelectors[sel.Name] = true
|
||||
}
|
||||
tablesToQuery := []struct {
|
||||
fieldContext telemetrytypes.FieldContext
|
||||
shouldQuery bool
|
||||
}{
|
||||
{telemetrytypes.FieldContextAttribute, queryAttributeTable},
|
||||
{telemetrytypes.FieldContextResource, queryResourceTable},
|
||||
}
|
||||
|
||||
// pre-compute parent array path names from body selectors for JSON plan building;
|
||||
// these will be fetched as a separate UNION arm filtered to ArrayJSON/ArrayDynamic only.
|
||||
parentPaths := make(map[string]bool)
|
||||
if queryBodyTable {
|
||||
for _, sel := range fieldKeySelectors {
|
||||
if sel.FieldContext != telemetrytypes.FieldContextBody &&
|
||||
sel.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
continue
|
||||
}
|
||||
key := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: sel.Name,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: telemetrytypes.FieldContextBody,
|
||||
}
|
||||
if !key.KeyNameContainsArray() {
|
||||
continue
|
||||
}
|
||||
for _, parent := range key.ArrayParentPaths() {
|
||||
parentPaths[parent] = true
|
||||
}
|
||||
for _, table := range tablesToQuery {
|
||||
if !table.shouldQuery {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldContext := table.fieldContext
|
||||
|
||||
// table name based on field context
|
||||
var tblName string
|
||||
if fieldContext == telemetrytypes.FieldContextAttribute {
|
||||
tblName = t.logsDBName + "." + t.logAttributeKeysTblName
|
||||
} else {
|
||||
tblName = t.logsDBName + "." + t.logResourceKeysTblName
|
||||
}
|
||||
}
|
||||
|
||||
// attribute and resource tables share identical schema (name/datatype columns, lower-cased select,
|
||||
// TagDataType encoding) — only the table name and field context differ.
|
||||
addTagTableQuery := func(tblName string, fieldContext telemetrytypes.FieldContext) {
|
||||
sb := sqlbuilder.Select(
|
||||
"name AS tag_key",
|
||||
fmt.Sprintf("'%s' AS tag_type", fieldContext.TagType()),
|
||||
"lower(datatype) AS tag_data_type", // logs had historical mixed-case data
|
||||
fmt.Sprintf("%d AS priority", getPriorityForContext(fieldContext)),
|
||||
"lower(datatype) AS tag_data_type", // in logs, we had some historical data with capital and small case
|
||||
fmt.Sprintf(`%d AS priority`, getPriorityForContext(fieldContext)),
|
||||
).From(tblName)
|
||||
|
||||
var limit int
|
||||
conds := []string{}
|
||||
for _, sel := range fieldKeySelectors {
|
||||
|
||||
for _, fieldKeySelector := range fieldKeySelectors {
|
||||
// Include this selector if:
|
||||
// 1. It has unspecified context (matches all tables)
|
||||
// 2. Its context matches the current table's context
|
||||
if sel.FieldContext != telemetrytypes.FieldContextUnspecified && sel.FieldContext != fieldContext {
|
||||
if fieldKeySelector.FieldContext != telemetrytypes.FieldContextUnspecified &&
|
||||
fieldKeySelector.FieldContext != fieldContext {
|
||||
continue
|
||||
}
|
||||
|
||||
// key part of the selector
|
||||
fieldKeyConds := []string{}
|
||||
if sel.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeExact {
|
||||
fieldKeyConds = append(fieldKeyConds, sb.E("name", sel.Name))
|
||||
if fieldKeySelector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeExact {
|
||||
fieldKeyConds = append(fieldKeyConds, sb.E("name", fieldKeySelector.Name))
|
||||
} else {
|
||||
fieldKeyConds = append(fieldKeyConds, sb.ILike("name", "%"+escapeForLike(sel.Name)+"%"))
|
||||
fieldKeyConds = append(fieldKeyConds, sb.ILike("name", "%"+escapeForLike(fieldKeySelector.Name)+"%"))
|
||||
}
|
||||
if sel.FieldDataType != telemetrytypes.FieldDataTypeUnspecified {
|
||||
fieldKeyConds = append(fieldKeyConds, sb.E("datatype", sel.FieldDataType.TagDataType()))
|
||||
|
||||
// now look at the field data type
|
||||
if fieldKeySelector.FieldDataType != telemetrytypes.FieldDataTypeUnspecified {
|
||||
fieldKeyConds = append(fieldKeyConds, sb.E("datatype", fieldKeySelector.FieldDataType.TagDataType()))
|
||||
}
|
||||
|
||||
if len(fieldKeyConds) > 0 {
|
||||
conds = append(conds, sb.And(fieldKeyConds...))
|
||||
}
|
||||
limit += fieldKeySelector.Limit
|
||||
}
|
||||
|
||||
if len(conds) > 0 {
|
||||
sb.Where(sb.Or(conds...))
|
||||
}
|
||||
|
||||
sb.GroupBy("name", "datatype")
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
queries = append(queries, query)
|
||||
allArgs = append(allArgs, args...)
|
||||
}
|
||||
|
||||
if queryAttributeTable {
|
||||
addTagTableQuery(t.logsDBName+"."+t.logAttributeKeysTblName, telemetrytypes.FieldContextAttribute)
|
||||
}
|
||||
if queryResourceTable {
|
||||
addTagTableQuery(t.logsDBName+"."+t.logResourceKeysTblName, telemetrytypes.FieldContextResource)
|
||||
}
|
||||
|
||||
// body context uses a different table/schema (field_name, field_data_type) and requires
|
||||
// signal+context base filters. It also fetches parent array container types (ArrayJSON/ArrayDynamic)
|
||||
// needed for JSON access plan building.
|
||||
if queryBodyTable {
|
||||
sb := sqlbuilder.Select(
|
||||
"field_name AS tag_key",
|
||||
fmt.Sprintf("'%s' AS tag_type", telemetrytypes.FieldContextBody.TagType()),
|
||||
"field_data_type AS tag_data_type",
|
||||
fmt.Sprintf("%d AS priority", getPriorityForContext(telemetrytypes.FieldContextBody)),
|
||||
).From(fmt.Sprintf("%s.%s", DBName, FieldKeysTable))
|
||||
|
||||
sb.Where(sb.E("signal", telemetrytypes.SignalLogs.StringValue()))
|
||||
sb.Where(sb.E("field_context", telemetrytypes.FieldContextBody.StringValue()))
|
||||
|
||||
branches := []string{}
|
||||
for _, sel := range fieldKeySelectors {
|
||||
if sel.FieldContext != telemetrytypes.FieldContextUnspecified && sel.FieldContext != telemetrytypes.FieldContextBody {
|
||||
continue
|
||||
}
|
||||
fieldKeyConds := []string{}
|
||||
if sel.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeExact {
|
||||
fieldKeyConds = append(fieldKeyConds, sb.E("field_name", sel.Name))
|
||||
} else {
|
||||
fieldKeyConds = append(fieldKeyConds, sb.ILike("field_name", "%"+escapeForLike(sel.Name)+"%"))
|
||||
}
|
||||
if sel.FieldDataType != telemetrytypes.FieldDataTypeUnspecified {
|
||||
fieldKeyConds = append(fieldKeyConds, sb.E("field_data_type", sel.FieldDataType.StringValue()))
|
||||
}
|
||||
if len(fieldKeyConds) > 0 {
|
||||
branches = append(branches, sb.And(fieldKeyConds...))
|
||||
}
|
||||
if limit == 0 {
|
||||
limit = 1000
|
||||
}
|
||||
if len(parentPaths) > 0 {
|
||||
names := make([]any, 0, len(parentPaths))
|
||||
for n := range parentPaths {
|
||||
names = append(names, n)
|
||||
}
|
||||
branches = append(branches, sb.And(
|
||||
sb.In("field_name", names...),
|
||||
sb.In("field_data_type",
|
||||
telemetrytypes.FieldDataTypeArrayDynamic.StringValue(),
|
||||
telemetrytypes.FieldDataTypeArrayJSON.StringValue(),
|
||||
),
|
||||
))
|
||||
}
|
||||
if len(branches) > 0 {
|
||||
sb.Where(sb.Or(branches...))
|
||||
}
|
||||
sb.GroupBy("field_name", "field_data_type")
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
queries = append(queries, query)
|
||||
allArgs = append(allArgs, args...)
|
||||
@@ -583,7 +517,6 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, fieldKeySelectors
|
||||
defer rows.Close()
|
||||
|
||||
keys := []*telemetrytypes.TelemetryFieldKey{}
|
||||
parentTypes := make(map[string][]telemetrytypes.FieldDataType)
|
||||
rowCount := 0
|
||||
searchTexts := []string{}
|
||||
|
||||
@@ -607,21 +540,6 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, fieldKeySelectors
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetLogsKeys.Error())
|
||||
}
|
||||
|
||||
// ArrayJSON/ArrayDynamic body rows for parent paths are needed by the JSON access plan
|
||||
// builder (enrichJSONKeys). Always record them in parentTypes. Only skip adding to keys
|
||||
// if the user did not also directly request this name — a field like "education" can be
|
||||
// both a parent of "education[].name" and an explicitly queried field in its own right.
|
||||
switch fieldDataType {
|
||||
case telemetrytypes.FieldDataTypeArrayJSON, telemetrytypes.FieldDataTypeArrayDynamic:
|
||||
if fieldContext == telemetrytypes.FieldContextBody && parentPaths[name] {
|
||||
parentTypes[name] = append(parentTypes[name], fieldDataType)
|
||||
if !mapOfRequestedSelectors[name] {
|
||||
continue // skip; don't register the key.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
key, ok := mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()]
|
||||
|
||||
// if there is no materialised column, create a key with the field context and data type
|
||||
@@ -675,11 +593,13 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, fieldKeySelectors
|
||||
}
|
||||
}
|
||||
|
||||
// enrich body keys with promoted paths, indexes, and JSON access plans
|
||||
if querybuilder.BodyJSONQueryEnabled {
|
||||
if err := t.enrichJSONKeys(ctx, fieldKeySelectors, keys, parentTypes); err != nil {
|
||||
return nil, false, err
|
||||
bodyJSONPaths, finished, err := t.buildBodyJSONPaths(ctx, fieldKeySelectors) // LIKE for pattern matching
|
||||
if err != nil {
|
||||
t.logger.ErrorContext(ctx, "failed to extract body JSON paths", errors.Attr(err))
|
||||
}
|
||||
keys = append(keys, bodyJSONPaths...)
|
||||
complete = complete && finished
|
||||
}
|
||||
|
||||
if _, err := t.updateColumnEvolutionMetadataForKeys(ctx, keys); err != nil {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package telemetrymetadata
|
||||
|
||||
import otelconst "github.com/SigNoz/signoz-otel-collector/constants"
|
||||
import otelcollectorconst "github.com/SigNoz/signoz-otel-collector/constants"
|
||||
|
||||
const (
|
||||
DBName = "signoz_metadata"
|
||||
AttributesMetadataTableName = "distributed_attributes_metadata"
|
||||
AttributesMetadataLocalTableName = "attributes_metadata"
|
||||
ColumnEvolutionMetadataTableName = "distributed_column_evolution_metadata"
|
||||
FieldKeysTable = otelconst.DistributedFieldKeysTable
|
||||
PathTypesTableName = otelcollectorconst.DistributedFieldKeysTable
|
||||
// Column Evolution table stores promoted paths as (signal, column_name, field_context, field_name); see signoz-otel-collector metadata_migrations.
|
||||
PromotedPathsTableName = "distributed_column_evolution_metadata"
|
||||
SkipIndexTableName = "system.data_skipping_indices"
|
||||
|
||||
@@ -114,11 +114,11 @@ type AlertCompositeQuery struct {
|
||||
|
||||
type RuleCondition struct {
|
||||
CompositeQuery *AlertCompositeQuery `json:"compositeQuery" required:"true"`
|
||||
CompareOperator CompareOperator `json:"op,omitzero"`
|
||||
CompareOperator CompareOperator `json:"op" required:"true"`
|
||||
Target *float64 `json:"target,omitempty"`
|
||||
AlertOnAbsent bool `json:"alertOnAbsent,omitempty"`
|
||||
AbsentFor uint64 `json:"absentFor,omitempty"`
|
||||
MatchType MatchType `json:"matchType,omitzero"`
|
||||
MatchType MatchType `json:"matchType" required:"true"`
|
||||
TargetUnit string `json:"targetUnit,omitempty"`
|
||||
Algorithm string `json:"algorithm,omitempty"`
|
||||
Seasonality Seasonality `json:"seasonality,omitzero"`
|
||||
|
||||
@@ -50,13 +50,13 @@ const (
|
||||
// PostableRule is used to create alerting rule from HTTP api.
|
||||
type PostableRule struct {
|
||||
AlertName string `json:"alert" required:"true"`
|
||||
AlertType AlertType `json:"alertType" required:"true"`
|
||||
AlertType AlertType `json:"alertType,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
RuleType RuleType `json:"ruleType" required:"true"`
|
||||
RuleType RuleType `json:"ruleType,omitzero" required:"true"`
|
||||
EvalWindow valuer.TextDuration `json:"evalWindow,omitzero"`
|
||||
Frequency valuer.TextDuration `json:"frequency,omitzero"`
|
||||
|
||||
RuleCondition *RuleCondition `json:"condition" required:"true"`
|
||||
RuleCondition *RuleCondition `json:"condition,omitempty" required:"true"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
|
||||
@@ -67,9 +67,9 @@ type PostableRule struct {
|
||||
|
||||
PreferredChannels []string `json:"preferredChannels,omitempty"`
|
||||
|
||||
Version string `json:"version"`
|
||||
Version string `json:"version,omitempty"`
|
||||
|
||||
Evaluation *EvaluationEnvelope `json:"evaluation,omitempty"`
|
||||
Evaluation *EvaluationEnvelope `yaml:"evaluation,omitempty" json:"evaluation,omitempty"`
|
||||
SchemaVersion string `json:"schemaVersion,omitempty"`
|
||||
|
||||
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"`
|
||||
|
||||
@@ -37,6 +37,7 @@ type TelemetryFieldKey struct {
|
||||
FieldContext FieldContext `json:"fieldContext,omitzero"`
|
||||
FieldDataType FieldDataType `json:"fieldDataType,omitzero"`
|
||||
|
||||
JSONDataType *JSONDataType `json:"-"`
|
||||
JSONPlan JSONAccessPlan `json:"-"`
|
||||
Indexes []JSONDataTypeIndex `json:"-"`
|
||||
Materialized bool `json:"-"` // refers to promoted in case of body.... fields
|
||||
@@ -79,11 +80,6 @@ func (f *TelemetryFieldKey) ArrayParentSelectors() []*FieldKeySelector {
|
||||
return selectors
|
||||
}
|
||||
|
||||
// GetJSONDataType derives the JSONDataType from FieldDataType.
|
||||
func (f *TelemetryFieldKey) GetJSONDataType() JSONDataType {
|
||||
return MappingFieldDataTypeToJSONDataType[f.FieldDataType]
|
||||
}
|
||||
|
||||
func (f TelemetryFieldKey) String() string {
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "name=%s", f.Name)
|
||||
@@ -96,6 +92,9 @@ func (f TelemetryFieldKey) String() string {
|
||||
if f.Materialized {
|
||||
sb.WriteString(",materialized=true")
|
||||
}
|
||||
if f.JSONDataType != nil {
|
||||
fmt.Fprintf(&sb, ",jsondatatype=%s", f.JSONDataType.StringValue())
|
||||
}
|
||||
if len(f.Indexes) > 0 {
|
||||
sb.WriteString(",indexes=[")
|
||||
for i, index := range f.Indexes {
|
||||
@@ -118,6 +117,7 @@ func (f TelemetryFieldKey) Text() string {
|
||||
func (f *TelemetryFieldKey) OverrideMetadataFrom(src *TelemetryFieldKey) {
|
||||
f.FieldContext = src.FieldContext
|
||||
f.FieldDataType = src.FieldDataType
|
||||
f.JSONDataType = src.JSONDataType
|
||||
f.Indexes = src.Indexes
|
||||
f.Materialized = src.Materialized
|
||||
f.JSONPlan = src.JSONPlan
|
||||
|
||||
@@ -31,7 +31,7 @@ var (
|
||||
FieldDataTypeArrayInt64 = FieldDataType{valuer.NewString("[]int64")}
|
||||
FieldDataTypeArrayNumber = FieldDataType{valuer.NewString("[]number")}
|
||||
|
||||
FieldDataTypeArrayJSON = FieldDataType{valuer.NewString("[]json")}
|
||||
FieldDataTypeArrayObject = FieldDataType{valuer.NewString("[]object")}
|
||||
FieldDataTypeArrayDynamic = FieldDataType{valuer.NewString("[]dynamic")}
|
||||
|
||||
// Map string representations to FieldDataType values
|
||||
@@ -51,7 +51,7 @@ var (
|
||||
"int8": FieldDataTypeNumber,
|
||||
"int16": FieldDataTypeNumber,
|
||||
"int32": FieldDataTypeNumber,
|
||||
"int64": FieldDataTypeInt64,
|
||||
"int64": FieldDataTypeNumber,
|
||||
"uint": FieldDataTypeNumber,
|
||||
"uint8": FieldDataTypeNumber,
|
||||
"uint16": FieldDataTypeNumber,
|
||||
@@ -72,8 +72,6 @@ var (
|
||||
"[]float64": FieldDataTypeArrayFloat64,
|
||||
"[]number": FieldDataTypeArrayNumber,
|
||||
"[]bool": FieldDataTypeArrayBool,
|
||||
"[]json": FieldDataTypeArrayJSON,
|
||||
"[]dynamic": FieldDataTypeArrayDynamic,
|
||||
|
||||
// c-style array types
|
||||
"string[]": FieldDataTypeArrayString,
|
||||
@@ -81,8 +79,6 @@ var (
|
||||
"float64[]": FieldDataTypeArrayFloat64,
|
||||
"number[]": FieldDataTypeArrayNumber,
|
||||
"bool[]": FieldDataTypeArrayBool,
|
||||
"json[]": FieldDataTypeArrayJSON,
|
||||
"dynamic[]": FieldDataTypeArrayDynamic,
|
||||
}
|
||||
|
||||
fieldDataTypeToCHDataType = map[FieldDataType]string{
|
||||
|
||||
@@ -19,8 +19,7 @@ var (
|
||||
BranchJSON = JSONAccessBranchType{valuer.NewString("json")}
|
||||
BranchDynamic = JSONAccessBranchType{valuer.NewString("dynamic")}
|
||||
|
||||
CodePlanIndexOutOfBounds = errors.MustNewCode("plan_index_out_of_bounds")
|
||||
CodePlanFieldDataTypeMissing = errors.MustNewCode("field_data_type_missing")
|
||||
CodePlanIndexOutOfBounds = errors.MustNewCode("plan_index_out_of_bounds")
|
||||
)
|
||||
|
||||
type JSONColumnMetadata struct {
|
||||
@@ -43,6 +42,9 @@ type JSONAccessNode struct {
|
||||
IsTerminal bool
|
||||
isRoot bool // marked true for only body_v2 and body_promoted
|
||||
|
||||
// Precomputed type information (single source of truth)
|
||||
AvailableTypes []JSONDataType
|
||||
|
||||
// Array type branches (Array(JSON) vs Array(Dynamic))
|
||||
Branches map[JSONAccessBranchType]*JSONAccessNode
|
||||
|
||||
@@ -104,7 +106,7 @@ type planBuilder struct {
|
||||
paths []string // cumulative paths for type cache lookups
|
||||
segments []string // individual path segments for node names
|
||||
isPromoted bool
|
||||
typeCache map[string][]FieldDataType
|
||||
typeCache map[string][]JSONDataType
|
||||
}
|
||||
|
||||
// buildPlan recursively builds the path plan tree.
|
||||
@@ -136,41 +138,34 @@ func (pb *planBuilder) buildPlan(index int, parent *JSONAccessNode, isDynArrChil
|
||||
}
|
||||
}
|
||||
|
||||
// Use cached types from the batched metadata query
|
||||
types, ok := pb.typeCache[pathSoFar]
|
||||
if !ok {
|
||||
return nil, errors.NewInternalf(errors.CodeInvalidInput, "types missing for path %s", pathSoFar)
|
||||
}
|
||||
|
||||
// Create node for this path segment
|
||||
node := &JSONAccessNode{
|
||||
Name: segmentName,
|
||||
IsTerminal: isTerminal,
|
||||
AvailableTypes: types,
|
||||
Branches: make(map[JSONAccessBranchType]*JSONAccessNode),
|
||||
Parent: parent,
|
||||
MaxDynamicTypes: maxTypes,
|
||||
MaxDynamicPaths: maxPaths,
|
||||
}
|
||||
|
||||
hasJSON := slices.Contains(node.AvailableTypes, ArrayJSON)
|
||||
hasDynamic := slices.Contains(node.AvailableTypes, ArrayDynamic)
|
||||
|
||||
// Configure terminal if this is the last part
|
||||
if isTerminal {
|
||||
// fielddatatype must not be unspecified else expression can not be generated
|
||||
if pb.key.FieldDataType == FieldDataTypeUnspecified {
|
||||
return nil, errors.NewInternalf(CodePlanFieldDataTypeMissing, "field data type is missing for path %s", pathSoFar)
|
||||
}
|
||||
|
||||
node.TerminalConfig = &TerminalConfig{
|
||||
Key: pb.key,
|
||||
ElemType: pb.key.GetJSONDataType(),
|
||||
ElemType: *pb.key.JSONDataType,
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
// Use cached types from the batched metadata query
|
||||
types, ok := pb.typeCache[pathSoFar]
|
||||
if !ok {
|
||||
return nil, errors.NewInternalf(errors.CodeInvalidInput, "types missing for path %s", pathSoFar)
|
||||
}
|
||||
|
||||
hasJSON := slices.Contains(types, FieldDataTypeArrayJSON)
|
||||
hasDynamic := slices.Contains(types, FieldDataTypeArrayDynamic)
|
||||
if !hasJSON && !hasDynamic {
|
||||
return nil, errors.NewInternalf(CodePlanFieldDataTypeMissing, "array data type missing for path %s", pathSoFar)
|
||||
}
|
||||
|
||||
if hasJSON {
|
||||
node.Branches[BranchJSON], err = pb.buildPlan(index+1, node, false)
|
||||
if err != nil {
|
||||
@@ -190,7 +185,7 @@ func (pb *planBuilder) buildPlan(index int, parent *JSONAccessNode, isDynArrChil
|
||||
|
||||
// buildJSONAccessPlan builds a tree structure representing the complete JSON path traversal
|
||||
// that precomputes all possible branches and their types.
|
||||
func (key *TelemetryFieldKey) SetJSONAccessPlan(columnInfo JSONColumnMetadata, typeCache map[string][]FieldDataType,
|
||||
func (key *TelemetryFieldKey) SetJSONAccessPlan(columnInfo JSONColumnMetadata, typeCache map[string][]JSONDataType,
|
||||
) error {
|
||||
// if path is empty, return nil
|
||||
if key.Name == "" {
|
||||
|
||||
@@ -19,11 +19,11 @@ const (
|
||||
// ============================================================================
|
||||
|
||||
// makeKey creates a TelemetryFieldKey for testing.
|
||||
func makeKey(name string, dataType FieldDataType, materialized bool) *TelemetryFieldKey {
|
||||
func makeKey(name string, dataType JSONDataType, materialized bool) *TelemetryFieldKey {
|
||||
return &TelemetryFieldKey{
|
||||
Name: name,
|
||||
FieldDataType: dataType,
|
||||
Materialized: materialized,
|
||||
Name: name,
|
||||
JSONDataType: &dataType,
|
||||
Materialized: materialized,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ type jsonAccessTestNode struct {
|
||||
MaxDynamicTypes int `yaml:"maxDynamicTypes,omitempty"`
|
||||
MaxDynamicPaths int `yaml:"maxDynamicPaths,omitempty"`
|
||||
ElemType string `yaml:"elemType,omitempty"`
|
||||
AvailableTypes []string `yaml:"availableTypes,omitempty"`
|
||||
Branches map[string]*jsonAccessTestNode `yaml:"branches,omitempty"`
|
||||
}
|
||||
|
||||
@@ -64,6 +65,14 @@ func toTestNode(n *JSONAccessNode) *jsonAccessTestNode {
|
||||
out.Column = n.Parent.Name
|
||||
}
|
||||
|
||||
// AvailableTypes as strings (using StringValue for stable representation)
|
||||
if len(n.AvailableTypes) > 0 {
|
||||
out.AvailableTypes = make([]string, 0, len(n.AvailableTypes))
|
||||
for _, t := range n.AvailableTypes {
|
||||
out.AvailableTypes = append(out.AvailableTypes, t.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal config
|
||||
if n.TerminalConfig != nil {
|
||||
out.ElemType = n.TerminalConfig.ElemType.StringValue()
|
||||
@@ -233,10 +242,12 @@ func TestPlanJSON_BasicStructure(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "Simple path not promoted",
|
||||
key: makeKey("user.name", FieldDataTypeString, false),
|
||||
key: makeKey("user.name", String, false),
|
||||
expectedYAML: fmt.Sprintf(`
|
||||
- name: user.name
|
||||
column: %s
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
@@ -244,15 +255,19 @@ func TestPlanJSON_BasicStructure(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "Simple path promoted",
|
||||
key: makeKey("user.name", FieldDataTypeString, true),
|
||||
key: makeKey("user.name", String, true),
|
||||
expectedYAML: fmt.Sprintf(`
|
||||
- name: user.name
|
||||
column: %s
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
- name: user.name
|
||||
column: %s
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
isTerminal: true
|
||||
@@ -261,7 +276,7 @@ func TestPlanJSON_BasicStructure(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "Empty path returns error",
|
||||
key: makeKey("", FieldDataTypeString, false),
|
||||
key: makeKey("", String, false),
|
||||
expectErr: true,
|
||||
expectedYAML: "",
|
||||
},
|
||||
@@ -299,10 +314,14 @@ func TestPlanJSON_ArrayPaths(t *testing.T) {
|
||||
expectedYAML: fmt.Sprintf(`
|
||||
- name: education
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: name
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 8
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
@@ -314,19 +333,28 @@ func TestPlanJSON_ArrayPaths(t *testing.T) {
|
||||
expectedYAML: fmt.Sprintf(`
|
||||
- name: education
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: awards
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
branches:
|
||||
json:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 4
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
dynamic:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
isTerminal: true
|
||||
@@ -339,29 +367,43 @@ func TestPlanJSON_ArrayPaths(t *testing.T) {
|
||||
expectedYAML: fmt.Sprintf(`
|
||||
- name: interests
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: entities
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
branches:
|
||||
json:
|
||||
name: reviews
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 4
|
||||
branches:
|
||||
json:
|
||||
name: entries
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 2
|
||||
branches:
|
||||
json:
|
||||
name: metadata
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 1
|
||||
branches:
|
||||
json:
|
||||
name: positions
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
branches:
|
||||
json:
|
||||
name: name
|
||||
availableTypes:
|
||||
- String
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
`, bodyV2Column),
|
||||
@@ -372,10 +414,14 @@ func TestPlanJSON_ArrayPaths(t *testing.T) {
|
||||
expectedYAML: fmt.Sprintf(`
|
||||
- name: education
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: name
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 8
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
@@ -385,7 +431,7 @@ func TestPlanJSON_ArrayPaths(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
key := makeKey(tt.path, FieldDataTypeString, false)
|
||||
key := makeKey(tt.path, String, false)
|
||||
err := key.SetJSONAccessPlan(JSONColumnMetadata{
|
||||
BaseColumn: bodyV2Column,
|
||||
PromotedColumn: bodyPromotedColumn,
|
||||
@@ -404,7 +450,7 @@ func TestPlanJSON_PromotedVsNonPromoted(t *testing.T) {
|
||||
path := "education[].awards[].type"
|
||||
|
||||
t.Run("Non-promoted plan", func(t *testing.T) {
|
||||
key := makeKey(path, FieldDataTypeString, false)
|
||||
key := makeKey(path, String, false)
|
||||
err := key.SetJSONAccessPlan(JSONColumnMetadata{
|
||||
BaseColumn: bodyV2Column,
|
||||
PromotedColumn: bodyPromotedColumn,
|
||||
@@ -415,19 +461,28 @@ func TestPlanJSON_PromotedVsNonPromoted(t *testing.T) {
|
||||
expectedYAML := fmt.Sprintf(`
|
||||
- name: education
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: awards
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
branches:
|
||||
json:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 4
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
dynamic:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
isTerminal: true
|
||||
@@ -438,7 +493,7 @@ func TestPlanJSON_PromotedVsNonPromoted(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Promoted plan", func(t *testing.T) {
|
||||
key := makeKey(path, FieldDataTypeString, true)
|
||||
key := makeKey(path, String, true)
|
||||
err := key.SetJSONAccessPlan(JSONColumnMetadata{
|
||||
BaseColumn: bodyV2Column,
|
||||
PromotedColumn: bodyPromotedColumn,
|
||||
@@ -449,41 +504,59 @@ func TestPlanJSON_PromotedVsNonPromoted(t *testing.T) {
|
||||
expectedYAML := fmt.Sprintf(`
|
||||
- name: education
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: awards
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
branches:
|
||||
json:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 4
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
dynamic:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
- name: education
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
branches:
|
||||
json:
|
||||
name: awards
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
maxDynamicPaths: 64
|
||||
branches:
|
||||
json:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 4
|
||||
maxDynamicPaths: 16
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
dynamic:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
isTerminal: true
|
||||
@@ -505,7 +578,7 @@ func TestPlanJSON_EdgeCases(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "Path with no available types",
|
||||
path: "unknown[].path",
|
||||
path: "unknown.path",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
@@ -514,29 +587,43 @@ func TestPlanJSON_EdgeCases(t *testing.T) {
|
||||
expectedYAML: fmt.Sprintf(`
|
||||
- name: interests
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: entities
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
branches:
|
||||
json:
|
||||
name: reviews
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 4
|
||||
branches:
|
||||
json:
|
||||
name: entries
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 2
|
||||
branches:
|
||||
json:
|
||||
name: metadata
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 1
|
||||
branches:
|
||||
json:
|
||||
name: positions
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
branches:
|
||||
json:
|
||||
name: name
|
||||
availableTypes:
|
||||
- String
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
`, bodyV2Column),
|
||||
@@ -547,10 +634,15 @@ func TestPlanJSON_EdgeCases(t *testing.T) {
|
||||
expectedYAML: fmt.Sprintf(`
|
||||
- name: education
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
- Int64
|
||||
maxDynamicTypes: 8
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
@@ -562,6 +654,8 @@ func TestPlanJSON_EdgeCases(t *testing.T) {
|
||||
expectedYAML: fmt.Sprintf(`
|
||||
- name: education
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
isTerminal: true
|
||||
elemType: Array(JSON)
|
||||
@@ -572,12 +666,12 @@ func TestPlanJSON_EdgeCases(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Choose key type based on path; operator does not affect the tree shape asserted here.
|
||||
keyType := FieldDataTypeString
|
||||
keyType := String
|
||||
switch tt.path {
|
||||
case "education":
|
||||
keyType = FieldDataTypeArrayJSON
|
||||
keyType = ArrayJSON
|
||||
case "education[].type":
|
||||
keyType = FieldDataTypeString
|
||||
keyType = String
|
||||
}
|
||||
key := makeKey(tt.path, keyType, false)
|
||||
err := key.SetJSONAccessPlan(JSONColumnMetadata{
|
||||
@@ -598,7 +692,7 @@ func TestPlanJSON_EdgeCases(t *testing.T) {
|
||||
func TestPlanJSON_TreeStructure(t *testing.T) {
|
||||
types, _ := TestJSONTypeSet()
|
||||
path := "education[].awards[].participated[].team[].branch"
|
||||
key := makeKey(path, FieldDataTypeString, false)
|
||||
key := makeKey(path, String, false)
|
||||
err := key.SetJSONAccessPlan(JSONColumnMetadata{
|
||||
BaseColumn: bodyV2Column,
|
||||
PromotedColumn: bodyPromotedColumn,
|
||||
@@ -609,59 +703,86 @@ func TestPlanJSON_TreeStructure(t *testing.T) {
|
||||
expectedYAML := fmt.Sprintf(`
|
||||
- name: education
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: awards
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
branches:
|
||||
json:
|
||||
name: participated
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 4
|
||||
branches:
|
||||
json:
|
||||
name: team
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 2
|
||||
branches:
|
||||
json:
|
||||
name: branch
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 1
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
dynamic:
|
||||
name: team
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
branches:
|
||||
json:
|
||||
name: branch
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 8
|
||||
maxDynamicPaths: 64
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
dynamic:
|
||||
name: participated
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
branches:
|
||||
json:
|
||||
name: team
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
maxDynamicPaths: 64
|
||||
branches:
|
||||
json:
|
||||
name: branch
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 4
|
||||
maxDynamicPaths: 16
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
dynamic:
|
||||
name: team
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
branches:
|
||||
json:
|
||||
name: branch
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 8
|
||||
maxDynamicPaths: 64
|
||||
isTerminal: true
|
||||
|
||||
@@ -46,6 +46,14 @@ var MappingStringToJSONDataType = map[string]JSONDataType{
|
||||
"Array(JSON)": ArrayJSON,
|
||||
}
|
||||
|
||||
var ScalerTypeToArrayType = map[JSONDataType]JSONDataType{
|
||||
String: ArrayString,
|
||||
Int64: ArrayInt64,
|
||||
Float64: ArrayFloat64,
|
||||
Bool: ArrayBool,
|
||||
Dynamic: ArrayDynamic,
|
||||
}
|
||||
|
||||
var MappingFieldDataTypeToJSONDataType = map[FieldDataType]JSONDataType{
|
||||
FieldDataTypeString: String,
|
||||
FieldDataTypeInt64: Int64,
|
||||
@@ -55,8 +63,18 @@ var MappingFieldDataTypeToJSONDataType = map[FieldDataType]JSONDataType{
|
||||
FieldDataTypeArrayString: ArrayString,
|
||||
FieldDataTypeArrayInt64: ArrayInt64,
|
||||
FieldDataTypeArrayFloat64: ArrayFloat64,
|
||||
FieldDataTypeArrayNumber: ArrayFloat64,
|
||||
FieldDataTypeArrayBool: ArrayBool,
|
||||
FieldDataTypeArrayDynamic: ArrayDynamic,
|
||||
FieldDataTypeArrayJSON: ArrayJSON,
|
||||
}
|
||||
|
||||
var MappingJSONDataTypeToFieldDataType = map[JSONDataType]FieldDataType{
|
||||
String: FieldDataTypeString,
|
||||
Int64: FieldDataTypeInt64,
|
||||
Float64: FieldDataTypeFloat64,
|
||||
Bool: FieldDataTypeBool,
|
||||
ArrayString: FieldDataTypeArrayString,
|
||||
ArrayInt64: FieldDataTypeArrayInt64,
|
||||
ArrayFloat64: FieldDataTypeArrayFloat64,
|
||||
ArrayBool: FieldDataTypeArrayBool,
|
||||
ArrayDynamic: FieldDataTypeArrayDynamic,
|
||||
ArrayJSON: FieldDataTypeArrayObject,
|
||||
}
|
||||
|
||||
@@ -4,69 +4,69 @@ package telemetrytypes
|
||||
// Test JSON Type Set Data Setup
|
||||
// ============================================================================
|
||||
|
||||
// TestJSONTypeSet returns a map of path->field data types for testing.
|
||||
// TestJSONTypeSet returns a map of path->types for testing.
|
||||
// This represents the type information available in the test JSON structure.
|
||||
func TestJSONTypeSet() (map[string][]FieldDataType, MetadataStore) {
|
||||
types := map[string][]FieldDataType{
|
||||
func TestJSONTypeSet() (map[string][]JSONDataType, MetadataStore) {
|
||||
types := map[string][]JSONDataType{
|
||||
|
||||
// ── user (primitives) ─────────────────────────────────────────────
|
||||
"user.name": {FieldDataTypeString},
|
||||
"user.permissions": {FieldDataTypeArrayString},
|
||||
"user.age": {FieldDataTypeInt64, FieldDataTypeString}, // Int64/String ambiguity
|
||||
"user.height": {FieldDataTypeFloat64},
|
||||
"user.active": {FieldDataTypeBool}, // Bool — not IndexSupported
|
||||
"user.name": {String},
|
||||
"user.permissions": {ArrayString},
|
||||
"user.age": {Int64, String}, // Int64/String ambiguity
|
||||
"user.height": {Float64},
|
||||
"user.active": {Bool}, // Bool — not IndexSupported
|
||||
|
||||
// Deeper non-array nesting (a.b.c — no array hops)
|
||||
"user.address.zip": {FieldDataTypeInt64},
|
||||
"user.address.zip": {Int64},
|
||||
|
||||
// ── education[] ───────────────────────────────────────────────────
|
||||
// Pattern: x[].y
|
||||
"education": {FieldDataTypeArrayJSON},
|
||||
"education[].name": {FieldDataTypeString},
|
||||
"education[].type": {FieldDataTypeString, FieldDataTypeInt64},
|
||||
"education[].year": {FieldDataTypeInt64},
|
||||
"education[].scores": {FieldDataTypeArrayInt64},
|
||||
"education[].parameters": {FieldDataTypeArrayFloat64, FieldDataTypeArrayDynamic},
|
||||
"education": {ArrayJSON},
|
||||
"education[].name": {String},
|
||||
"education[].type": {String, Int64},
|
||||
"education[].year": {Int64},
|
||||
"education[].scores": {ArrayInt64},
|
||||
"education[].parameters": {ArrayFloat64, ArrayDynamic},
|
||||
|
||||
// Pattern: x[].y[]
|
||||
"education[].awards": {FieldDataTypeArrayDynamic, FieldDataTypeArrayJSON},
|
||||
"education[].awards": {ArrayDynamic, ArrayJSON},
|
||||
|
||||
// Pattern: x[].y[].z
|
||||
"education[].awards[].name": {FieldDataTypeString},
|
||||
"education[].awards[].type": {FieldDataTypeString},
|
||||
"education[].awards[].semester": {FieldDataTypeInt64},
|
||||
"education[].awards[].name": {String},
|
||||
"education[].awards[].type": {String},
|
||||
"education[].awards[].semester": {Int64},
|
||||
|
||||
// Pattern: x[].y[].z[]
|
||||
"education[].awards[].participated": {FieldDataTypeArrayDynamic, FieldDataTypeArrayJSON},
|
||||
"education[].awards[].participated": {ArrayDynamic, ArrayJSON},
|
||||
|
||||
// Pattern: x[].y[].z[].w
|
||||
"education[].awards[].participated[].members": {FieldDataTypeArrayString},
|
||||
"education[].awards[].participated[].members": {ArrayString},
|
||||
|
||||
// Pattern: x[].y[].z[].w[]
|
||||
"education[].awards[].participated[].team": {FieldDataTypeArrayJSON},
|
||||
"education[].awards[].participated[].team": {ArrayJSON},
|
||||
|
||||
// Pattern: x[].y[].z[].w[].v
|
||||
"education[].awards[].participated[].team[].branch": {FieldDataTypeString},
|
||||
"education[].awards[].participated[].team[].branch": {String},
|
||||
|
||||
// ── interests[] ───────────────────────────────────────────────────
|
||||
"interests": {FieldDataTypeArrayJSON},
|
||||
"interests[].entities": {FieldDataTypeArrayJSON},
|
||||
"interests[].entities[].product_codes": {FieldDataTypeArrayDynamic},
|
||||
"interests[].entities[].reviews": {FieldDataTypeArrayJSON},
|
||||
"interests[].entities[].reviews[].entries": {FieldDataTypeArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata": {FieldDataTypeArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions": {FieldDataTypeArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].name": {FieldDataTypeString},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].ratings": {FieldDataTypeArrayInt64, FieldDataTypeArrayString},
|
||||
"http-events": {FieldDataTypeArrayJSON},
|
||||
"http-events[].request-info.host": {FieldDataTypeString},
|
||||
"interests": {ArrayJSON},
|
||||
"interests[].entities": {ArrayJSON},
|
||||
"interests[].entities[].reviews": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].name": {String},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].ratings": {ArrayInt64, ArrayString},
|
||||
"http-events": {ArrayJSON},
|
||||
"http-events[].request-info.host": {String},
|
||||
"ids": {ArrayDynamic},
|
||||
|
||||
// ── top-level primitives ──────────────────────────────────────────
|
||||
"message": {FieldDataTypeString},
|
||||
"http-status": {FieldDataTypeInt64, FieldDataTypeString}, // hyphen in root key, ambiguous
|
||||
"message": {String},
|
||||
"http-status": {Int64, String}, // hyphen in root key, ambiguous
|
||||
|
||||
// ── top-level nested objects (no array hops) ───────────────────────
|
||||
"response.time-taken": {FieldDataTypeFloat64}, // hyphen inside nested key
|
||||
"response.time-taken": {Float64}, // hyphen inside nested key
|
||||
}
|
||||
|
||||
return types, nil
|
||||
|
||||
Reference in New Issue
Block a user