Compare commits

..

15 Commits

Author SHA1 Message Date
Vinícius Lourenço
735e9b1ee6 fix(window.open): missing few places 2026-04-21 11:24:05 -03:00
SagarRajput-7
06e521ad97 Merge branch 'base-path-config-setup-1' into base-path-task-phase-2 2026-04-21 11:04:29 +05:30
SagarRajput-7
a9dcad863b Merge branch 'main' into base-path-config-setup-1 2026-04-21 11:04:19 +05:30
Piyush Singariya
50b452080f chore: Removal of JSONDataType field (#10925)
* fix: tons of changes

* chore: remove redundent comparison

* ci: tests fixed

* fix: upgraded collector version

* fix: qbtoexpr tests

* fix: go sum

* chore: upgrade collector version v0.144.3-rc.4

* fix: tests

* ci: test fix

* revert: remove db binaries

* test: selectField tests added

* fix: added safeguards in plan generation

* fix: name changed to field_map

* fix: json access plan remval of AvailableTypes

* fix: invalid index usage on terminal condition

* fix: branches should tell missing array types

* fix: comment removed

* fix: issue with FuzzyMatching and API failing

* fix: int64 mapping

* ci: test and lint fix

* fix: test VisitKey

* test: running test for sku

* fix: buildFieldForJSON works

* fix: few minor changes

* fix: refactor tag vs field_key table

* fix: minor changes based on review

* revert: minor variable change

* fix: added more membership testcases

* revert: minor var names reverted

* ci: tests aligned

* fix: indexed expressions
2026-04-21 05:11:50 +00:00
SagarRajput-7
72db77b068 feat(base-path): migrate remaining pattern for window.location.origin + path 2026-04-21 10:33:24 +05:30
SagarRajput-7
4f84f07494 feat(base-path): replace window.open with openInNewTab for internal paths 2026-04-21 10:28:57 +05:30
SagarRajput-7
a9e09ee349 feat: code refactor around feedbacks 2026-04-21 10:25:02 +05:30
SagarRajput-7
0a9bb6ba0b feat: applied suggested patch changes 2026-04-21 10:24:51 +05:30
SagarRajput-7
6664a0fae3 feat: code refactor around feedbacks 2026-04-21 10:24:38 +05:30
SagarRajput-7
040dcb9c9b feat: updated base path utils and fixed navigation and translations 2026-04-21 10:24:23 +05:30
SagarRajput-7
4a39453826 feat: updated the html template 2026-04-21 10:24:12 +05:30
SagarRajput-7
ac4db09ec6 feat: removed plugin and serving the index.html only as the template 2026-04-21 10:24:01 +05:30
SagarRajput-7
a691e6a775 feat: refactor the interceptor and added gotmpl into gitignore 2026-04-21 10:23:51 +05:30
SagarRajput-7
d270a3807b feat: changed output path to dir level 2026-04-21 10:23:42 +05:30
SagarRajput-7
c2b553d26c feat: base path config setup and plugin for gotmpl generation at build time 2026-04-21 10:23:33 +05:30
74 changed files with 1054 additions and 1815 deletions

View File

@@ -66,6 +66,8 @@ module.exports = {
rules: {
// Asset migration — base-path safety
'rulesdir/no-unsupported-asset-pattern': 'error',
// Base-path safety — window.open and origin-concat patterns; upgrade to error coming PR
'rulesdir/no-raw-absolute-path': 'warn',
// Code quality rules
'prefer-const': 'error', // Enforces const for variables never reassigned

View File

@@ -0,0 +1,153 @@
'use strict';
/**
* ESLint rule: no-raw-absolute-path
*
* Catches patterns that break at runtime when the app is served from a
* sub-path (e.g. /signoz/):
*
* 1. window.open(path, '_blank')
* → use openInNewTab(path) which calls withBasePath internally
*
* 2. window.location.origin + path / `${window.location.origin}${path}`
* → use getAbsoluteUrl(path)
*
* 3. frontendBaseUrl: window.location.origin (bare origin usage)
* → use getBaseUrl() to include the base path
*
* 4. window.location.href = path
* → use withBasePath(path) or navigate() for internal navigation
*
* External URLs (first arg starts with "http") are explicitly allowed.
*/
function isOriginAccess(node) {
return (
node.type === 'MemberExpression' &&
!node.computed &&
node.property.name === 'origin' &&
node.object.type === 'MemberExpression' &&
!node.object.computed &&
node.object.property.name === 'location' &&
node.object.object.type === 'Identifier' &&
node.object.object.name === 'window'
);
}
function isHrefAccess(node) {
return (
node.type === 'MemberExpression' &&
!node.computed &&
node.property.name === 'href' &&
node.object.type === 'MemberExpression' &&
!node.object.computed &&
node.object.property.name === 'location' &&
node.object.object.type === 'Identifier' &&
node.object.object.name === 'window'
);
}
function isExternalUrl(node) {
if (node.type === 'Literal' && typeof node.value === 'string') {
return node.value.startsWith('http://') || node.value.startsWith('https://');
}
if (node.type === 'TemplateLiteral' && node.quasis.length > 0) {
const raw = node.quasis[0].value.raw;
return raw.startsWith('http://') || raw.startsWith('https://');
}
return false;
}
// window.open(withBasePath(x)) and window.open(getAbsoluteUrl(x)) are already safe.
function isSafeHelperCall(node) {
return (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
(node.callee.name === 'withBasePath' || node.callee.name === 'getAbsoluteUrl')
);
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'Disallow raw window.open and origin-concatenation patterns that miss the runtime base path',
category: 'Base Path Safety',
},
schema: [],
messages: {
windowOpen:
'Use openInNewTab(path) instead of window.open(path, "_blank") — openInNewTab prepends the base path automatically.',
originConcat:
'Use getAbsoluteUrl(path) instead of window.location.origin + path — getAbsoluteUrl prepends the base path automatically.',
originDirect:
'Use getBaseUrl() instead of window.location.origin — getBaseUrl includes the base path.',
hrefAssign:
'Use withBasePath(path) or navigate() instead of window.location.href = path — ensures the base path is included.',
},
},
create(context) {
return {
// window.open(path, ...) — allow only external first-arg URLs
CallExpression(node) {
const { callee, arguments: args } = node;
if (
callee.type !== 'MemberExpression' ||
callee.object.type !== 'Identifier' ||
callee.object.name !== 'window' ||
callee.property.name !== 'open'
)
return;
if (args.length < 1) return;
if (isExternalUrl(args[0])) return;
if (isSafeHelperCall(args[0])) return;
context.report({ node, messageId: 'windowOpen' });
},
// window.location.origin + path
BinaryExpression(node) {
if (node.operator !== '+') return;
if (isOriginAccess(node.left) || isOriginAccess(node.right)) {
context.report({ node, messageId: 'originConcat' });
}
},
// `${window.location.origin}${path}`
TemplateLiteral(node) {
if (node.expressions.some(isOriginAccess)) {
context.report({ node, messageId: 'originConcat' });
}
},
// window.location.origin used directly (not in concatenation)
// Catches: frontendBaseUrl: window.location.origin
MemberExpression(node) {
if (!isOriginAccess(node)) return;
const parent = node.parent;
// Skip if parent is BinaryExpression with + (handled by BinaryExpression visitor)
if (parent.type === 'BinaryExpression' && parent.operator === '+') return;
// Skip if inside TemplateLiteral (handled by TemplateLiteral visitor)
if (parent.type === 'TemplateLiteral') return;
context.report({ node, messageId: 'originDirect' });
},
// window.location.href = path
AssignmentExpression(node) {
if (node.operator !== '=') return;
if (!isHrefAccess(node.left)) return;
// Allow external URLs
if (isExternalUrl(node.right)) return;
// Allow safe helper calls
if (isSafeHelperCall(node.right)) return;
context.report({ node, messageId: 'hrefAssign' });
},
};
},
};

View File

@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="[[.BaseHref]]" />
<meta
http-equiv="Cache-Control"
content="no-cache, no-store, must-revalidate, max-age: 0"
@@ -59,7 +60,7 @@
<meta data-react-helmet="true" name="docusaurus_locale" content="en" />
<meta data-react-helmet="true" name="docusaurus_tag" content="default" />
<meta name="robots" content="noindex" />
<link data-react-helmet="true" rel="shortcut icon" href="/favicon.ico" />
<link data-react-helmet="true" rel="shortcut icon" href="favicon.ico" />
</head>
<body data-theme="default">
<script>
@@ -136,7 +137,7 @@
})(document, 'script');
}
</script>
<link rel="stylesheet" href="/css/uPlot.min.css" />
<link rel="stylesheet" href="css/uPlot.min.css" />
<script type="module" src="./src/index.tsx"></script>
</body>
</html>

View File

@@ -1,52 +0,0 @@
{
"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"
}

View File

@@ -16,6 +16,5 @@
"roles": "Roles",
"role_details": "Role Details",
"members": "Members",
"service_accounts": "Service Accounts",
"mcp_server": "MCP Server"
"service_accounts": "Service Accounts"
}

View File

@@ -53,6 +53,5 @@
"METER": "SigNoz | Meter",
"ROLES_SETTINGS": "SigNoz | Roles",
"MEMBERS_SETTINGS": "SigNoz | Members",
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts",
"MCP_SERVER": "SigNoz | MCP Server"
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts"
}

View File

@@ -1,52 +0,0 @@
{
"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"
}

View File

@@ -16,6 +16,5 @@
"roles": "Roles",
"role_details": "Role Details",
"members": "Members",
"service_accounts": "Service Accounts",
"mcp_server": "MCP Server"
"service_accounts": "Service Accounts"
}

View File

@@ -76,6 +76,5 @@
"METER": "SigNoz | Meter",
"ROLES_SETTINGS": "SigNoz | Roles",
"MEMBERS_SETTINGS": "SigNoz | Members",
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts",
"MCP_SERVER": "SigNoz | MCP Server"
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts"
}

View File

@@ -2,6 +2,7 @@ import { initReactI18next } from 'react-i18next';
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
import { getBasePath } from 'utils/basePath';
import cacheBursting from '../../i18n-translations-hash.json';
@@ -24,7 +25,7 @@ i18n
const ns = namespace[0];
const pathkey = `/${language}/${ns}`;
const hash = cacheBursting[pathkey as keyof typeof cacheBursting] || '';
return `/locales/${language}/${namespace}.json?h=${hash}`;
return `${getBasePath()}locales/${language}/${namespace}.json?h=${hash}`;
},
},
react: {

View File

@@ -1,5 +1,6 @@
import {
interceptorRejected,
interceptorsRequestBasePath,
interceptorsRequestResponse,
interceptorsResponse,
} from 'api';
@@ -17,6 +18,7 @@ export const GeneratedAPIInstance = <T>(
return generatedAPIAxiosInstance({ ...config }).then(({ data }) => data);
};
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
generatedAPIAxiosInstance.interceptors.response.use(
interceptorsResponse,

View File

@@ -11,6 +11,7 @@ import axios, {
import { ENVIRONMENT } from 'constants/env';
import { Events } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { getBasePath } from 'utils/basePath';
import { eventEmitter } from 'utils/getEventEmitter';
import apiV1, { apiAlertManager, apiV2, apiV3, apiV4, apiV5 } from './apiV1';
@@ -67,6 +68,39 @@ export const interceptorsRequestResponse = (
return value;
};
// Strips the leading '/' from path and joins with base — idempotent if already prefixed.
// e.g. prependBase('/signoz/', '/api/v1/') → '/signoz/api/v1/'
function prependBase(base: string, path: string): string {
return path.startsWith(base) ? path : base + path.slice(1);
}
// Prepends the runtime base path to outgoing requests so API calls work under
// a URL prefix (e.g. /signoz/api/v1/…). No-op for root deployments and dev
// (dev baseURL is a full http:// URL, not an absolute path).
export const interceptorsRequestBasePath = (
value: InternalAxiosRequestConfig,
): InternalAxiosRequestConfig => {
const basePath = getBasePath();
if (basePath === '/') {
return value;
}
if (value.baseURL?.startsWith('/')) {
// Production relative baseURL: '/api/v1/' → '/signoz/api/v1/'
value.baseURL = prependBase(basePath, value.baseURL);
} else if (value.baseURL?.startsWith('http')) {
// Dev absolute baseURL (VITE_FRONTEND_API_ENDPOINT): 'https://host/api/v1/' → 'https://host/signoz/api/v1/'
const url = new URL(value.baseURL);
url.pathname = prependBase(basePath, url.pathname);
value.baseURL = url.toString();
} else if (!value.baseURL && value.url?.startsWith('/')) {
// Orval-generated client (empty baseURL, path in url): '/api/signoz/v1/rules' → '/signoz/api/signoz/v1/rules'
value.url = prependBase(basePath, value.url);
}
return value;
};
export const interceptorRejected = async (
value: AxiosResponse<any>,
): Promise<AxiosResponse<any>> => {
@@ -133,6 +167,7 @@ const instance = axios.create({
});
instance.interceptors.request.use(interceptorsRequestResponse);
instance.interceptors.request.use(interceptorsRequestBasePath);
instance.interceptors.response.use(interceptorsResponse, interceptorRejected);
export const AxiosAlertManagerInstance = axios.create({
@@ -147,6 +182,7 @@ ApiV2Instance.interceptors.response.use(
interceptorRejected,
);
ApiV2Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV2Instance.interceptors.request.use(interceptorsRequestBasePath);
// axios V3
export const ApiV3Instance = axios.create({
@@ -158,6 +194,7 @@ ApiV3Instance.interceptors.response.use(
interceptorRejected,
);
ApiV3Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV3Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios V4
@@ -170,6 +207,7 @@ ApiV4Instance.interceptors.response.use(
interceptorRejected,
);
ApiV4Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV4Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios V5
@@ -182,6 +220,7 @@ ApiV5Instance.interceptors.response.use(
interceptorRejected,
);
ApiV5Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV5Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios Base
@@ -194,6 +233,7 @@ LogEventAxiosInstance.interceptors.response.use(
interceptorRejectedBase,
);
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
//
AxiosAlertManagerInstance.interceptors.response.use(
@@ -201,6 +241,7 @@ AxiosAlertManagerInstance.interceptors.response.use(
interceptorRejected,
);
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestResponse);
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestBasePath);
export { apiV1 };
export default instance;

View File

@@ -12,6 +12,8 @@ import { AppState } from 'store/reducers';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { withBasePath } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
export interface NavigateToExplorerProps {
filters: TagFilterItem[];
@@ -133,7 +135,11 @@ export function useNavigateToExplorer(): (
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
window.open(newExplorerPath, sameTab ? '_self' : '_blank');
if (sameTab) {
window.location.href = withBasePath(newExplorerPath);
} else {
openInNewTab(newExplorerPath);
}
},
[
prepareQuery,

View File

@@ -13,6 +13,7 @@ import GetMinMax from 'lib/getMinMax';
import { Check, Info, Link2 } from 'lucide-react';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getAbsoluteUrl } from 'utils/basePath';
const routesToBeSharedWithTime = [
ROUTES.LOGS_EXPLORER,
@@ -80,17 +81,13 @@ function ShareURLModal(): JSX.Element {
urlQuery.delete(QueryParams.relativeTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
} else {
urlQuery.delete(QueryParams.startTime);
urlQuery.delete(QueryParams.endTime);
urlQuery.set(QueryParams.relativeTime, selectedTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
}
}

View File

@@ -87,7 +87,6 @@ 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;

View File

@@ -9,6 +9,7 @@ import {
} from 'container/ApiMonitoring/utils';
import { UnfoldVertical } from 'lucide-react';
import { SuccessResponse } from 'types/api';
import { openInNewTab } from 'utils/navigation';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
@@ -94,20 +95,14 @@ function DependentServices({
}}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
const url = new URL(
`/services/${
record.serviceData.serviceName &&
record.serviceData.serviceName !== '-'
? record.serviceData.serviceName
: ''
}`,
window.location.origin,
);
const serviceName =
record.serviceData.serviceName && record.serviceData.serviceName !== '-'
? record.serviceData.serviceName
: '';
const urlQuery = new URLSearchParams();
urlQuery.set(QueryParams.startTime, timeRange.startTime.toString());
urlQuery.set(QueryParams.endTime, timeRange.endTime.toString());
url.search = urlQuery.toString();
window.open(url.toString(), '_blank');
openInNewTab(`/services/${serviceName}?${urlQuery.toString()}`);
},
className: 'clickable-row',
})}

View File

@@ -14,6 +14,7 @@ import { IUser } from 'providers/App/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
import { ROUTING_POLICIES_ROUTE } from './constants';
import { RoutingPolicyBannerProps } from './types';
@@ -387,7 +388,7 @@ export function NotificationChannelsNotFoundContent({
style={{ padding: '0 4px' }}
type="link"
onClick={(): void => {
window.open(ROUTES.CHANNELS_NEW, '_blank');
openInNewTab(ROUTES.CHANNELS_NEW);
}}
>
here.

View File

@@ -15,6 +15,7 @@ import { AlertDef, Labels } from 'types/api/alerts/def';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
import { openInNewTab } from 'utils/navigation';
import { popupContainer } from 'utils/selectPopupContainer';
import ChannelSelect from './ChannelSelect';
@@ -87,7 +88,7 @@ function BasicInfo({
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
ruleId: isNewRule ? 0 : alertDef?.id,
});
window.open(ROUTES.CHANNELS_NEW, '_blank');
openInNewTab(ROUTES.CHANNELS_NEW);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const hasLoggedEvent = useRef(false);

View File

@@ -10,6 +10,7 @@ import Card from 'periscope/components/Card/Card';
import { useAppContext } from 'providers/App/App';
import { Dashboard } from 'types/api/dashboard/getAll';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
import dialsUrl from '@/assets/Icons/dials.svg';
@@ -114,7 +115,7 @@ export default function Dashboards({
dashboardName: dashboard.data.title,
});
if (event.metaKey || event.ctrlKey) {
window.open(getLink(), '_blank');
openInNewTab(getLink());
} else {
safeNavigate(getLink());
}

View File

@@ -51,6 +51,7 @@ import {
LogsAggregatorOperator,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { v4 as uuidv4 } from 'uuid';
import { filterDuplicateFilters } from '../commonUtils';
@@ -569,10 +570,7 @@ function K8sBaseDetails<T>({
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
window.open(
`${window.location.origin}${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`,
'_blank',
);
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
const compositeQuery = {
...initialQueryState,
@@ -591,10 +589,7 @@ function K8sBaseDetails<T>({
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
window.open(
`${window.location.origin}${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`,
'_blank',
);
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
}
};

View File

@@ -4,6 +4,7 @@ import {
CloudintegrationtypesServiceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { openInNewTab } from 'utils/navigation';
import './ServiceDashboards.styles.scss';
@@ -44,7 +45,7 @@ function ServiceDashboards({
return;
}
if (event.metaKey || event.ctrlKey) {
window.open(dashboardUrl, '_blank', 'noopener,noreferrer');
openInNewTab(dashboardUrl);
return;
}
safeNavigate(dashboardUrl);
@@ -54,7 +55,7 @@ function ServiceDashboards({
return;
}
if (event.button === 1) {
window.open(dashboardUrl, '_blank', 'noopener,noreferrer');
openInNewTab(dashboardUrl);
}
}}
onKeyDown={(event): void => {

View File

@@ -83,6 +83,8 @@ import {
} from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { isModifierKeyPressed } from 'utils/app';
import { getAbsoluteUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
import dashboardsUrl from '@/assets/Icons/dashboards.svg';
@@ -457,7 +459,7 @@ function DashboardsList(): JSX.Element {
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
window.open(getLink(), '_blank');
openInNewTab(getLink());
}}
>
Open in New Tab
@@ -469,7 +471,7 @@ function DashboardsList(): JSX.Element {
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
setCopy(`${window.location.origin}${getLink()}`);
setCopy(getAbsoluteUrl(getLink()));
}}
>
Copy Link

View File

@@ -1,6 +1,7 @@
import { LockFilled } from '@ant-design/icons';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { openInNewTab } from 'utils/navigation';
import { Data } from '../DashboardsList';
import { TableLinkText } from './styles';
@@ -12,7 +13,7 @@ function Name(name: Data['name'], data: Data): JSX.Element {
const onClickHandler = (event: React.MouseEvent<HTMLElement>): void => {
if (event.metaKey || event.ctrlKey) {
window.open(getLink(), '_blank');
openInNewTab(getLink());
} else {
history.push(getLink());
}

View File

@@ -17,6 +17,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { useContextLogData } from './useContextLogData';
@@ -116,7 +117,7 @@ function ContextLogRenderer({
);
const link = `${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`;
window.open(link, '_blank', 'noopener,noreferrer');
openInNewTab(link);
},
[query, urlQuery],
);

View File

@@ -34,6 +34,7 @@ import { SET_DETAILED_LOG_DATA } from 'types/actions/logs';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { openInNewTab } from 'utils/navigation';
import { ActionItemProps } from './ActionItem';
import FieldRenderer from './FieldRenderer';
@@ -191,7 +192,7 @@ function TableView({
if (event.ctrlKey || event.metaKey) {
// open the trace in new tab
window.open(route, '_blank');
openInNewTab(route);
} else {
history.push(route);
}

View File

@@ -34,6 +34,7 @@ import ROUTES from 'constants/routes';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDragColumns from 'hooks/useDragColumns';
import { getAbsoluteUrl } from 'utils/basePath';
import { infinityDefaultStyles } from '../InfinityTableView/config';
import { TanStackTableStyled } from '../InfinityTableView/styles';
@@ -239,7 +240,7 @@ const TanStackTableView = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
urlQuery.delete(QueryParams.activeLogId);
urlQuery.delete(QueryParams.relativeTime);
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
setCopy(link);
toast.success('Copied to clipboard', { position: 'top-right' });

View File

@@ -1,246 +0,0 @@
.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;
}
}
}

View File

@@ -1,536 +0,0 @@
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;

View File

@@ -1,111 +0,0 @@
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/`;

View File

@@ -1,51 +0,0 @@
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);
}

View File

@@ -1,5 +1,6 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { openInNewTab as openInNewTabUtil } from 'utils/navigation';
import { TopOperationList } from './TopOperationsTable';
import { NavigateToTraceProps } from './types';
@@ -37,7 +38,7 @@ export const navigateToTrace = ({
}=${JSONCompositeQuery}`;
if (openInNewTab) {
window.open(newTraceExplorerPath, '_blank');
openInNewTabUtil(newTraceExplorerPath);
} else {
safeNavigate(newTraceExplorerPath);
}

View File

@@ -9,6 +9,7 @@ import {
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { Bell, Grid } from 'lucide-react';
import { openInNewTab } from 'utils/navigation';
import { pluralize } from 'utils/pluralize';
import { DashboardsAndAlertsPopoverProps } from './types';
@@ -67,9 +68,8 @@ function DashboardsAndAlertsPopover({
<Typography.Link
key={alert.alertId}
onClick={(): void => {
window.open(
openInNewTab(
`${ROUTES.ALERT_OVERVIEW}?${QueryParams.ruleId}=${alert.alertId}`,
'_blank',
);
}}
className="dashboards-popover-content-item"
@@ -90,11 +90,10 @@ function DashboardsAndAlertsPopover({
<Typography.Link
key={dashboard.dashboardId}
onClick={(): void => {
window.open(
openInNewTab(
generatePath(ROUTES.DASHBOARD, {
dashboardId: dashboard.dashboardId,
}),
'_blank',
);
}}
className="dashboards-popover-content-item"

View File

@@ -192,8 +192,7 @@ const onboardingConfigWithLinks = [
'setup',
],
imgUrl: signozBrandLogoUrl,
link: '/settings/mcp-server',
internalRedirect: true,
link: '/docs/ai/signoz-mcp-server/',
},
{
dataSource: 'migrate-from-datadog',

View File

@@ -14,6 +14,7 @@ import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ContextLinksData } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { openInNewTab } from 'utils/navigation';
import { ContextMenuItem } from './contextConfig';
import { getDataLinks } from './dataLinksUtils';
@@ -115,7 +116,7 @@ const useBaseAggregateOptions = ({
key={id}
icon={<LinkOutlined />}
onClick={(): void => {
window.open(url, '_blank');
openInNewTab(url);
onClose?.();
}}
>

View File

@@ -14,6 +14,7 @@ import { ModalTitle } from 'container/PipelinePage/PipelineListsView/styles';
import { Check, Loader, X } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
import { INITIAL_ROUTING_POLICY_DETAILS_FORM_STATE } from './constants';
import {
@@ -76,7 +77,7 @@ function RoutingPolicyDetails({
style={{ padding: '0 4px' }}
type="link"
onClick={(): void => {
window.open(ROUTES.CHANNELS_NEW, '_blank');
openInNewTab(ROUTES.CHANNELS_NEW);
}}
>
here.

View File

@@ -32,7 +32,6 @@ import {
Settings,
Shield,
Slack,
Sparkles,
Unplug,
User,
UserPlus,
@@ -338,13 +337,6 @@ export const settingsNavSections: SettingsNavSection[] = [
isEnabled: false,
itemKey: 'integrations',
},
{
key: ROUTES.MCP_SERVER,
label: 'MCP Server',
icon: <Sparkles size={16} />,
isEnabled: false,
itemKey: 'mcp-server',
},
],
},

View File

@@ -28,6 +28,7 @@ import {
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { v4 as uuid } from 'uuid';
import noDataUrl from '@/assets/Icons/no-data.svg';
@@ -143,7 +144,7 @@ function SpanLogs({
const url = `${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`;
window.open(url, '_blank');
openInNewTab(url);
},
[
isLogSpanRelated,

View File

@@ -17,6 +17,7 @@ import { BarChart2, Compass, X } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Span } from 'types/api/trace/getTraceV2';
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { RelatedSignalsViews } from '../constants';
import SpanLogs from '../SpanLogs/SpanLogs';
@@ -157,13 +158,7 @@ function SpanRelatedSignals({
searchParams.set(QueryParams.startTime, startTimeMs.toString());
searchParams.set(QueryParams.endTime, endTimeMs.toString());
window.open(
`${window.location.origin}${
ROUTES.LOGS_EXPLORER
}?${searchParams.toString()}`,
'_blank',
'noopener,noreferrer',
);
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${searchParams.toString()}`);
}, [selectedSpan.traceId, traceStartTime, traceEndTime]);
const emptyStateConfig = useMemo(

View File

@@ -157,7 +157,6 @@ 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,

View File

@@ -31,6 +31,7 @@ import {
UPDATE_SPANS_AGGREGATE_PAGE_SIZE,
} from 'types/actions/trace';
import { TraceReducer } from 'types/reducer/trace';
import { openInNewTab } from 'utils/navigation';
import { v4 } from 'uuid';
dayjs.extend(duration);
@@ -214,7 +215,7 @@ function TraceTable(): JSX.Element {
event.preventDefault();
event.stopPropagation();
if (event.metaKey || event.ctrlKey) {
window.open(getLink(record), '_blank');
openInNewTab(getLink(record));
} else {
history.push(getLink(record));
}

View File

@@ -28,6 +28,7 @@ import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { openInNewTab } from 'utils/navigation';
import './TracesTableComponent.styles.scss';
@@ -86,7 +87,7 @@ function TracesTableComponent({
event.preventDefault();
event.stopPropagation();
if (event.metaKey || event.ctrlKey) {
window.open(getTraceLink(record), '_blank');
openInNewTab(getTraceLink(record));
} else {
history.push(getTraceLink(record));
}

View File

@@ -17,6 +17,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getAbsoluteUrl } from 'utils/basePath';
import { HIGHLIGHTED_DELAY } from './configs';
import { UseCopyLogLink } from './types';
@@ -60,7 +61,7 @@ export const useCopyLogLink = (logId?: string): UseCopyLogLink => {
urlQuery.set(QueryParams.startTime, minTime?.toString() || '');
urlQuery.set(QueryParams.endTime, maxTime?.toString() || '');
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
setCopy(link);

View File

@@ -22,6 +22,7 @@ import { AppState } from 'store/reducers';
import { Widgets } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getGraphType } from 'utils/getGraphType';
import { openInNewTab } from 'utils/navigation';
const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
const queryRangeMutation = useMutation(getSubstituteVars);
@@ -92,7 +93,7 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
const url = `${ROUTES.ALERTS_NEW}?${params.toString()}`;
window.open(url, '_blank', 'noreferrer');
openInNewTab(url);
},
onError: () => {
notifications.error({

View File

@@ -4,6 +4,7 @@ import { useCopyToClipboard } from 'react-use';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import { Span } from 'types/api/trace/getTraceV2';
import { getAbsoluteUrl } from 'utils/basePath';
export const useCopySpanLink = (
span?: Span,
@@ -28,7 +29,7 @@ export const useCopySpanLink = (
urlQuery.set('spanId', span?.spanId);
}
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
setCopy(link);
notifications.success({

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
import { cloneDeep, isEqual } from 'lodash-es';
import { withBasePath } from 'utils/basePath';
interface NavigateOptions {
replace?: boolean;
@@ -130,7 +131,7 @@ export const useSafeNavigate = (
typeof to === 'string'
? to
: `${to.pathname || location.pathname}${to.search || ''}`;
window.open(targetPath, '_blank');
window.open(withBasePath(targetPath), '_blank');
return;
}

View File

@@ -1,3 +1,4 @@
import { createBrowserHistory } from 'history';
import { getBasePath } from 'utils/basePath';
export default createBrowserHistory();
export default createBrowserHistory({ basename: getBasePath() });

View File

@@ -4,6 +4,7 @@ import ROUTES from 'constants/routes';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { Home, LifeBuoy } from 'lucide-react';
import { withBasePath } from 'utils/basePath';
import cloudUrl from '@/assets/Images/cloud.svg';
@@ -11,8 +12,8 @@ import './ErrorBoundaryFallback.styles.scss';
function ErrorBoundaryFallback(): JSX.Element {
const handleReload = (): void => {
// Go to home page
window.location.href = ROUTES.HOME;
// Hard reload resets Sentry.ErrorBoundary state; withBasePath preserves any /signoz/ prefix.
window.location.href = withBasePath(ROUTES.HOME);
};
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();

View File

@@ -19,6 +19,7 @@ import {
} from 'pages/MessagingQueues/MessagingQueuesUtils';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { openInNewTab } from 'utils/navigation';
import {
convertToMilliseconds,
@@ -93,7 +94,7 @@ export function getColumns(
key={item}
className="traceid-text"
onClick={(): void => {
window.open(`${ROUTES.TRACE}/${item}`, '_blank');
openInNewTab(`${ROUTES.TRACE}/${item}`);
logEvent(`MQ Kafka: Drop Rate - traceid navigation`, {
item,
});
@@ -123,7 +124,7 @@ export function getColumns(
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
window.open(`/services/${encodeURIComponent(text)}`, '_blank');
openInNewTab(`/services/${encodeURIComponent(text)}`);
}}
>
{text}

View File

@@ -75,11 +75,6 @@ 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,

View File

@@ -8,7 +8,6 @@ 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';
@@ -25,7 +24,6 @@ import {
Pencil,
Plus,
Shield,
Sparkles,
User,
Users,
} from 'lucide-react';
@@ -207,19 +205,6 @@ 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 => (

View File

@@ -10,7 +10,6 @@ import {
generalSettings,
ingestionSettings,
keyboardShortcuts,
mcpServerSettings,
membersSettings,
multiIngestionSettings,
mySettings,
@@ -80,10 +79,6 @@ 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;

View File

@@ -0,0 +1,118 @@
/**
* basePath is memoized at module init, so each describe block isolates the
* module with a fresh DOM state using jest.isolateModules + require.
*/
type BasePath = typeof import('../basePath');
function loadModule(href?: string): BasePath {
if (href !== undefined) {
const base = document.createElement('base');
base.setAttribute('href', href);
document.head.appendChild(base);
}
let mod!: BasePath;
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
mod = require('../basePath');
});
return mod;
}
afterEach(() => {
document.head.querySelectorAll('base').forEach((el) => el.remove());
});
describe('at basePath="/"', () => {
let m: BasePath;
beforeEach(() => {
m = loadModule('/');
});
it('getBasePath returns "/"', () => {
expect(m.getBasePath()).toBe('/');
});
it('withBasePath is a no-op for any internal path', () => {
expect(m.withBasePath('/logs')).toBe('/logs');
expect(m.withBasePath('/logs/explorer')).toBe('/logs/explorer');
});
it('withBasePath passes through external URLs', () => {
expect(m.withBasePath('https://example.com/foo')).toBe(
'https://example.com/foo',
);
});
it('getAbsoluteUrl returns origin + path', () => {
expect(m.getAbsoluteUrl('/logs')).toBe(`${window.location.origin}/logs`);
});
it('getBaseUrl returns bare origin', () => {
expect(m.getBaseUrl()).toBe(window.location.origin);
});
});
describe('at basePath="/signoz/"', () => {
let m: BasePath;
beforeEach(() => {
m = loadModule('/signoz/');
});
it('getBasePath returns "/signoz/"', () => {
expect(m.getBasePath()).toBe('/signoz/');
});
it('withBasePath prepends the prefix', () => {
expect(m.withBasePath('/logs')).toBe('/signoz/logs');
expect(m.withBasePath('/logs/explorer')).toBe('/signoz/logs/explorer');
});
it('withBasePath is idempotent — safe to call twice', () => {
expect(m.withBasePath('/signoz/logs')).toBe('/signoz/logs');
});
it('withBasePath is idempotent when path equals the prefix without trailing slash', () => {
expect(m.withBasePath('/signoz')).toBe('/signoz');
});
it('withBasePath passes through external URLs', () => {
expect(m.withBasePath('https://example.com/foo')).toBe(
'https://example.com/foo',
);
});
it('getAbsoluteUrl returns origin + prefixed path', () => {
expect(m.getAbsoluteUrl('/logs')).toBe(
`${window.location.origin}/signoz/logs`,
);
});
it('getBaseUrl returns origin + prefix without trailing slash', () => {
expect(m.getBaseUrl()).toBe(`${window.location.origin}/signoz`);
});
});
describe('no <base> tag', () => {
it('getBasePath falls back to "/"', () => {
const m = loadModule();
expect(m.getBasePath()).toBe('/');
});
});
describe('href without trailing slash', () => {
it('normalises to trailing slash', () => {
const m = loadModule('/signoz');
expect(m.getBasePath()).toBe('/signoz/');
expect(m.withBasePath('/logs')).toBe('/signoz/logs');
});
});
describe('nested prefix "/a/b/prefix/"', () => {
it('withBasePath handles arbitrary depth', () => {
const m = loadModule('/a/b/prefix/');
expect(m.withBasePath('/logs')).toBe('/a/b/prefix/logs');
expect(m.withBasePath('/a/b/prefix/logs')).toBe('/a/b/prefix/logs');
});
});

View File

@@ -1,15 +1,27 @@
import { isModifierKeyPressed } from '../app';
import { openInNewTab } from '../navigation';
type NavigationModule = typeof import('../navigation');
function loadNavigationModule(href?: string): NavigationModule {
if (href !== undefined) {
const base = document.createElement('base');
base.setAttribute('href', href);
document.head.appendChild(base);
}
let mod!: NavigationModule;
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
mod = require('../navigation');
});
return mod;
}
describe('navigation utilities', () => {
const originalWindowOpen = window.open;
beforeEach(() => {
window.open = jest.fn();
});
afterEach(() => {
window.open = originalWindowOpen;
document.head.querySelectorAll('base').forEach((el) => el.remove());
});
describe('isModifierKeyPressed', () => {
@@ -56,25 +68,59 @@ describe('navigation utilities', () => {
});
describe('openInNewTab', () => {
it('calls window.open with the given path and _blank target', () => {
openInNewTab('/dashboard');
expect(window.open).toHaveBeenCalledWith('/dashboard', '_blank');
describe('at basePath="/"', () => {
let m: NavigationModule;
beforeEach(() => {
window.open = jest.fn();
m = loadNavigationModule('/');
});
it('passes internal path through unchanged', () => {
m.openInNewTab('/dashboard');
expect(window.open).toHaveBeenCalledWith('/dashboard', '_blank');
});
it('passes through external URLs unchanged', () => {
m.openInNewTab('https://example.com/page');
expect(window.open).toHaveBeenCalledWith(
'https://example.com/page',
'_blank',
);
});
it('handles paths with query strings', () => {
m.openInNewTab('/alerts?tab=AlertRules&relativeTime=30m');
expect(window.open).toHaveBeenCalledWith(
'/alerts?tab=AlertRules&relativeTime=30m',
'_blank',
);
});
});
it('handles full URLs', () => {
openInNewTab('https://example.com/page');
expect(window.open).toHaveBeenCalledWith(
'https://example.com/page',
'_blank',
);
});
describe('at basePath="/signoz/"', () => {
let m: NavigationModule;
beforeEach(() => {
window.open = jest.fn();
m = loadNavigationModule('/signoz/');
});
it('handles paths with query strings', () => {
openInNewTab('/alerts?tab=AlertRules&relativeTime=30m');
expect(window.open).toHaveBeenCalledWith(
'/alerts?tab=AlertRules&relativeTime=30m',
'_blank',
);
it('prepends base path to internal paths', () => {
m.openInNewTab('/dashboard');
expect(window.open).toHaveBeenCalledWith('/signoz/dashboard', '_blank');
});
it('passes through external URLs unchanged', () => {
m.openInNewTab('https://example.com/page');
expect(window.open).toHaveBeenCalledWith(
'https://example.com/page',
'_blank',
);
});
it('is idempotent — does not double-prefix an already-prefixed path', () => {
m.openInNewTab('/signoz/dashboard');
expect(window.open).toHaveBeenCalledWith('/signoz/dashboard', '_blank');
});
});
});
});

View File

@@ -0,0 +1,50 @@
// Read once at module init — avoids a DOM query on every axios request.
const _basePath: string = ((): string => {
const href = document.querySelector('base')?.getAttribute('href') ?? '/';
return href.endsWith('/') ? href : `${href}/`;
})();
/** Returns the runtime base path — always trailing-slashed. e.g. "/" or "/signoz/" */
export function getBasePath(): string {
return _basePath;
}
/**
* Prepends the base path to an internal absolute path.
* Idempotent and safe to call on any value.
*
* withBasePath('/logs') → '/signoz/logs'
* withBasePath('/signoz/logs') → '/signoz/logs' (already prefixed)
* withBasePath('https://x.com') → 'https://x.com' (external, passthrough)
*/
export function withBasePath(path: string): string {
if (!path.startsWith('/')) {
return path;
}
if (_basePath === '/') {
return path;
}
if (path.startsWith(_basePath) || path === _basePath.slice(0, -1)) {
return path;
}
return _basePath + path.slice(1);
}
/**
* Full absolute URL — for copy-to-clipboard and window.open calls.
* getAbsoluteUrl(ROUTES.LOGS_EXPLORER) → 'https://host/signoz/logs/logs-explorer'
*/
export function getAbsoluteUrl(path: string): string {
return window.location.origin + withBasePath(path);
}
/**
* Origin + base path without trailing slash — for sending to the backend
* as frontendBaseUrl in invite / password-reset email flows.
* getBaseUrl() → 'https://host/signoz'
*/
export function getBaseUrl(): string {
return (
window.location.origin + (_basePath === '/' ? '' : _basePath.slice(0, -1))
);
}

View File

@@ -1,6 +1,5 @@
/**
* Opens the given path in a new browser tab.
*/
import { withBasePath } from 'utils/basePath';
export const openInNewTab = (path: string): void => {
window.open(path, '_blank');
window.open(withBasePath(path), '_blank');
};

View File

@@ -132,5 +132,4 @@ 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'],
};

View File

@@ -10,6 +10,18 @@ import { createHtmlPlugin } from 'vite-plugin-html';
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer';
import tsconfigPaths from 'vite-tsconfig-paths';
// In dev the Go backend is not involved, so replace the [[.BaseHref]] placeholder
// with "/" so relative assets resolve correctly from the Vite dev server.
function devBasePathPlugin(): Plugin {
return {
name: 'dev-base-path',
apply: 'serve',
transformIndexHtml(html): string {
return html.replaceAll('[[.BaseHref]]', '/');
},
};
}
function rawMarkdownPlugin(): Plugin {
return {
name: 'raw-markdown',
@@ -32,6 +44,7 @@ export default defineConfig(
const plugins = [
tsconfigPaths(),
rawMarkdownPlugin(),
devBasePathPlugin(),
react(),
createHtmlPlugin({
inject: {
@@ -124,6 +137,7 @@ export default defineConfig(
'process.env.TUNNEL_DOMAIN': JSON.stringify(env.VITE_TUNNEL_DOMAIN),
'process.env.DOCS_BASE_URL': JSON.stringify(env.VITE_DOCS_BASE_URL),
},
base: './',
build: {
sourcemap: true,
outDir: 'build',

View File

@@ -207,23 +207,16 @@ 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
@@ -248,15 +241,6 @@ 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

View File

@@ -318,7 +318,7 @@ func TestVisitKey(t *testing.T) {
{
Name: "count",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
FieldDataType: telemetrytypes.FieldDataTypeInt64,
},
},
},
@@ -326,7 +326,7 @@ func TestVisitKey(t *testing.T) {
{
Name: "count",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
FieldDataType: telemetrytypes.FieldDataTypeInt64,
},
},
expectedErrors: nil,

View File

@@ -44,7 +44,6 @@ func (c *conditionBuilder) conditionFor(
}
return cond, nil
}
}
if operator.IsStringSearchOperator() {

View File

@@ -276,14 +276,10 @@ func (m *fieldMapper) FieldFor(ctx context.Context, tsStart, tsEnd uint64, key *
continue
}
if key.JSONDataType == nil {
if key.FieldDataType == telemetrytypes.FieldDataTypeUnspecified {
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
@@ -354,7 +350,6 @@ 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
@@ -393,6 +388,8 @@ 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

View File

@@ -172,30 +172,15 @@ 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)
}
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
if !node.IsTerminal {
return "", errors.NewInternalf(errors.CodeInvalidInput, "can not build index condition for non-terminal node %s", fieldPath)
}
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)
cond, err := c.applyOperator(sb, indexedExpr, operator, formattedValue)
if err != nil {
return "", err
}
return cond, nil
return c.applyOperator(sb, indexedExpr, operator, formattedValue)
}
// buildPrimitiveTerminalCondition builds the condition if the terminal node is a primitive type
@@ -204,32 +189,31 @@ func (c *jsonConditionBuilder) buildPrimitiveTerminalCondition(node *telemetryty
fieldPath := node.FieldPath()
conditions := []string{}
// utilize indexes for the condition if available
// 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.
//
// Note: Indexing code doesn't get executed for Array Nested fields because they can not be indexed
// Note: indexing is also skipped for Array Nested fields because they cannot be indexed.
indexed := slices.ContainsFunc(node.TerminalConfig.Key.Indexes, func(index telemetrytypes.JSONDataTypeIndex) bool {
return index.Type == node.TerminalConfig.ElemType
})
if node.TerminalConfig.ElemType.IndexSupported && indexed {
isExistsCheck := operator == qbtypes.FilterOperatorExists || operator == qbtypes.FilterOperatorNotExists
if node.TerminalConfig.ElemType.IndexSupported && indexed && !isExistsCheck {
indexCond, err := c.terminalIndexedCondition(node, operator, value, sb)
if err != nil {
return "", err
}
// if qb has a definitive value, we can skip adding a condition to
// check the existence of the path in the json column
// With a concrete non-zero value the index condition is self-contained.
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)
// 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
}
operator = qbtypes.FilterOperatorExists
}
var formattedValue = value
@@ -239,20 +223,15 @@ func (c *jsonConditionBuilder) buildPrimitiveTerminalCondition(node *telemetryty
fieldExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, node.TerminalConfig.ElemType.StringValue())
// 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
// 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.
//
// 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)
}
}
// 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)
}
fieldExpr, formattedValue = querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, formattedValue, fieldExpr, operator)

View File

@@ -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,jsondatatype=Int64 name=user.age,context=body,datatype=string,jsondatatype=String]."},
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]."},
},
},
{
@@ -413,8 +413,8 @@ func TestStatementBuilderListQueryBodyPromoted(t *testing.T) {
Limit: 10,
},
expected: TestExpected{
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)]."},
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]."},
},
expectedErr: nil,
},
@@ -440,8 +440,8 @@ func TestStatementBuilderListQueryBodyPromoted(t *testing.T) {
Limit: 10,
},
expected: TestExpected{
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)]."},
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]."},
},
expectedErr: nil,
},
@@ -454,8 +454,8 @@ func TestStatementBuilderListQueryBodyPromoted(t *testing.T) {
Limit: 10,
},
expected: TestExpected{
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)]."},
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]."},
},
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,jsondatatype=String name=education[].type,context=body,datatype=int64,jsondatatype=Int64]."},
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]."},
},
},
{
@@ -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,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
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]."},
},
},
{
@@ -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,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
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]."},
},
},
{
@@ -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,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
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]."},
},
},
{
@@ -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,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
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]."},
},
},
{
@@ -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,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
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]."},
},
},
{
@@ -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,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)].",
"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].",
},
},
},
@@ -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,jsondatatype=Array(Nullable(Int64)) name=interests[].entities[].reviews[].entries[].metadata[].positions[].ratings,context=body,datatype=[]string,jsondatatype=Array(Nullable(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 name=interests[].entities[].reviews[].entries[].metadata[].positions[].ratings,context=body,datatype=[]string]."},
},
},
{
@@ -761,27 +761,35 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
},
},
{
name: "dynamic array element comparison",
filter: "ids Contains 1",
name: "dynamic_array_element_compare_CONTAINS",
filter: "interests[].entities[].product_codes Contains 1",
expected: TestExpected{
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'))",
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'))",
Args: []any{"%1%", float64(1), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
{
name: "dynamic array element comparison",
filter: "ids != '1'",
name: "dynamic_array_element_compare_HAS_STRING",
filter: "has(interests[].entities[].product_codes, '2002')",
expected: TestExpected{
WhereClause: "(NOT arrayExists(x -> accurateCastOrNull(x, 'Float64') = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(body_v2.`ids`, 'Array(Dynamic)'))))",
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))')))",
Args: []any{int64(1), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
{
name: "dynamic array element comparison boolean",
filter: "ids = true",
name: "dynamic_array_element_compare_HAS_INT",
filter: "has(interests[].entities[].product_codes, 1001)",
expected: TestExpected{
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},
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},
},
},
}
@@ -828,7 +836,7 @@ func TestJSONStmtBuilder_IndexedPaths(t *testing.T) {
Limit: 10,
},
expected: TestExpected{
WhereClause: "((assumeNotNull(dynamicElement(body_v2.`user.name`, 'String')) = ?) AND has(JSONAllPaths(body_v2), 'user.name'))",
WhereClause: "((lower(assumeNotNull(dynamicElement(body_v2.user.name, 'String'))) = ?) AND has(JSONAllPaths(body_v2), 'user.name'))",
Args: []any{"alice", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
@@ -840,11 +848,14 @@ 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: emits assumeNotNull != nil AND dynamicElement IS NOT NULL ─
// ── 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).
{
name: "Indexed String Exists",
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
@@ -853,8 +864,8 @@ func TestJSONStmtBuilder_IndexedPaths(t *testing.T) {
Limit: 10,
},
expected: TestExpected{
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},
WhereClause: "(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 ─────────
@@ -866,26 +877,10 @@ func TestJSONStmtBuilder_IndexedPaths(t *testing.T) {
Limit: 10,
},
expected: TestExpected{
WhereClause: "(assumeNotNull(dynamicElement(body_v2.`user.name`, 'String')) <> ?)",
WhereClause: "(lower(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 ───────────────────────────────────
{
@@ -896,7 +891,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(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(lower(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},
},
},
@@ -908,7 +903,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},
},
},
@@ -942,20 +937,192 @@ 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, jsonTypes := range types {
for _, jsonType := range jsonTypes {
for path, fieldDataTypes := range types {
for _, fdt := range fieldDataTypes {
key := &telemetrytypes.TelemetryFieldKey{
Name: path,
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextBody,
FieldDataType: telemetrytypes.MappingJSONDataTypeToFieldDataType[jsonType],
JSONDataType: &jsonType,
FieldDataType: fdt,
}
if addIndexes {
jsonType := telemetrytypes.MappingFieldDataTypeToJSONDataType[fdt]
idx := slices.IndexFunc(telemetrytypes.TestIndexedPaths, func(entry telemetrytypes.TestIndexedPathEntry) bool {
return entry.Path == path && entry.Type == jsonType
})

View File

@@ -875,7 +875,6 @@ 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")
})
}

View File

@@ -21,133 +21,84 @@ 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")
)
// 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
// 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.
//
// 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")
// 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
}
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")
mapOfExactSelectors[selector.Name] = selector
}
return fieldKeys, paths, rowCount <= limit, 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
}
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))
var filteredKeys []*telemetrytypes.TelemetryFieldKey
for _, key := range keys {
parentSelectors = append(parentSelectors, key.ArrayParentSelectors()...)
if key.FieldContext == telemetrytypes.FieldContextBody && mapOfExactSelectors[key.Name] != nil {
filteredKeys = append(filteredKeys, key)
}
}
parentKeys, _, _, err := t.fetchBodyJSONPaths(ctx, parentSelectors)
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
}
typeCache := make(map[string][]telemetrytypes.JSONDataType)
for _, key := range parentKeys {
typeCache[key.Name] = append(typeCache[key.Name], *key.JSONDataType)
// fetch JSON path indexes
indexes, err := t.getJSONPathIndexes(ctx, paths...)
if err != nil {
return err
}
// build plans for keys now
// 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)
}
// 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
}
columnMeta := t.jsonColumnMetadata[telemetrytypes.SignalLogs][telemetrytypes.FieldContextBody]
for _, key := range keys {
err = key.SetJSONAccessPlan(t.jsonColumnMetadata[telemetrytypes.SignalLogs][telemetrytypes.FieldContextBody], typeCache)
if err != nil {
if err := key.SetJSONAccessPlan(columnMeta, typeCache); err != nil {
return err
}
}
@@ -155,51 +106,6 @@ func (t *telemetryMetaStore) buildJSONPlans(ctx context.Context, keys []*telemet
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 {

View File

@@ -7,99 +7,9 @@ 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

View File

@@ -397,90 +397,156 @@ 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 both tables
// unspecified context, query all 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
}
}
tablesToQuery := []struct {
fieldContext telemetrytypes.FieldContext
shouldQuery bool
}{
{telemetrytypes.FieldContextAttribute, queryAttributeTable},
{telemetrytypes.FieldContextResource, queryResourceTable},
// 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
}
}
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
// 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
}
}
}
// 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", // in logs, we had some historical data with capital and small case
fmt.Sprintf(`%d AS priority`, getPriorityForContext(fieldContext)),
"lower(datatype) AS tag_data_type", // logs had historical mixed-case data
fmt.Sprintf("%d AS priority", getPriorityForContext(fieldContext)),
).From(tblName)
var limit int
conds := []string{}
for _, fieldKeySelector := range fieldKeySelectors {
for _, sel := range fieldKeySelectors {
// Include this selector if:
// 1. It has unspecified context (matches all tables)
// 2. Its context matches the current table's context
if fieldKeySelector.FieldContext != telemetrytypes.FieldContextUnspecified &&
fieldKeySelector.FieldContext != fieldContext {
if sel.FieldContext != telemetrytypes.FieldContextUnspecified && sel.FieldContext != fieldContext {
continue
}
// key part of the selector
fieldKeyConds := []string{}
if fieldKeySelector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeExact {
fieldKeyConds = append(fieldKeyConds, sb.E("name", fieldKeySelector.Name))
if sel.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeExact {
fieldKeyConds = append(fieldKeyConds, sb.E("name", sel.Name))
} else {
fieldKeyConds = append(fieldKeyConds, sb.ILike("name", "%"+escapeForLike(fieldKeySelector.Name)+"%"))
fieldKeyConds = append(fieldKeyConds, sb.ILike("name", "%"+escapeForLike(sel.Name)+"%"))
}
// now look at the field data type
if fieldKeySelector.FieldDataType != telemetrytypes.FieldDataTypeUnspecified {
fieldKeyConds = append(fieldKeyConds, sb.E("datatype", fieldKeySelector.FieldDataType.TagDataType()))
if sel.FieldDataType != telemetrytypes.FieldDataTypeUnspecified {
fieldKeyConds = append(fieldKeyConds, sb.E("datatype", sel.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")
if limit == 0 {
limit = 1000
}
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 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...)
@@ -517,6 +583,7 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, fieldKeySelectors
defer rows.Close()
keys := []*telemetrytypes.TelemetryFieldKey{}
parentTypes := make(map[string][]telemetrytypes.FieldDataType)
rowCount := 0
searchTexts := []string{}
@@ -540,6 +607,21 @@ 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
@@ -593,13 +675,11 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, fieldKeySelectors
}
}
// enrich body keys with promoted paths, indexes, and JSON access plans
if querybuilder.BodyJSONQueryEnabled {
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))
if err := t.enrichJSONKeys(ctx, fieldKeySelectors, keys, parentTypes); err != nil {
return nil, false, err
}
keys = append(keys, bodyJSONPaths...)
complete = complete && finished
}
if _, err := t.updateColumnEvolutionMetadataForKeys(ctx, keys); err != nil {

View File

@@ -1,13 +1,13 @@
package telemetrymetadata
import otelcollectorconst "github.com/SigNoz/signoz-otel-collector/constants"
import otelconst "github.com/SigNoz/signoz-otel-collector/constants"
const (
DBName = "signoz_metadata"
AttributesMetadataTableName = "distributed_attributes_metadata"
AttributesMetadataLocalTableName = "attributes_metadata"
ColumnEvolutionMetadataTableName = "distributed_column_evolution_metadata"
PathTypesTableName = otelcollectorconst.DistributedFieldKeysTable
FieldKeysTable = otelconst.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"

View File

@@ -37,7 +37,6 @@ 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
@@ -80,6 +79,11 @@ 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)
@@ -92,9 +96,6 @@ 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 {
@@ -117,7 +118,6 @@ 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

View File

@@ -31,7 +31,7 @@ var (
FieldDataTypeArrayInt64 = FieldDataType{valuer.NewString("[]int64")}
FieldDataTypeArrayNumber = FieldDataType{valuer.NewString("[]number")}
FieldDataTypeArrayObject = FieldDataType{valuer.NewString("[]object")}
FieldDataTypeArrayJSON = FieldDataType{valuer.NewString("[]json")}
FieldDataTypeArrayDynamic = FieldDataType{valuer.NewString("[]dynamic")}
// Map string representations to FieldDataType values
@@ -51,7 +51,7 @@ var (
"int8": FieldDataTypeNumber,
"int16": FieldDataTypeNumber,
"int32": FieldDataTypeNumber,
"int64": FieldDataTypeNumber,
"int64": FieldDataTypeInt64,
"uint": FieldDataTypeNumber,
"uint8": FieldDataTypeNumber,
"uint16": FieldDataTypeNumber,
@@ -72,6 +72,8 @@ var (
"[]float64": FieldDataTypeArrayFloat64,
"[]number": FieldDataTypeArrayNumber,
"[]bool": FieldDataTypeArrayBool,
"[]json": FieldDataTypeArrayJSON,
"[]dynamic": FieldDataTypeArrayDynamic,
// c-style array types
"string[]": FieldDataTypeArrayString,
@@ -79,6 +81,8 @@ var (
"float64[]": FieldDataTypeArrayFloat64,
"number[]": FieldDataTypeArrayNumber,
"bool[]": FieldDataTypeArrayBool,
"json[]": FieldDataTypeArrayJSON,
"dynamic[]": FieldDataTypeArrayDynamic,
}
fieldDataTypeToCHDataType = map[FieldDataType]string{

View File

@@ -19,7 +19,8 @@ var (
BranchJSON = JSONAccessBranchType{valuer.NewString("json")}
BranchDynamic = JSONAccessBranchType{valuer.NewString("dynamic")}
CodePlanIndexOutOfBounds = errors.MustNewCode("plan_index_out_of_bounds")
CodePlanIndexOutOfBounds = errors.MustNewCode("plan_index_out_of_bounds")
CodePlanFieldDataTypeMissing = errors.MustNewCode("field_data_type_missing")
)
type JSONColumnMetadata struct {
@@ -42,9 +43,6 @@ 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
@@ -106,7 +104,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][]JSONDataType
typeCache map[string][]FieldDataType
}
// buildPlan recursively builds the path plan tree.
@@ -138,34 +136,41 @@ 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.JSONDataType,
ElemType: pb.key.GetJSONDataType(),
}
} 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 {
@@ -185,7 +190,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][]JSONDataType,
func (key *TelemetryFieldKey) SetJSONAccessPlan(columnInfo JSONColumnMetadata, typeCache map[string][]FieldDataType,
) error {
// if path is empty, return nil
if key.Name == "" {

View File

@@ -19,11 +19,11 @@ const (
// ============================================================================
// makeKey creates a TelemetryFieldKey for testing.
func makeKey(name string, dataType JSONDataType, materialized bool) *TelemetryFieldKey {
func makeKey(name string, dataType FieldDataType, materialized bool) *TelemetryFieldKey {
return &TelemetryFieldKey{
Name: name,
JSONDataType: &dataType,
Materialized: materialized,
Name: name,
FieldDataType: dataType,
Materialized: materialized,
}
}
@@ -41,7 +41,6 @@ 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"`
}
@@ -65,14 +64,6 @@ 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()
@@ -242,12 +233,10 @@ func TestPlanJSON_BasicStructure(t *testing.T) {
}{
{
name: "Simple path not promoted",
key: makeKey("user.name", String, false),
key: makeKey("user.name", FieldDataTypeString, false),
expectedYAML: fmt.Sprintf(`
- name: user.name
column: %s
availableTypes:
- String
maxDynamicTypes: 16
isTerminal: true
elemType: String
@@ -255,19 +244,15 @@ func TestPlanJSON_BasicStructure(t *testing.T) {
},
{
name: "Simple path promoted",
key: makeKey("user.name", String, true),
key: makeKey("user.name", FieldDataTypeString, 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
@@ -276,7 +261,7 @@ func TestPlanJSON_BasicStructure(t *testing.T) {
},
{
name: "Empty path returns error",
key: makeKey("", String, false),
key: makeKey("", FieldDataTypeString, false),
expectErr: true,
expectedYAML: "",
},
@@ -314,14 +299,10 @@ 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
@@ -333,28 +314,19 @@ 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
@@ -367,43 +339,29 @@ 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),
@@ -414,14 +372,10 @@ 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
@@ -431,7 +385,7 @@ func TestPlanJSON_ArrayPaths(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key := makeKey(tt.path, String, false)
key := makeKey(tt.path, FieldDataTypeString, false)
err := key.SetJSONAccessPlan(JSONColumnMetadata{
BaseColumn: bodyV2Column,
PromotedColumn: bodyPromotedColumn,
@@ -450,7 +404,7 @@ func TestPlanJSON_PromotedVsNonPromoted(t *testing.T) {
path := "education[].awards[].type"
t.Run("Non-promoted plan", func(t *testing.T) {
key := makeKey(path, String, false)
key := makeKey(path, FieldDataTypeString, false)
err := key.SetJSONAccessPlan(JSONColumnMetadata{
BaseColumn: bodyV2Column,
PromotedColumn: bodyPromotedColumn,
@@ -461,28 +415,19 @@ 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
@@ -493,7 +438,7 @@ func TestPlanJSON_PromotedVsNonPromoted(t *testing.T) {
})
t.Run("Promoted plan", func(t *testing.T) {
key := makeKey(path, String, true)
key := makeKey(path, FieldDataTypeString, true)
err := key.SetJSONAccessPlan(JSONColumnMetadata{
BaseColumn: bodyV2Column,
PromotedColumn: bodyPromotedColumn,
@@ -504,59 +449,41 @@ 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
@@ -578,7 +505,7 @@ func TestPlanJSON_EdgeCases(t *testing.T) {
}{
{
name: "Path with no available types",
path: "unknown.path",
path: "unknown[].path",
expectErr: true,
},
{
@@ -587,43 +514,29 @@ 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),
@@ -634,15 +547,10 @@ 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
@@ -654,8 +562,6 @@ func TestPlanJSON_EdgeCases(t *testing.T) {
expectedYAML: fmt.Sprintf(`
- name: education
column: %s
availableTypes:
- Array(JSON)
maxDynamicTypes: 16
isTerminal: true
elemType: Array(JSON)
@@ -666,12 +572,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 := String
keyType := FieldDataTypeString
switch tt.path {
case "education":
keyType = ArrayJSON
keyType = FieldDataTypeArrayJSON
case "education[].type":
keyType = String
keyType = FieldDataTypeString
}
key := makeKey(tt.path, keyType, false)
err := key.SetJSONAccessPlan(JSONColumnMetadata{
@@ -692,7 +598,7 @@ func TestPlanJSON_EdgeCases(t *testing.T) {
func TestPlanJSON_TreeStructure(t *testing.T) {
types, _ := TestJSONTypeSet()
path := "education[].awards[].participated[].team[].branch"
key := makeKey(path, String, false)
key := makeKey(path, FieldDataTypeString, false)
err := key.SetJSONAccessPlan(JSONColumnMetadata{
BaseColumn: bodyV2Column,
PromotedColumn: bodyPromotedColumn,
@@ -703,86 +609,59 @@ 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

View File

@@ -46,14 +46,6 @@ 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,
@@ -63,18 +55,8 @@ var MappingFieldDataTypeToJSONDataType = map[FieldDataType]JSONDataType{
FieldDataTypeArrayString: ArrayString,
FieldDataTypeArrayInt64: ArrayInt64,
FieldDataTypeArrayFloat64: ArrayFloat64,
FieldDataTypeArrayNumber: ArrayFloat64,
FieldDataTypeArrayBool: ArrayBool,
}
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,
FieldDataTypeArrayDynamic: ArrayDynamic,
FieldDataTypeArrayJSON: ArrayJSON,
}

View File

@@ -4,69 +4,69 @@ package telemetrytypes
// Test JSON Type Set Data Setup
// ============================================================================
// TestJSONTypeSet returns a map of path->types for testing.
// TestJSONTypeSet returns a map of path->field data types for testing.
// This represents the type information available in the test JSON structure.
func TestJSONTypeSet() (map[string][]JSONDataType, MetadataStore) {
types := map[string][]JSONDataType{
func TestJSONTypeSet() (map[string][]FieldDataType, MetadataStore) {
types := map[string][]FieldDataType{
// ── user (primitives) ─────────────────────────────────────────────
"user.name": {String},
"user.permissions": {ArrayString},
"user.age": {Int64, String}, // Int64/String ambiguity
"user.height": {Float64},
"user.active": {Bool}, // Bool — not IndexSupported
"user.name": {FieldDataTypeString},
"user.permissions": {FieldDataTypeArrayString},
"user.age": {FieldDataTypeInt64, FieldDataTypeString}, // Int64/String ambiguity
"user.height": {FieldDataTypeFloat64},
"user.active": {FieldDataTypeBool}, // Bool — not IndexSupported
// Deeper non-array nesting (a.b.c — no array hops)
"user.address.zip": {Int64},
"user.address.zip": {FieldDataTypeInt64},
// ── education[] ───────────────────────────────────────────────────
// Pattern: x[].y
"education": {ArrayJSON},
"education[].name": {String},
"education[].type": {String, Int64},
"education[].year": {Int64},
"education[].scores": {ArrayInt64},
"education[].parameters": {ArrayFloat64, ArrayDynamic},
"education": {FieldDataTypeArrayJSON},
"education[].name": {FieldDataTypeString},
"education[].type": {FieldDataTypeString, FieldDataTypeInt64},
"education[].year": {FieldDataTypeInt64},
"education[].scores": {FieldDataTypeArrayInt64},
"education[].parameters": {FieldDataTypeArrayFloat64, FieldDataTypeArrayDynamic},
// Pattern: x[].y[]
"education[].awards": {ArrayDynamic, ArrayJSON},
"education[].awards": {FieldDataTypeArrayDynamic, FieldDataTypeArrayJSON},
// Pattern: x[].y[].z
"education[].awards[].name": {String},
"education[].awards[].type": {String},
"education[].awards[].semester": {Int64},
"education[].awards[].name": {FieldDataTypeString},
"education[].awards[].type": {FieldDataTypeString},
"education[].awards[].semester": {FieldDataTypeInt64},
// Pattern: x[].y[].z[]
"education[].awards[].participated": {ArrayDynamic, ArrayJSON},
"education[].awards[].participated": {FieldDataTypeArrayDynamic, FieldDataTypeArrayJSON},
// Pattern: x[].y[].z[].w
"education[].awards[].participated[].members": {ArrayString},
"education[].awards[].participated[].members": {FieldDataTypeArrayString},
// Pattern: x[].y[].z[].w[]
"education[].awards[].participated[].team": {ArrayJSON},
"education[].awards[].participated[].team": {FieldDataTypeArrayJSON},
// Pattern: x[].y[].z[].w[].v
"education[].awards[].participated[].team[].branch": {String},
"education[].awards[].participated[].team[].branch": {FieldDataTypeString},
// ── interests[] ───────────────────────────────────────────────────
"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},
"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},
// ── top-level primitives ──────────────────────────────────────────
"message": {String},
"http-status": {Int64, String}, // hyphen in root key, ambiguous
"message": {FieldDataTypeString},
"http-status": {FieldDataTypeInt64, FieldDataTypeString}, // hyphen in root key, ambiguous
// ── top-level nested objects (no array hops) ───────────────────────
"response.time-taken": {Float64}, // hyphen inside nested key
"response.time-taken": {FieldDataTypeFloat64}, // hyphen inside nested key
}
return types, nil