Compare commits

..

1 Commits

Author SHA1 Message Date
Nikhil Mantri
70335dc707 feat(alert-channel-integrations): jira + jsm ops channel frontend (#12488)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

Adds **Jira** and **JSM Ops** as alert channel types in the existing
channel flow. No new pages or endpoints — both reuse the same create /
edit / list / test actions as every other channel.

**Jira**

- Required fields: Jira Cloud site URL, Atlassian email + API token,
project, and issue type. **Summary** and **Description** are prefilled,
editable templates (the rich issue body is built server-side).
- The form recommends using an Atlassian **service account**, with a
link to the docs.
- Advanced Options: priority, labels (chip input), resolve/reopen
transition-name overrides, and the reopen window.
- Client-side validation mirrors the backend (must be an
`https://….atlassian.net` URL; reopen window ≥ 1m) purely for a
friendlier error — the backend enforces the same rules.

**JSM Ops**

- The JSM **integration API key** is the only required field — there is
no site or region to configure.
- **Message**, **Description**, and **Priority** are prefilled, editable
templates; **Tags** is a chip input (defaults to `signoz`).

**Shared behaviour**

- `Send resolved alerts` is on by default; on resolve, Jira transitions
+ comments the issue and JSM Ops closes the alert.
- Optional fields left empty are omitted from the payload, so the
backend applies its own defaults.
- Jest tests cover both forms: rendering, prefilled defaults, validation
errors, and the exact save payloads (`jira_configs` / `jsmops_configs`).
- Generated API client types are regenerated to include
`jsmops_configs`; locale strings added for all new fields.

#### Issues closed by this PR

Stacked on top of #12478 · Discussion: SigNoz/pulse-pod#169 · Closes
SigNoz/pulse-pod#170

#### Screenshots / Screen Recordings

Jira Form : 

<img width="1512" height="823" alt="Screenshot 2026-08-18 at 1 16 49 PM"
src="https://github.com/user-attachments/assets/1a3a3419-de1b-4bf1-8a71-50d8341e5d47"
/>

Expanded Jira advanced options : 

<img width="1444" height="406" alt="Screenshot 2026-08-18 at 1 17 08 PM"
src="https://github.com/user-attachments/assets/d1298009-b0f3-4f3d-b960-b805b12f5026"
/>

JSM Ops Form: 

<img width="1479" height="631" alt="Screenshot 2026-08-18 at 1 19 11 PM"
src="https://github.com/user-attachments/assets/678b130b-8b1f-41c8-a78a-b6bd58ae2904"
/>

JSM Ops Advanced Options: 

<img width="1455" height="239" alt="Screenshot 2026-08-18 at 1 19 22 PM"
src="https://github.com/user-attachments/assets/a4425b39-b7e1-4f10-bd0a-83dbc678693a"
/>

#### Additional Information

Notes for reviewers:

- **This branch is stacked on #12478**, so the diff shows the backend
commits too — only the `frontend/` files are new here.
- Follows the pattern of the Google Chat channel frontend.
- **JSM Ops seeds `send_resolved: true` in its prefilled config on
purpose** — the backend cannot default it to on, so the UI carries the
default and sends it explicitly.
- **Tags is a chip input in the UI, but the backend takes a
comma-separated string** — joined on save, split back into chips on
edit-prefill.
- Jira's reopen window is sent as a duration string (`"72h"`) even
though the generated DTO types it as a number, so it's cast at that one
boundary. `custom_fields` stays API-only and is not surfaced in the
form.

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-09-02 03:52:24 +00:00
28 changed files with 1328 additions and 1241 deletions

View File

@@ -256,7 +256,7 @@ Tests can be configured using pytest options:
- `--sqlite-mode` — SQLite journal mode: `delete` or `wal` (default: `delete`). Only relevant when `--sqlstore-provider=sqlite`.
- `--postgres-version` — PostgreSQL version (default: `15`)
- `--clickhouse-version` — ClickHouse version, also used for ClickHouse Keeper (default: `25.12.5`)
- `--schema-migrator-version` — SigNoz schema migrator version (default: `v0.144.9`)
- `--schema-migrator-version` — SigNoz schema migrator version (default: `v0.144.6`)
- `--with-web` — Build the frontend into the SigNoz image (required for e2e)
Example:

View File

@@ -26,6 +26,55 @@
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
"field_jira_site": "Site URL",
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net. Only Jira Cloud is supported.",
"jira_site_invalid": "Site URL must be an https URL on an atlassian.net domain",
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
"jira_service_account_tip_link": "Learn how",
"field_jira_email": "Email",
"help_jira_email": "The Atlassian account email used for authentication.",
"field_jira_api_token": "API token",
"help_jira_api_token": "Create one at id.atlassian.com under Security → API tokens.",
"field_jira_project": "Project key",
"field_jira_issue_type": "Issue type",
"help_jira_issue_type": "An issue type that exists in the project, e.g. Task, Bug or Incident.",
"field_jira_summary": "Summary (issue title)",
"help_jira_summary": "Template for the Jira issue title.",
"field_jira_description": "Description",
"help_jira_description": "Template for the issue description. Rendered as rich text with a status panel and links back to SigNoz.",
"jira_advanced_section": "Advanced Options",
"field_jira_priority": "Priority",
"placeholder_jira_priority": "Leave empty to use the project default",
"help_jira_priority": "Must match a priority in the project's scheme, e.g. High.",
"field_jira_labels": "Labels",
"placeholder_jira_labels": "Type a label and press Enter",
"help_jira_labels": "signoz and a deduplication label are added automatically.",
"field_jira_resolve_transition": "Resolve transition",
"field_jira_reopen_transition": "Reopen transition",
"help_jira_resolve_transition": "When the alert resolves, SigNoz moves the Jira issue to a \"Done\" status via a workflow transition. This is auto-detected — leave it empty unless your project has more than one \"Done\" transition (e.g. Done vs. Won't Do) and you want to force a specific one by name.",
"help_jira_reopen_transition": "When a resolved alert fires again (within the reopen window), SigNoz moves the issue back out of \"Done\" to an active status via a workflow transition. This is auto-detected — leave it empty unless you want to force a specific one by name (e.g. To Do or Reopen).",
"placeholder_jira_resolve_transition": "Auto-detected, e.g. Done",
"placeholder_jira_reopen_transition": "Auto-detected, e.g. To Do",
"field_jira_reopen_duration": "Reopen window",
"placeholder_jira_reopen_duration": "e.g. 72h",
"help_jira_reopen_duration": "If a resolved alert fires again within this window, the same ticket is reopened; after the window, a re-fire opens a new ticket instead. Default: 3d.",
"tooltip_jira_reopen_duration": "Accepted units: m (minutes), h (hours), d (days), w (weeks), y (years) — e.g. 30m, 72h or 3d. Minimum 1m.",
"jira_reopen_duration_invalid": "Reopen window must be a duration like 30m, 72h or 3d (minimum 1m)",
"jsmops_tip": "Create an API integration on your JSM team's Operations page and paste its key below.",
"jsmops_tip_link": "Learn how",
"field_jsmops_api_key": "API key",
"help_jsmops_api_key": "The JSM Ops integration API key, from your team's Operations → Integrations → API. Make sure the integration is turned on.",
"field_jsmops_message": "Message (alert title)",
"help_jsmops_message": "Template for the alert title. Truncated to 130 characters.",
"field_jsmops_description": "Description",
"help_jsmops_description": "Template for the alert description. Rendered as rich text; kept under 15,000 characters.",
"jsmops_advanced_section": "Advanced Options",
"field_jsmops_priority": "Priority",
"help_jsmops_priority": "Template resolving to one of P1P5. Leave as-is to map from alert severity.",
"field_jsmops_tags": "Tags",
"placeholder_jsmops_tags": "Type a tag and press Enter",
"help_jsmops_tags": "Tags added to every alert.",
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",

View File

@@ -26,6 +26,55 @@
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
"field_jira_site": "Site URL",
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net. Only Jira Cloud is supported.",
"jira_site_invalid": "Site URL must be an https URL on an atlassian.net domain",
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
"jira_service_account_tip_link": "Learn how",
"field_jira_email": "Email",
"help_jira_email": "The Atlassian account email used for authentication.",
"field_jira_api_token": "API token",
"help_jira_api_token": "Create one at id.atlassian.com under Security → API tokens.",
"field_jira_project": "Project key",
"field_jira_issue_type": "Issue type",
"help_jira_issue_type": "An issue type that exists in the project, e.g. Task, Bug or Incident.",
"field_jira_summary": "Summary (issue title)",
"help_jira_summary": "Template for the Jira issue title.",
"field_jira_description": "Description",
"help_jira_description": "Template for the issue description. Rendered as rich text with a status panel and links back to SigNoz.",
"jira_advanced_section": "Advanced Options",
"field_jira_priority": "Priority",
"placeholder_jira_priority": "Leave empty to use the project default",
"help_jira_priority": "Must match a priority in the project's scheme, e.g. High.",
"field_jira_labels": "Labels",
"placeholder_jira_labels": "Type a label and press Enter",
"help_jira_labels": "signoz and a deduplication label are added automatically.",
"field_jira_resolve_transition": "Resolve transition",
"field_jira_reopen_transition": "Reopen transition",
"help_jira_resolve_transition": "When the alert resolves, SigNoz moves the Jira issue to a \"Done\" status via a workflow transition. This is auto-detected — leave it empty unless your project has more than one \"Done\" transition (e.g. Done vs. Won't Do) and you want to force a specific one by name.",
"help_jira_reopen_transition": "When a resolved alert fires again (within the reopen window), SigNoz moves the issue back out of \"Done\" to an active status via a workflow transition. This is auto-detected — leave it empty unless you want to force a specific one by name (e.g. To Do or Reopen).",
"placeholder_jira_resolve_transition": "Auto-detected, e.g. Done",
"placeholder_jira_reopen_transition": "Auto-detected, e.g. To Do",
"field_jira_reopen_duration": "Reopen window",
"placeholder_jira_reopen_duration": "e.g. 72h",
"help_jira_reopen_duration": "If a resolved alert fires again within this window, the same ticket is reopened; after the window, a re-fire opens a new ticket instead. Default: 3d.",
"tooltip_jira_reopen_duration": "Accepted units: m (minutes), h (hours), d (days), w (weeks), y (years) — e.g. 30m, 72h or 3d. Minimum 1m.",
"jira_reopen_duration_invalid": "Reopen window must be a duration like 30m, 72h or 3d (minimum 1m)",
"jsmops_tip": "Create an API integration on your JSM team's Operations page and paste its key below.",
"jsmops_tip_link": "Learn how",
"field_jsmops_api_key": "API key",
"help_jsmops_api_key": "The JSM Ops integration API key, from your team's Operations → Integrations → API. Make sure the integration is turned on.",
"field_jsmops_message": "Message (alert title)",
"help_jsmops_message": "Template for the alert title. Truncated to 130 characters.",
"field_jsmops_description": "Description",
"help_jsmops_description": "Template for the alert description. Rendered as rich text; kept under 15,000 characters.",
"jsmops_advanced_section": "Advanced Options",
"field_jsmops_priority": "Priority",
"help_jsmops_priority": "Template resolving to one of P1P5. Leave as-is to map from alert severity.",
"field_jsmops_tags": "Tags",
"placeholder_jsmops_tags": "Type a tag and press Enter",
"help_jsmops_tags": "Tags added to every alert.",
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",
"field_slack_description": "Description",

View File

@@ -1,6 +1,10 @@
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import { GoogleChatInitialConfig } from 'container/CreateAlertChannels/defaults';
import {
GoogleChatInitialConfig,
JiraInitialConfig,
JsmOpsInitialConfig,
} from 'container/CreateAlertChannels/defaults';
import {
googleChatDescriptionDefaultValue,
googleChatTitleDefaultValue,
@@ -526,6 +530,213 @@ describe('Create Alert Channel', () => {
});
});
});
describe('Jira', () => {
const validSite = 'https://acme.atlassian.net';
const fillRequired = async (
user: ReturnType<typeof userEvent.setup>,
site: string,
): Promise<void> => {
await user.type(screen.getByTestId('channel-name-textbox'), 'jira-channel');
await user.type(screen.getByTestId('jira-site-textbox'), site);
await user.type(screen.getByTestId('jira-email-textbox'), 'me@acme.com');
await user.type(screen.getByTestId('jira-api-token-textbox'), 'tok123');
await user.type(screen.getByTestId('jira-project-textbox'), 'KAN');
};
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Jira} />);
});
it('Should check if the selected item in the type dropdown has text "Jira"', () => {
expect(screen.getByText('Jira')).toBeInTheDocument();
});
it('Should check if the Site URL field is displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_jira_site',
testId: 'jira-site-textbox',
});
});
it('Should prefill the issue type with Task', () => {
expect(screen.getByTestId('jira-issue-type-textbox')).toHaveValue('Task');
});
it('Should show the service-account recommendation tip linking to the docs', () => {
expect(screen.getByTestId('jira-service-account-tip')).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'jira_service_account_tip_link' }),
).toHaveAttribute(
'href',
'https://signoz.io/docs/alerts-management/notification-channel/jira/#use-a-service-account-recommended',
);
});
it('Should display an error when the site is not an atlassian.net URL', async () => {
const user = userEvent.setup({ delay: null });
await fillRequired(user, 'https://example.com');
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'jira_site_invalid',
}),
);
});
it('Should send a jira_configs payload with basic auth', async () => {
let requestBody: unknown;
server.use(
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
requestBody = await req.json();
return res(
ctx.status(201),
ctx.json({ status: 'success', data: 'channel created' }),
);
}),
);
const user = userEvent.setup({ delay: null });
await fillRequired(user, validSite);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_creation_done',
}),
);
expect(requestBody).toStrictEqual({
name: 'jira-channel',
jira_configs: [
{
site: validSite,
project: 'KAN',
issue_type: 'Task',
summary: JiraInitialConfig.summary,
description: JiraInitialConfig.description,
send_resolved: true,
http_config: {
basic_auth: { username: 'me@acme.com', password: 'tok123' },
},
},
],
});
}, 15000);
it('Should block save when the reopen window is below the 1m minimum', async () => {
const user = userEvent.setup({ delay: null });
await fillRequired(user, validSite);
await user.click(screen.getByText('jira_advanced_section'));
await user.type(screen.getByTestId('jira-reopen-duration-textbox'), '30s');
// the rule surfaces an inline message, not just a red border
await expect(
screen.findByText('jira_reopen_duration_invalid'),
).resolves.toBeInTheDocument();
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'jira_reopen_duration_invalid',
}),
);
}, 15000);
});
describe('JSM Ops', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.JsmOps} />);
});
it('Should show "Jira Service Management Ops" as the selected type', () => {
expect(screen.getByText('Jira Service Management Ops')).toBeInTheDocument();
});
it('Should display the API key field properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_jsmops_api_key',
testId: 'jsmops-api-key-textbox',
});
});
it('Should show the tip linking to the JSM Ops docs', () => {
expect(screen.getByTestId('jsmops-tip')).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'jsmops_tip_link' }),
).toHaveAttribute(
'href',
'https://signoz.io/docs/alerts-management/notification-channel/jsm-ops/',
);
});
it('Should block save when the API key is missing', async () => {
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'jsmops-channel',
);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'api_key_required',
}),
);
});
it('Should send a jsmops_configs payload with prefilled defaults', async () => {
let requestBody: unknown;
server.use(
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
requestBody = await req.json();
return res(
ctx.status(201),
ctx.json({ status: 'success', data: 'channel created' }),
);
}),
);
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'jsmops-channel',
);
await user.type(screen.getByTestId('jsmops-api-key-textbox'), 'key-abc');
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_creation_done',
}),
);
expect(requestBody).toStrictEqual({
name: 'jsmops-channel',
jsmops_configs: [
{
api_key: 'key-abc',
send_resolved: true,
message: JsmOpsInitialConfig.message,
description: JsmOpsInitialConfig.description,
priority: JsmOpsInitialConfig.priority,
tags: JsmOpsInitialConfig.tags?.join(','),
},
],
});
});
});
describe('Changing the channel type', () => {
async function selectType(
user: ReturnType<typeof userEvent.setup>,

View File

@@ -58,6 +58,38 @@ describe('EditAlertChannels save', () => {
expect(edit.calls[0].id).toBe('3');
});
it('blocks jira save when the reopen window is below the 1m minimum', async () => {
const edit = mockEditChannel();
const jiraInitialValue = {
type: 'jira',
name: 'jira-channel',
site: 'https://acme.atlassian.net',
username: 'user@acme.io',
password: 'token',
project: 'OPS',
issue_type: 'Task',
send_resolved: true,
reopen_duration: '30s',
};
const { unmount } = render(
<EditAlertChannels channelId="3" initialValue={jiraInitialValue} />,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('save-channel-button'));
expect(edit.calls).toHaveLength(0);
unmount();
render(
<EditAlertChannels
channelId="3"
initialValue={{ ...jiraInitialValue, reopen_duration: '72h' }}
/>,
);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
});
it('persists send_resolved toggle in the edit request', async () => {
const edit = mockEditChannel();
render(

View File

@@ -105,6 +105,8 @@ export enum ChannelType {
Opsgenie = 'opsgenie',
MsTeams = 'msteams',
GoogleChat = 'googlechat',
Jira = 'jira',
JsmOps = 'jsmops',
}
// LabelFilterStatement will be used for preparing filter conditions / matchers
@@ -134,3 +136,39 @@ export interface GoogleChatChannel extends Channel {
title?: string;
text?: string;
}
// JiraChannel configures the Jira Cloud alert channel. Auth is basic auth
// (Atlassian account email + API token) carried in username / password.
export interface JiraChannel extends Channel {
// Jira Cloud base URL, e.g. https://acme.atlassian.net
site: string;
project: string;
issue_type: string;
// issue title template
summary?: string;
// issue body template, rendered to rich text server-side
description?: string;
// basic auth: username is the Atlassian account email, password is the API token
username: string;
password: string;
priority?: string;
labels?: string[];
resolve_transition?: string;
reopen_transition?: string;
// duration string, e.g. 72h or 3d
reopen_duration?: string;
}
// JsmOpsChannel configures the Jira Service Management Ops alert channel
// (ex-Opsgenie alert API). Auth is the JSM integration API key.
export interface JsmOpsChannel extends Channel {
api_key: string;
// alert title template
message?: string;
// alert body template (markdown, rendered to HTML server-side)
description?: string;
// priority template, resolves to P1-P5
priority?: string;
// tags, joined to a comma-separated string for the backend
tags?: string[];
}

View File

@@ -2,6 +2,8 @@ import {
ChannelType,
EmailChannel,
GoogleChatChannel,
JiraChannel,
JsmOpsChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
@@ -47,6 +49,23 @@ export const GoogleChatInitialConfig: Partial<GoogleChatChannel> = {
{{ end }}`,
};
// mirrors DefaultJiraSummaryTemplate / DefaultJiraDescriptionTemplate in
// pkg/types/alertmanagertypes/jira.go, which the backend applies when the
// summary / description are left empty. The description is markdown here and is
// wrapped in the ADF status panel + deep-links server-side.
export const JiraInitialConfig: Partial<JiraChannel> = {
issue_type: 'Task',
summary: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
{{ if .Annotations.summary }}
**Summary:** {{ .Annotations.summary }}
{{ end }}{{ if .Annotations.description }}
**Description:** {{ .Annotations.description }}
{{ end }}
{{ end }}`,
};
export const PagerInitialConfig: Partial<PagerChannel> = {
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
@@ -98,6 +117,33 @@ export const OpsgenieInitialConfig: Partial<OpsgenieChannel> = {
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
};
// mirrors DefaultJSMOpsMessageTemplate / DefaultJSMOpsDescriptionTemplate in
// pkg/types/alertmanagertypes/jsmops.go, applied by the backend when message /
// description are left empty. send_resolved is seeded on so JSM alerts close on
// resolve (the backend cannot default it, see jsmops.go). priority mirrors the
// Opsgenie template mapping severity to P1-P5.
export const JsmOpsInitialConfig: Partial<JsmOpsChannel> = {
send_resolved: true,
message: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
{{ if .Annotations.summary }}**Summary:** {{ .Annotations.summary }}
{{ end }}{{ if .Annotations.description }}**Description:** {{ .Annotations.description }}
{{ end }}{{ if .GeneratorURL }}[View in SigNoz]({{ .GeneratorURL }})
{{ end }}{{ if .Annotations.related_logs }}[View related logs]({{ .Annotations.related_logs }})
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
{{ end }}{{ end }}`,
priority:
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
tags: ['signoz-alert'],
};
export const EmailInitialConfig: Partial<EmailChannel> = {
send_resolved: true,
html: `<!--
@@ -505,12 +551,16 @@ export const ChannelInitialConfig: Record<
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel
GoogleChatChannel &
JiraChannel &
JsmOpsChannel
>
> = {
[ChannelType.Slack]: SlackInitialConfig,
[ChannelType.MsTeams]: SlackInitialConfig,
[ChannelType.GoogleChat]: GoogleChatInitialConfig,
[ChannelType.Jira]: JiraInitialConfig,
[ChannelType.JsmOps]: JsmOpsInitialConfig,
[ChannelType.Pagerduty]: PagerInitialConfig,
[ChannelType.Opsgenie]: OpsgenieInitialConfig,
[ChannelType.Email]: EmailInitialConfig,

View File

@@ -32,6 +32,8 @@ import {
ChannelType,
EmailChannel,
GoogleChatChannel,
JiraChannel,
JsmOpsChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
@@ -43,7 +45,11 @@ import { ChannelInitialConfig } from './defaults';
import {
isChannelType,
isValidGoogleChatWebhookURL,
isValidJiraReopenDuration,
isValidJiraSiteURL,
prepareGoogleChatRequest,
prepareJiraRequest,
prepareJsmOpsRequest,
} from './utils';
import './CreateAlertChannels.styles.scss';
@@ -69,7 +75,9 @@ function CreateAlertChannels({
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel
GoogleChatChannel &
JiraChannel &
JsmOpsChannel
>
>(() => ({
send_resolved: true,
@@ -434,6 +442,114 @@ function CreateAlertChannels({
showErrorModal,
]);
const validateJiraConfig = useCallback((): boolean => {
if (
!selectedConfig.site ||
!selectedConfig.username ||
!selectedConfig.password ||
!selectedConfig.project ||
!selectedConfig.issue_type
) {
notifications.error({
message: 'Error',
description: t('jira_required_fields'),
});
return false;
}
if (!isValidJiraSiteURL(selectedConfig.site)) {
notifications.error({
message: 'Error',
description: t('jira_site_invalid'),
});
return false;
}
if (
selectedConfig.reopen_duration &&
!isValidJiraReopenDuration(selectedConfig.reopen_duration)
) {
notifications.error({
message: 'Error',
description: t('jira_reopen_duration_invalid'),
});
return false;
}
return true;
}, [selectedConfig, notifications, t]);
const onJiraHandler = useCallback(async () => {
if (!validateJiraConfig()) {
return { status: 'failed', statusMessage: t('channel_creation_failed') };
}
setSavingState(true);
try {
await createChannel({ data: prepareJiraRequest(selectedConfig) });
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
validateJiraConfig,
createChannel,
selectedConfig,
notifications,
t,
showErrorModal,
]);
const validateJsmOpsConfig = useCallback((): boolean => {
if (!selectedConfig.api_key) {
notifications.error({
message: 'Error',
description: t('api_key_required'),
});
return false;
}
return true;
}, [selectedConfig.api_key, notifications, t]);
const onJsmOpsHandler = useCallback(async () => {
if (!validateJsmOpsConfig()) {
return { status: 'failed', statusMessage: t('channel_creation_failed') };
}
setSavingState(true);
try {
await createChannel({ data: prepareJsmOpsRequest(selectedConfig) });
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
validateJsmOpsConfig,
createChannel,
selectedConfig,
notifications,
t,
showErrorModal,
]);
const onSaveHandler = useCallback(
async (value: ChannelType) => {
if (!selectedConfig.name) {
@@ -452,6 +568,8 @@ function CreateAlertChannels({
[ChannelType.MsTeams]: onMsTeamsHandler,
[ChannelType.Email]: onEmailHandler,
[ChannelType.GoogleChat]: onGoogleChatHandler,
[ChannelType.Jira]: onJiraHandler,
[ChannelType.JsmOps]: onJsmOpsHandler,
};
if (isChannelType(value)) {
@@ -484,6 +602,8 @@ function CreateAlertChannels({
onMsTeamsHandler,
onEmailHandler,
onGoogleChatHandler,
onJiraHandler,
onJsmOpsHandler,
notifications,
t,
],
@@ -528,6 +648,20 @@ function CreateAlertChannels({
}
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
break;
case ChannelType.Jira:
if (!validateJiraConfig()) {
setTestingState(false);
return;
}
await testChannel({ data: prepareJiraRequest(selectedConfig) });
break;
case ChannelType.JsmOps:
if (!validateJsmOpsConfig()) {
setTestingState(false);
return;
}
await testChannel({ data: prepareJsmOpsRequest(selectedConfig) });
break;
default:
notifications.error({
message: 'Error',
@@ -576,6 +710,8 @@ function CreateAlertChannels({
prepareMsTeamsRequest,
prepareEmailRequest,
validateGoogleChatConfig,
validateJiraConfig,
validateJsmOpsConfig,
testChannel,
notifications,
],

View File

@@ -1,9 +1,17 @@
import {
AlertmanagertypesJiraReceiverConfigDTO,
AlertmanagertypesJSMOpsReceiverConfigDTO,
AlertmanagertypesPostableChannelDTO,
ConfigSecretURLDTO,
ModelDurationDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ChannelType, GoogleChatChannel } from './config';
import {
ChannelType,
GoogleChatChannel,
JiraChannel,
JsmOpsChannel,
} from './config';
export const isChannelType = (type: string): type is ChannelType =>
Object.values(ChannelType).includes(type as ChannelType);
@@ -37,3 +45,126 @@ export const prepareGoogleChatRequest = (
},
],
});
const JIRA_CLOUD_HOST_SUFFIX = '.atlassian.net';
// the backend enforces the same rule, this is only for a nicer error experience
export const isValidJiraSiteURL = (url: string): boolean => {
try {
const { protocol, hostname } = new URL(url);
return (
protocol === 'https:' &&
hostname.toLowerCase().endsWith(JIRA_CLOUD_HOST_SUFFIX)
);
} catch {
return false;
}
};
// mirrors go's prometheus model.Duration units
const JIRA_DURATION_UNIT_MS: Record<string, number> = {
ms: 1,
s: 1_000,
m: 60_000,
h: 3_600_000,
d: 86_400_000,
w: 604_800_000,
y: 31_536_000_000,
};
const JIRA_DURATION_RE = /^(\d+(ms|s|m|h|d|w|y))+$/;
const JIRA_DURATION_TOKEN_RE = /(\d+)(ms|s|m|h|d|w|y)/g;
const JIRA_MIN_REOPEN_MS = 60_000;
// backend requires the same format and a >= 1m minimum, this is only for a
// nicer error experience. Empty and "0" defer to the backend default.
export const isValidJiraReopenDuration = (value: string): boolean => {
if (!value || value === '0') {
return true;
}
if (!JIRA_DURATION_RE.test(value)) {
return false;
}
let totalMs = 0;
for (const [, amount, unit] of value.matchAll(JIRA_DURATION_TOKEN_RE)) {
totalMs += Number(amount) * JIRA_DURATION_UNIT_MS[unit];
}
return totalMs >= JIRA_MIN_REOPEN_MS;
};
// create, update and test all send the same body shape. Optional fields are
// omitted when empty so the backend applies its defaults.
export const prepareJiraRequest = (
config: Partial<JiraChannel>,
): AlertmanagertypesPostableChannelDTO => {
const jira: AlertmanagertypesJiraReceiverConfigDTO = {
site: config.site || '',
project: config.project || '',
issue_type: config.issue_type || '',
send_resolved: config.send_resolved || false,
http_config: {
basic_auth: {
username: config.username || '',
password: config.password || '',
},
},
};
if (config.summary) {
jira.summary = config.summary;
}
if (config.description) {
jira.description = config.description;
}
if (config.priority) {
jira.priority = config.priority;
}
if (config.labels?.length) {
jira.labels = config.labels;
}
if (config.resolve_transition) {
jira.resolve_transition = config.resolve_transition;
}
if (config.reopen_transition) {
jira.reopen_transition = config.reopen_transition;
}
if (config.reopen_duration) {
// the generated type models go's model.Duration as a number, the api takes a
// duration string like "72h"
jira.reopen_duration = config.reopen_duration as unknown as ModelDurationDTO;
}
return {
name: config.name || '',
jira_configs: [jira],
};
};
// create, update and test all send the same body shape. Optional fields are
// omitted when empty so the backend applies its defaults.
export const prepareJsmOpsRequest = (
config: Partial<JsmOpsChannel>,
): AlertmanagertypesPostableChannelDTO => {
const jsmops: AlertmanagertypesJSMOpsReceiverConfigDTO = {
api_key: config.api_key || '',
send_resolved: config.send_resolved || false,
};
if (config.message) {
jsmops.message = config.message;
}
if (config.description) {
jsmops.description = config.description;
}
if (config.priority) {
jsmops.priority = config.priority;
}
if (config.tags?.length) {
// the backend takes a comma-separated string and splits it back
jsmops.tags = config.tags.join(',');
}
return {
name: config.name || '',
jsmops_configs: [jsmops],
};
};

View File

@@ -25,6 +25,8 @@ import {
ChannelType,
EmailChannel,
GoogleChatChannel,
JiraChannel,
JsmOpsChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
@@ -34,7 +36,11 @@ import {
} from 'container/CreateAlertChannels/config';
import {
isValidGoogleChatWebhookURL,
isValidJiraReopenDuration,
isValidJiraSiteURL,
prepareGoogleChatRequest,
prepareJiraRequest,
prepareJsmOpsRequest,
} from 'container/CreateAlertChannels/utils';
import FormAlertChannels from 'container/FormAlertChannels';
import { useNotifications } from 'hooks/useNotifications';
@@ -58,7 +64,9 @@ function EditAlertChannels({
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel
GoogleChatChannel &
JiraChannel &
JsmOpsChannel
>
>({
...initialValue,
@@ -452,6 +460,124 @@ function EditAlertChannels({
t,
]);
const validateJiraConfig = useCallback((): string => {
if (
!selectedConfig.site ||
!selectedConfig.username ||
!selectedConfig.password ||
!selectedConfig.project ||
!selectedConfig.issue_type
) {
return t('jira_required_fields');
}
if (!isValidJiraSiteURL(selectedConfig.site)) {
return t('jira_site_invalid');
}
if (
selectedConfig.reopen_duration &&
!isValidJiraReopenDuration(selectedConfig.reopen_duration)
) {
return t('jira_reopen_duration_invalid');
}
return '';
}, [selectedConfig, t]);
const onJiraEditHandler = useCallback(async () => {
const validationError = validateJiraConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return { status: 'failed', statusMessage: validationError };
}
setSavingState(true);
try {
await updateChannel({
pathParams: { id },
data: prepareJiraRequest(selectedConfig),
});
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
const apiError = notifyError(error);
return {
status: 'failed',
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [
validateJiraConfig,
updateChannel,
id,
selectedConfig,
notifications,
notifyError,
t,
]);
const validateJsmOpsConfig = useCallback((): string => {
if (!selectedConfig.api_key) {
return t('api_key_required');
}
return '';
}, [selectedConfig, t]);
const onJsmOpsEditHandler = useCallback(async () => {
const validationError = validateJsmOpsConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return { status: 'failed', statusMessage: validationError };
}
setSavingState(true);
try {
await updateChannel({
pathParams: { id },
data: prepareJsmOpsRequest(selectedConfig),
});
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
const apiError = notifyError(error);
return {
status: 'failed',
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [
validateJsmOpsConfig,
updateChannel,
id,
selectedConfig,
notifications,
notifyError,
t,
]);
const onSaveHandler = useCallback(
async (value: ChannelType) => {
let result;
@@ -469,6 +595,10 @@ function EditAlertChannels({
result = await onEmailEditHandler();
} else if (value === ChannelType.GoogleChat) {
result = await onGoogleChatEditHandler();
} else if (value === ChannelType.Jira) {
result = await onJiraEditHandler();
} else if (value === ChannelType.JsmOps) {
result = await onJsmOpsEditHandler();
}
logEvent('Alert Channel: Save channel', {
type: value,
@@ -488,10 +618,13 @@ function EditAlertChannels({
onOpsgenieEditHandler,
onEmailEditHandler,
onGoogleChatEditHandler,
onJiraEditHandler,
onJsmOpsEditHandler,
],
);
const performChannelTest = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
async (channelType: ChannelType) => {
setTestingState(true);
try {
@@ -542,6 +675,32 @@ function EditAlertChannels({
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
break;
}
case ChannelType.Jira: {
const validationError = validateJiraConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setTestingState(false);
return;
}
await testChannel({ data: prepareJiraRequest(selectedConfig) });
break;
}
case ChannelType.JsmOps: {
const validationError = validateJsmOpsConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setTestingState(false);
return;
}
await testChannel({ data: prepareJsmOpsRequest(selectedConfig) });
break;
}
default:
notifications.error({
message: 'Error',
@@ -579,6 +738,8 @@ function EditAlertChannels({
t,
notifyError,
validateGoogleChatConfig,
validateJiraConfig,
validateJsmOpsConfig,
testChannel,
prepareWebhookRequest,
preparePagerRequest,

View File

@@ -0,0 +1,242 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapse, Form, Input, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { JiraChannel } from '../../CreateAlertChannels/config';
import {
isValidJiraReopenDuration,
isValidJiraSiteURL,
} from '../../CreateAlertChannels/utils';
function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
const { t } = useTranslation('channels');
const update = (patch: Partial<JiraChannel>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const advanced = (
<>
<Form.Item
name="priority"
label={t('field_jira_priority')}
help={t('help_jira_priority')}
>
<Input
placeholder={t('placeholder_jira_priority')}
onChange={(event): void => update({ priority: event.target.value })}
data-testid="jira-priority-textbox"
/>
</Form.Item>
<Form.Item
name="labels"
label={t('field_jira_labels')}
help={t('help_jira_labels')}
>
<Select
mode="tags"
open={false}
placeholder={t('placeholder_jira_labels')}
onChange={(value): void => update({ labels: value as string[] })}
data-testid="jira-labels-select"
/>
</Form.Item>
<Form.Item
name="resolve_transition"
label={t('field_jira_resolve_transition')}
help={t('help_jira_resolve_transition')}
>
<Input
placeholder={t('placeholder_jira_resolve_transition')}
onChange={(event): void =>
update({ resolve_transition: event.target.value })
}
data-testid="jira-resolve-transition-textbox"
/>
</Form.Item>
<Form.Item
name="reopen_transition"
label={t('field_jira_reopen_transition')}
help={t('help_jira_reopen_transition')}
>
<Input
placeholder={t('placeholder_jira_reopen_transition')}
onChange={(event): void =>
update({ reopen_transition: event.target.value })
}
data-testid="jira-reopen-transition-textbox"
/>
</Form.Item>
<Form.Item
name="reopen_duration"
label={t('field_jira_reopen_duration')}
extra={t('help_jira_reopen_duration')}
rules={[
{
validator: (_, value: string): Promise<void> =>
isValidJiraReopenDuration(value)
? Promise.resolve()
: Promise.reject(new Error(t('jira_reopen_duration_invalid'))),
},
]}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_jira_reopen_duration')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<Input
placeholder={t('placeholder_jira_reopen_duration')}
onChange={(event): void => update({ reopen_duration: event.target.value })}
data-testid="jira-reopen-duration-textbox"
/>
</Form.Item>
</>
);
return (
<>
<Typography.Text
color="muted"
size="sm"
testId="jira-service-account-tip"
style={{ display: 'block', marginBottom: 16 }}
>
{t('jira_service_account_tip')}{' '}
<Typography.Link
href="https://signoz.io/docs/alerts-management/notification-channel/jira/#use-a-service-account-recommended"
target="_blank"
rel="noopener noreferrer"
>
{t('jira_service_account_tip_link')}
</Typography.Link>
</Typography.Text>
<Form.Item
name="site"
label={t('field_jira_site')}
required
rules={[
{
validator: (_, value: string): Promise<void> =>
!value || isValidJiraSiteURL(value)
? Promise.resolve()
: Promise.reject(new Error(t('jira_site_invalid'))),
},
]}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_jira_site')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<Input
placeholder="https://your-domain.atlassian.net"
onChange={(event): void => update({ site: event.target.value })}
data-testid="jira-site-textbox"
/>
</Form.Item>
<Form.Item
name="username"
label={t('field_jira_email')}
help={t('help_jira_email')}
required
>
<Input
onChange={(event): void => update({ username: event.target.value })}
data-testid="jira-email-textbox"
/>
</Form.Item>
<Form.Item
name="password"
label={t('field_jira_api_token')}
help={t('help_jira_api_token')}
required
>
<Input
type="password"
onChange={(event): void => update({ password: event.target.value })}
data-testid="jira-api-token-textbox"
/>
</Form.Item>
<Form.Item name="project" label={t('field_jira_project')} required>
<Input
placeholder="e.g. OPS"
onChange={(event): void => update({ project: event.target.value })}
data-testid="jira-project-textbox"
/>
</Form.Item>
<Form.Item
name="issue_type"
label={t('field_jira_issue_type')}
help={t('help_jira_issue_type')}
required
>
<Input
onChange={(event): void => update({ issue_type: event.target.value })}
data-testid="jira-issue-type-textbox"
/>
</Form.Item>
<Form.Item
name="summary"
label={t('field_jira_summary')}
help={t('help_jira_summary')}
>
<Input.TextArea
rows={2}
onChange={(event): void => update({ summary: event.target.value })}
data-testid="jira-summary-textarea"
/>
</Form.Item>
<Form.Item
name="description"
label={t('field_jira_description')}
help={t('help_jira_description')}
>
<Input.TextArea
rows={6}
onChange={(event): void => update({ description: event.target.value })}
data-testid="jira-description-textarea"
/>
</Form.Item>
<Collapse
ghost
items={[
{
key: 'advanced',
label: t('jira_advanced_section'),
children: advanced,
},
]}
/>
</>
);
}
interface JiraProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<JiraChannel>>>;
}
export default JiraSettings;

View File

@@ -0,0 +1,117 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapse, Form, Input, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { JsmOpsChannel } from '../../CreateAlertChannels/config';
function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
const { t } = useTranslation('channels');
const update = (patch: Partial<JsmOpsChannel>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const advanced = (
<>
<Form.Item
name="priority"
label={t('field_jsmops_priority')}
help={t('help_jsmops_priority')}
>
<Input.TextArea
rows={2}
onChange={(event): void => update({ priority: event.target.value })}
data-testid="jsmops-priority-textarea"
/>
</Form.Item>
<Form.Item
name="tags"
label={t('field_jsmops_tags')}
help={t('help_jsmops_tags')}
>
<Select
mode="tags"
open={false}
placeholder={t('placeholder_jsmops_tags')}
onChange={(value): void => update({ tags: value as string[] })}
data-testid="jsmops-tags-select"
/>
</Form.Item>
</>
);
return (
<>
<Typography.Text
color="muted"
size="sm"
testId="jsmops-tip"
style={{ display: 'block', marginBottom: 16 }}
>
{t('jsmops_tip')}{' '}
<Typography.Link
href="https://signoz.io/docs/alerts-management/notification-channel/jsm-ops/"
target="_blank"
rel="noopener noreferrer"
>
{t('jsmops_tip_link')}
</Typography.Link>
</Typography.Text>
<Form.Item
name="api_key"
label={t('field_jsmops_api_key')}
help={t('help_jsmops_api_key')}
required
>
<Input
type="password"
onChange={(event): void => update({ api_key: event.target.value })}
data-testid="jsmops-api-key-textbox"
/>
</Form.Item>
<Form.Item
name="message"
label={t('field_jsmops_message')}
help={t('help_jsmops_message')}
>
<Input.TextArea
rows={2}
onChange={(event): void => update({ message: event.target.value })}
data-testid="jsmops-message-textarea"
/>
</Form.Item>
<Form.Item
name="description"
label={t('field_jsmops_description')}
help={t('help_jsmops_description')}
>
<Input.TextArea
rows={6}
onChange={(event): void => update({ description: event.target.value })}
data-testid="jsmops-description-textarea"
/>
</Form.Item>
<Collapse
ghost
items={[
{
key: 'advanced',
label: t('jsmops_advanced_section'),
children: advanced,
},
]}
/>
</>
);
}
interface JsmOpsProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<JsmOpsChannel>>>;
}
export default JsmOpsSettings;

View File

@@ -10,6 +10,8 @@ import {
ChannelType,
EmailChannel,
GoogleChatChannel,
JiraChannel,
JsmOpsChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
@@ -19,6 +21,8 @@ import history from 'lib/history';
import EmailSettings from './Settings/Email';
import GoogleChatSettings from './Settings/GoogleChat';
import JiraSettings from './Settings/Jira';
import JsmOpsSettings from './Settings/JsmOps';
import MsTeamsSettings from './Settings/MsTeams';
import OpsgenieSettings from './Settings/Opsgenie';
import PagerSettings from './Settings/Pager';
@@ -53,6 +57,10 @@ function FormAlertChannels({
return <MsTeamsSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.GoogleChat:
return <GoogleChatSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Jira:
return <JiraSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.JsmOps:
return <JsmOpsSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Opsgenie:
return <OpsgenieSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Email:
@@ -141,6 +149,14 @@ function FormAlertChannels({
>
Google Chat
</Select.Option>
<Select.Option value="jira" key="jira" data-testid="select-option">
Jira
</Select.Option>
<Select.Option value="jsmops" key="jsmops" data-testid="select-option">
Jira Service Management Ops
</Select.Option>
</Select>
</Form.Item>
@@ -189,7 +205,9 @@ interface FormAlertChannelsProps {
PagerChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel
GoogleChatChannel &
JiraChannel &
JsmOpsChannel
>
>
>;

View File

@@ -11,6 +11,8 @@ import ROUTES from 'constants/routes';
import {
ChannelType,
GoogleChatChannel,
JiraChannel,
JsmOpsChannel,
MsTeamsChannel,
PagerChannel,
SlackChannel,
@@ -60,17 +62,25 @@ function ChannelsEdit(): JSX.Element {
const prepChannelConfig = (): {
type: string;
channel: SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
GoogleChatChannel;
channel: Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
GoogleChatChannel &
JiraChannel &
JsmOpsChannel
>;
} => {
let channel: SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
GoogleChatChannel = {
let channel: Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
GoogleChatChannel &
JiraChannel &
JsmOpsChannel
> = {
name: '',
};
@@ -101,6 +111,19 @@ function ChannelsEdit(): JSX.Element {
};
}
if (value && 'jira_configs' in value) {
const [jiraConfig] = value.jira_configs;
channel = jiraConfig;
if (jiraConfig.http_config?.basic_auth) {
channel.username = jiraConfig.http_config.basic_auth.username;
channel.password = jiraConfig.http_config.basic_auth.password;
}
return {
type: ChannelType.Jira,
channel,
};
}
if (value && 'pagerduty_configs' in value) {
const pagerConfig = value.pagerduty_configs[0];
channel = pagerConfig;
@@ -112,6 +135,22 @@ function ChannelsEdit(): JSX.Element {
};
}
if (value && 'jsmops_configs' in value) {
const [jsmopsConfig] = value.jsmops_configs;
channel = jsmopsConfig;
// backend stores tags as a comma-separated string; the form uses chips
channel.tags = jsmopsConfig.tags
? String(jsmopsConfig.tags)
.split(',')
.map((tag: string) => tag.trim())
.filter(Boolean)
: [];
return {
type: ChannelType.JsmOps,
channel,
};
}
if (value && 'opsgenie_configs' in value) {
const opsgenieConfig = value.opsgenie_configs[0];
channel = opsgenieConfig;

View File

@@ -43,7 +43,7 @@ func ExistsExpression(columns []*schema.Column, key *telemetrytypes.TelemetryFie
if len(evolutionsEntries) > 0 && evolutionsEntries[0] != nil {
columnName = evolutionsEntries[0].ColumnName
}
rawPath := fmt.Sprintf("%s.%s", columnName, ClickHouseIdentifier(key.Name))
rawPath := fmt.Sprintf("%s.`%s`", columnName, key.Name)
if exists {
return rawPath + " IS NOT NULL", nil
}

View File

@@ -2500,15 +2500,13 @@ func (k *telemetryMetaStore) updateColumnEvolutionMetadataForKeys(ctx context.Co
FieldContext: key.FieldContext,
FieldName: "__all__",
}
// the per-field entries add to the column-wide ones, they don't replace them.
// NOTE: if a field evolved to its own column before an __all__ migration for the
// same signal+context, that later __all__ entry does not really apply to this field
// (the field had already moved). We ignore that case as it does not occur currently.
var keyEvolutions []*telemetrytypes.EvolutionEntry
keyEvolutions = append(keyEvolutions, evolutionsByUniqueKey[selector.QualifiedName()]...)
// first check if there is evolutions that with field name as __all__
if keyEvolutions, ok := evolutionsByUniqueKey[selector.QualifiedName()]; ok {
keysToUpdate[i].Evolutions = keyEvolutions
}
// then check for specific field name
selector.FieldName = key.Name
keyEvolutions = append(keyEvolutions, evolutionsByUniqueKey[selector.QualifiedName()]...)
if len(keyEvolutions) > 0 {
if keyEvolutions, ok := evolutionsByUniqueKey[selector.QualifiedName()]; ok {
keysToUpdate[i].Evolutions = keyEvolutions
}
}

View File

@@ -73,8 +73,6 @@ func (c *conditionBuilder) conditionFor(
// the first member stands in for the field.
fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(logical.Single(), value, fieldExpression, operator)
fieldExpression = foldAbsentJSONReadToTypeDefault(logical.Single(), operator, fieldExpression)
// regular operators
switch operator {
// regular operators
@@ -179,31 +177,6 @@ func (c *conditionBuilder) conditionFor(
return "", nil
}
// foldAbsentJSONReadToTypeDefault gives negative operators on a numeric/bool JSON attribute the
// legacy Map's absent-key semantics. Negative operators carry no guard, so NULL <> x would drop rows
// lacking the key, whereas the Map defaulted them to the type zero and kept them (0 <> x).
// String needs no fold — its ::String value already reads absent as ”.
func foldAbsentJSONReadToTypeDefault(key *telemetrytypes.TelemetryFieldKey, operator qbtypes.FilterOperator, expr string) string {
if !operator.IsNegativeOperator() || operator == qbtypes.FilterOperatorNotExists {
return expr
}
if key.FieldContext != telemetrytypes.FieldContextAttribute {
return expr
}
if !attributeColumnEvolutionRegistered(key, SpanAttributesColumn) {
return expr
}
switch key.FieldDataType {
case telemetrytypes.FieldDataTypeInt64,
telemetrytypes.FieldDataTypeFloat64,
telemetrytypes.FieldDataTypeNumber:
return fmt.Sprintf("ifNull(%s, 0)", expr)
case telemetrytypes.FieldDataTypeBool:
return fmt.Sprintf("ifNull(%s, false)", expr)
}
return expr
}
// isFoldContext reports whether the context is one CandidateKeys would fold the prefix into
// the key name for (span/trace). These behave like a default context that also addresses
// columns and attributes, unlike strict resource/attribute/scope contexts.

View File

@@ -40,12 +40,10 @@ const (
SpanIsRemoteColumn = "is_remote"
// Contextual Columns.
SpanAttributesStringColumn = "attributes_string"
SpanAttributesNumberColumn = "attributes_number"
SpanAttributesBoolColumn = "attributes_bool"
SpanAttributesColumn = "attributes"
SpanAttributesPromotedColumn = "attributes_promoted"
SpanResourcesStringColumn = "resources_string"
SpanAttributesStringColumn = "attributes_string"
SpanAttributesNumberColumn = "attributes_number"
SpanAttributesBoolColumn = "attributes_bool"
SpanResourcesStringColumn = "resources_string"
)
var (

View File

@@ -52,10 +52,8 @@ var (
KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
ValueType: schema.ColumnTypeString,
}},
"resource": {Name: "resource", Type: schema.JSONColumnType{}},
"scope": {Name: "scope", Type: schema.JSONColumnType{}},
"attributes": {Name: "attributes", Type: schema.JSONColumnType{}},
"attributes_promoted": {Name: "attributes_promoted", Type: schema.JSONColumnType{}},
"resource": {Name: "resource", Type: schema.JSONColumnType{}},
"scope": {Name: "scope", Type: schema.JSONColumnType{}},
"events": {Name: "events", Type: schema.ArrayColumnType{
ElementType: schema.ColumnTypeString,
@@ -186,28 +184,16 @@ func (m *fieldMapper) getColumn(
case telemetrytypes.FieldContextScope:
return []*schema.Column{indexV3Columns["scope"]}, nil
case telemetrytypes.FieldContextAttribute:
var mapCol *schema.Column
switch key.FieldDataType {
case telemetrytypes.FieldDataTypeString:
mapCol = indexV3Columns["attributes_string"]
return []*schema.Column{indexV3Columns["attributes_string"]}, nil
case telemetrytypes.FieldDataTypeInt64,
telemetrytypes.FieldDataTypeFloat64,
telemetrytypes.FieldDataTypeNumber:
mapCol = indexV3Columns["attributes_number"]
return []*schema.Column{indexV3Columns["attributes_number"]}, nil
case telemetrytypes.FieldDataTypeBool:
mapCol = indexV3Columns["attributes_bool"]
default:
return nil, qbtypes.ErrColumnNotFound
return []*schema.Column{indexV3Columns["attributes_bool"]}, nil
}
// The `attributes` evolution entry is the rollout control.
if attributeColumnEvolutionRegistered(key, SpanAttributesColumn) {
cols := make([]*schema.Column, 0, 3)
if attributeColumnEvolutionRegistered(key, SpanAttributesPromotedColumn) {
cols = append(cols, indexV3Columns["attributes_promoted"])
}
return append(cols, indexV3Columns["attributes"], mapCol), nil
}
return []*schema.Column{mapCol}, nil
case telemetrytypes.FieldContextSpan:
// Check if this is a span scope field
if strings.ToLower(key.Name) == SpanSearchScopeRoot || strings.ToLower(key.Name) == SpanSearchScopeEntryPoint {
@@ -274,7 +260,7 @@ func (m *fieldMapper) FieldFor(
for i, expr := range exprs {
finalExprs = append(finalExprs, fmt.Sprintf("%s, %s", existExpr[i], expr))
}
return fmt.Sprintf("multiIf(%s, NULL)", strings.Join(finalExprs, ", ")), nil
return "multiIf(" + strings.Join(finalExprs, ", ") + ", NULL)", nil
}
// should not reach here
@@ -323,13 +309,8 @@ func (m *fieldMapper) resolveColumnExprs(
exprs = append(exprs, fmt.Sprintf("%s.attributes.%s::String", columnName, querybuilder.ClickHouseIdentifier(attributeName)))
existExprs = append(existExprs, fmt.Sprintf("%s.attributes.%s IS NOT NULL", columnName, querybuilder.ClickHouseIdentifier(attributeName)))
}
case telemetrytypes.FieldContextAttribute:
path := fmt.Sprintf("%s.%s", columnName, querybuilder.ClickHouseIdentifier(key.Name))
expr, existExpr := attributeJSONValueExpr(path, key.FieldDataType)
exprs = append(exprs, expr)
existExprs = append(existExprs, existExpr)
default:
return nil, nil, nil, errors.NewInternalf(errors.CodeInternal, "only resource, scope and attribute context fields are supported for json columns, got %s", key.FieldContext.String)
return nil, nil, nil, errors.NewInternalf(errors.CodeInternal, "only resource and scope context fields are supported for json columns, got %s", key.FieldContext.String)
}
case schema.ColumnTypeEnumString,
schema.ColumnTypeEnumUInt64,
@@ -372,39 +353,6 @@ func (m *fieldMapper) resolveColumnExprs(
return exprs, existExprs, columns, nil
}
// attributeColumnEvolutionRegistered reports whether key carries an evolution entry for the given column.
func attributeColumnEvolutionRegistered(key *telemetrytypes.TelemetryFieldKey, columnName string) bool {
for _, e := range key.Evolutions {
if e != nil && e.ColumnName == columnName {
return true
}
}
return false
}
// attributeJSONValueExpr renders the value expression for a span attribute read from the JSON
// column along with its per-type existence guard.
// Numeric and bool gate a crash-safe accurateCastOrNull by dynamicType: the cast alone coerces
// across domains (bool true reads 1, '200' reads 200, 200.5 reads true), so the read is
// restricted to values stored as that type — the per-type separation the typed maps gave
// structurally. Being NULL-capable, the gated read itself is the existence guard (present AS
// THIS TYPE). Other reads are total (::String folds absent to '' on the raw path), so the
// guard is presence on the raw path.
func attributeJSONValueExpr(path string, dataType telemetrytypes.FieldDataType) (string, string) {
switch dataType {
case telemetrytypes.FieldDataTypeInt64,
telemetrytypes.FieldDataTypeFloat64,
telemetrytypes.FieldDataTypeNumber:
expr := fmt.Sprintf("if(dynamicType(%s) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(%s, 'Float64'), NULL)", path, path) // all numeric types to float64 like attributes_number map.
return expr, expr + " IS NOT NULL"
case telemetrytypes.FieldDataTypeBool:
expr := fmt.Sprintf("if(dynamicType(%s) = 'Bool', accurateCastOrNull(%s, 'Bool'), NULL)", path, path)
return expr, expr + " IS NOT NULL"
default:
return path + "::String", fmt.Sprintf("%s IS NOT NULL", path)
}
}
// upgradeToFamilies swaps single-member candidates for their family when the
// metadata map proves membership. Candidate order and every non-family
// candidate stay exactly as the legacy flow produced them; sibling candidates
@@ -491,7 +439,6 @@ func (m *fieldMapper) ColumnExpressionFor(
// Group-by/order (String) and aggregation (String/Float64): every candidate is
// exists-guarded and coerced to requiredDataType, in a single multiIf. Raw select
// (Unspecified) keeps the lighter native shape below.
if requiredDataType != telemetrytypes.FieldDataTypeUnspecified {
var dummyValue any = ""
if requiredDataType == telemetrytypes.FieldDataTypeFloat64 {
@@ -499,7 +446,11 @@ func (m *fieldMapper) ColumnExpressionFor(
}
stmts := make([]string, 0, len(candidates)*2)
for _, logical := range candidates {
value, guard, err := m.branchValueAndGuard(ctx, orgID, startNs, endNs, logical)
value, err := querybuilder.LogicalValueExpr(ctx, orgID, startNs, endNs, m, logical)
if err != nil {
return "", err
}
guard, err := querybuilder.LogicalExistsExpr(ctx, orgID, startNs, endNs, m, logical, true)
if err != nil {
return "", err
}
@@ -536,7 +487,11 @@ func (m *fieldMapper) ColumnExpressionFor(
// stringified so branches share a type.
args := make([]string, 0, len(candidates))
for _, logical := range candidates {
value, guard, err := m.branchValueAndGuard(ctx, orgID, startNs, endNs, logical)
value, err := querybuilder.LogicalValueExpr(ctx, orgID, startNs, endNs, m, logical)
if err != nil {
return "", err
}
guard, err := querybuilder.LogicalExistsExpr(ctx, orgID, startNs, endNs, m, logical, true)
if err != nil {
return "", err
}
@@ -545,41 +500,7 @@ func (m *fieldMapper) ColumnExpressionFor(
return fmt.Sprintf("multiIf(%s, NULL)", strings.Join(args, ", ")), nil
}
// branchValueAndGuard resolves a candidate's value expression and branch guard. A
// single-column attribute candidate takes both from one column resolution: the guard is
// the per-type existence (on the JSON column, the cast itself for numeric/bool), so a row
// stored as another type falls through to the branch that renders it. Families and
// multi-column (straddle) candidates keep the presence guard from LogicalExistsExpr.
func (m *fieldMapper) branchValueAndGuard(ctx context.Context,
orgID valuer.UUID,
startNs, endNs uint64,
logical *telemetrytypes.LogicalField,
) (string, string, error) {
if !logical.IsFamily() {
member := logical.Single()
if member.FieldContext == telemetrytypes.FieldContextAttribute {
exprs, existExprs, _, err := m.resolveColumnExprs(ctx, startNs, endNs, member)
if err != nil {
return "", "", err
}
if len(exprs) == 1 && len(existExprs) == 1 {
return exprs[0], existExprs[0], nil
}
}
}
value, err := querybuilder.LogicalValueExpr(ctx, orgID, startNs, endNs, m, logical)
if err != nil {
return "", "", err
}
guard, err := querybuilder.LogicalExistsExpr(ctx, orgID, startNs, endNs, m, logical, true)
if err != nil {
return "", "", err
}
return value, guard, nil
}
// logicalIsTemporal reports whether the logical field resolves to a single time
// column. A family is attribute-backed and never temporal.
func (m *fieldMapper) logicalIsTemporal(ctx context.Context, startNs, endNs uint64, logical *telemetrytypes.LogicalField) (bool, error) {
if logical.IsFamily() {

View File

@@ -1,475 +0,0 @@
package tracestelemetryschema
import (
"context"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/huandu/go-sqlbuilder"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var (
attrJSONRelease = time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
attrWindowBefore = [2]uint64{tsNano(2024, 1), tsNano(2024, 6)}
attrWindowAfter = [2]uint64{tsNano(2025, 6), tsNano(2025, 7)}
attrWindowStraddle = [2]uint64{tsNano(2024, 6), tsNano(2025, 6)}
)
func tsNano(y int, m time.Month) uint64 {
return uint64(time.Date(y, m, 1, 0, 0, 0, 0, time.UTC).UnixNano())
}
func attrKey(name string, dt telemetrytypes.FieldDataType, evo []*telemetrytypes.EvolutionEntry) telemetrytypes.TelemetryFieldKey {
return telemetrytypes.TelemetryFieldKey{
Name: name,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: dt,
Evolutions: evo,
}
}
// TestFieldForAttributeJSONEvolution asserts the value expression across the rollout window:
// before release the legacy Map lookup (byte-for-byte today), after release the type-aware JSON
// cast, straddling a dual-read multiIf with the JSON column first.
func TestFieldForAttributeJSONEvolution(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper(flaggertest.New(t))
evo := MockAttributeEvolutionData(attrJSONRelease)
testCases := []struct {
name string
dataType telemetrytypes.FieldDataType
window [2]uint64
expected string
}{
{"string before -> map", telemetrytypes.FieldDataTypeString, attrWindowBefore, "attributes_string['user.id']"},
{"string after -> json", telemetrytypes.FieldDataTypeString, attrWindowAfter, "attributes.`user.id`::String"},
{"string straddle -> dual", telemetrytypes.FieldDataTypeString, attrWindowStraddle, "multiIf(attributes.`user.id` IS NOT NULL, attributes.`user.id`::String, mapContains(attributes_string, 'user.id'), attributes_string['user.id'], NULL)"},
{"number before -> map", telemetrytypes.FieldDataTypeNumber, attrWindowBefore, "attributes_number['user.id']"},
{"number after -> json", telemetrytypes.FieldDataTypeNumber, attrWindowAfter, "if(dynamicType(attributes.`user.id`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`user.id`, 'Float64'), NULL)"},
{"number straddle -> dual", telemetrytypes.FieldDataTypeNumber, attrWindowStraddle, "multiIf(if(dynamicType(attributes.`user.id`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`user.id`, 'Float64'), NULL) IS NOT NULL, if(dynamicType(attributes.`user.id`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`user.id`, 'Float64'), NULL), mapContains(attributes_number, 'user.id'), attributes_number['user.id'], NULL)"},
{"int64 after -> json", telemetrytypes.FieldDataTypeInt64, attrWindowAfter, "if(dynamicType(attributes.`user.id`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`user.id`, 'Float64'), NULL)"},
{"bool before -> map", telemetrytypes.FieldDataTypeBool, attrWindowBefore, "attributes_bool['user.id']"},
{"bool after -> json", telemetrytypes.FieldDataTypeBool, attrWindowAfter, "if(dynamicType(attributes.`user.id`) = 'Bool', accurateCastOrNull(attributes.`user.id`, 'Bool'), NULL)"},
{"bool straddle -> dual", telemetrytypes.FieldDataTypeBool, attrWindowStraddle, "multiIf(if(dynamicType(attributes.`user.id`) = 'Bool', accurateCastOrNull(attributes.`user.id`, 'Bool'), NULL) IS NOT NULL, if(dynamicType(attributes.`user.id`) = 'Bool', accurateCastOrNull(attributes.`user.id`, 'Bool'), NULL), mapContains(attributes_bool, 'user.id'), attributes_bool['user.id'], NULL)"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
key := attrKey("user.id", tc.dataType, evo)
got, err := fm.FieldFor(ctx, valuer.UUID{}, tc.window[0], tc.window[1], &key)
require.NoError(t, err)
assert.Equal(t, tc.expected, got)
})
}
}
// TestFieldForAttributeNoEvolutionParity proves the JSON column is untouched until the evolution
// entry is registered: a key with no evolutions resolves to the Map column for every window.
func TestFieldForAttributeNoEvolutionParity(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper(flaggertest.New(t))
for _, dt := range []struct {
dataType telemetrytypes.FieldDataType
expected string
}{
{telemetrytypes.FieldDataTypeString, "attributes_string['user.id']"},
{telemetrytypes.FieldDataTypeNumber, "attributes_number['user.id']"},
{telemetrytypes.FieldDataTypeBool, "attributes_bool['user.id']"},
} {
key := attrKey("user.id", dt.dataType, nil)
got, err := fm.FieldFor(ctx, valuer.UUID{}, attrWindowAfter[0], attrWindowAfter[1], &key)
require.NoError(t, err)
assert.Equal(t, dt.expected, got, "no evolution entry must keep the Map path")
}
}
// TestConditionForAttributeJSON asserts the emitted WHERE fragment per operator against the JSON
// column (window fully after release). Positive operators carry the raw-path existence guard;
// numeric comparisons keep numeric semantics; existence never tests the ::String cast.
func TestConditionForAttributeJSON(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper(flaggertest.New(t))
cb := NewConditionBuilder(fm, flaggertest.New(t))
evo := MockAttributeEvolutionData(attrJSONRelease)
testCases := []struct {
name string
key telemetrytypes.TelemetryFieldKey
operator qbtypes.FilterOperator
value any
expected string
}{
{
name: "equal string",
key: attrKey("user.id", telemetrytypes.FieldDataTypeString, evo),
operator: qbtypes.FilterOperatorEqual, value: "admin",
expected: "(attributes.`user.id`::String = ? AND attributes.`user.id` IS NOT NULL)",
},
{
name: "not equal string has no exists guard",
key: attrKey("user.id", telemetrytypes.FieldDataTypeString, evo),
operator: qbtypes.FilterOperatorNotEqual, value: "admin",
expected: "attributes.`user.id`::String <> ?",
},
{
name: "greater than number",
key: attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo),
operator: qbtypes.FilterOperatorGreaterThan, value: float64(200),
expected: "toFloat64(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL)) > ?",
},
{
name: "ilike string",
key: attrKey("user.id", telemetrytypes.FieldDataTypeString, evo),
operator: qbtypes.FilterOperatorILike, value: "%adm%",
expected: "LOWER(attributes.`user.id`::String) LIKE LOWER(?)",
},
{
name: "exists uses raw path",
key: attrKey("user.id", telemetrytypes.FieldDataTypeString, evo),
operator: qbtypes.FilterOperatorExists, value: nil,
expected: "attributes.`user.id` IS NOT NULL",
},
{
name: "not exists uses raw path",
key: attrKey("user.id", telemetrytypes.FieldDataTypeString, evo),
operator: qbtypes.FilterOperatorNotExists, value: nil,
expected: "attributes.`user.id` IS NULL",
},
{
name: "in string",
key: attrKey("user.id", telemetrytypes.FieldDataTypeString, evo),
operator: qbtypes.FilterOperatorIn, value: []any{"a", "b"},
expected: "((attributes.`user.id`::String = ? OR attributes.`user.id`::String = ?) AND attributes.`user.id` IS NOT NULL)",
},
{
name: "not in string has no exists guard",
key: attrKey("user.id", telemetrytypes.FieldDataTypeString, evo),
operator: qbtypes.FilterOperatorNotIn, value: []any{"a", "b"},
expected: "(attributes.`user.id`::String <> ? AND attributes.`user.id`::String <> ?)",
},
{
name: "between number",
key: attrKey("latency", telemetrytypes.FieldDataTypeNumber, evo),
operator: qbtypes.FilterOperatorBetween, value: []any{float64(1), float64(9)},
expected: "toFloat64(if(dynamicType(attributes.`latency`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`latency`, 'Float64'), NULL)) BETWEEN ? AND ?",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := cb.ConditionFor(ctx, valuer.UUID{}, attrWindowAfter[0], attrWindowAfter[1], &tc.key,
map[string][]*telemetrytypes.TelemetryFieldKey{tc.key.Name: {&tc.key}}, qbtypes.ConditionBuilderOptions{}, tc.operator, tc.value, sb)
require.NoError(t, err)
sb.Where(conds...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, tc.expected)
})
}
}
// TestConditionForAttributeJSONNotExistsDualRead covers NOT EXISTS across both homes during the
// dual-read window: it must AND the JSON IS NULL with NOT mapContains so a row present in either
// home is excluded (De Morgan), including rows that predate the JSON column.
func TestConditionForAttributeJSONNotExistsDualRead(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper(flaggertest.New(t))
cb := NewConditionBuilder(fm, flaggertest.New(t))
evo := MockAttributeEvolutionData(attrJSONRelease)
key := attrKey("user.id", telemetrytypes.FieldDataTypeString, evo)
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := cb.ConditionFor(ctx, valuer.UUID{}, attrWindowStraddle[0], attrWindowStraddle[1], &key,
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {&key}}, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorNotExists, nil, sb)
require.NoError(t, err)
sb.Where(conds...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
// the value multiIf resolves the row's home; NOT EXISTS negates the whole thing to IS NULL
assert.Contains(t, sql, "IS NULL")
assert.Contains(t, sql, "attributes.`user.id` IS NOT NULL")
assert.Contains(t, sql, "mapContains(attributes_string, 'user.id')")
}
// TestColumnExpressionForAttributeJSON covers group-by (coerced to String) and aggregation
// (coerced to Float64) over a JSON attribute after release: both are exists-guarded so an absent
// path is NULL rather than a spurious ”/0, and the numeric branch keeps its toFloat64 coercion.
func TestColumnExpressionForAttributeJSON(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper(flaggertest.New(t))
evo := MockAttributeEvolutionData(attrJSONRelease)
t.Run("group by string", func(t *testing.T) {
key := attrKey("user.id", telemetrytypes.FieldDataTypeString, evo)
got, err := fm.ColumnExpressionFor(ctx, valuer.UUID{}, attrWindowAfter[0], attrWindowAfter[1], &key, telemetrytypes.FieldDataTypeString, nil)
require.NoError(t, err)
assert.Equal(t, "multiIf(attributes.`user.id` IS NOT NULL, attributes.`user.id`::String, NULL)", got)
})
t.Run("aggregation numeric", func(t *testing.T) {
key := attrKey("latency", telemetrytypes.FieldDataTypeNumber, evo)
got, err := fm.ColumnExpressionFor(ctx, valuer.UUID{}, attrWindowAfter[0], attrWindowAfter[1], &key, telemetrytypes.FieldDataTypeFloat64, nil)
require.NoError(t, err)
assert.Equal(t, "multiIf(if(dynamicType(attributes.`latency`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`latency`, 'Float64'), NULL) IS NOT NULL, toFloat64(if(dynamicType(attributes.`latency`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`latency`, 'Float64'), NULL)), NULL)", got)
})
}
// TestAttributeJSONNoAmbiguityWarning guards against a visible regression: the JSON column is a
// second physical home for the same logical field, not a second logical field, so a plain
// attribute filter must not emit the "ambiguous key" warning.
func TestAttributeJSONNoAmbiguityWarning(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper(flaggertest.New(t))
cb := NewConditionBuilder(fm, flaggertest.New(t))
evo := MockAttributeEvolutionData(attrJSONRelease)
key := attrKey("user.id", telemetrytypes.FieldDataTypeString, evo)
sb := sqlbuilder.NewSelectBuilder()
_, warnings, err := cb.ConditionFor(ctx, valuer.UUID{}, attrWindowAfter[0], attrWindowAfter[1], &key,
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {&key}}, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, "x", sb)
require.NoError(t, err)
assert.Empty(t, warnings, "a plain attribute filter must not emit an ambiguity warning")
}
// TestConditionForAttributeJSONTypeCollision covers a name stored under two data types (String
// and Int64) in the JSON column: an untyped filter fans out to one exists-guarded condition per
// type, both reading the same physical path with their own cast, and surfaces the ambiguity
// warning. In the JSON column the two branches share the raw path; only the cast differs.
func TestConditionForAttributeJSONTypeCollision(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper(flaggertest.New(t))
cb := NewConditionBuilder(fm, flaggertest.New(t))
evo := MockAttributeEvolutionData(attrJSONRelease)
strKey := attrKey("http.status_code", telemetrytypes.FieldDataTypeString, evo)
intKey := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"http.status_code": {&strKey, &intKey},
}
ref := attrKey("http.status_code", telemetrytypes.FieldDataTypeUnspecified, nil)
sb := sqlbuilder.NewSelectBuilder()
conds, warnings, err := cb.ConditionFor(ctx, valuer.UUID{}, attrWindowAfter[0], attrWindowAfter[1], &ref,
fieldKeys, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, float64(200), sb)
require.NoError(t, err)
require.Len(t, conds, 2, "a colliding name must build one condition per data type")
sb.Where(sb.Or(conds...))
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, "toFloat64OrNull(attributes.`http.status_code`::String) = ?")
assert.Contains(t, sql, "toFloat64(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL)) = ?")
assert.Contains(t, sql, "attributes.`http.status_code` IS NOT NULL")
assert.NotEmpty(t, warnings, "a colliding name must surface the ambiguity warning")
}
// TestColumnExpressionForAttributeJSONTypeCollision covers group-by on a name stored under two
// data types. On the JSON column both interpretations read one path: the numeric branch is guarded
// by its cast (a wrong-typed row reads NULL and falls through), and the ::String branch renders any
// stored scalar faithfully (200 -> '200', true -> 'true'), so every candidate order reads each row
// as its actual stored type.
func TestColumnExpressionForAttributeJSONTypeCollision(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper(flaggertest.New(t))
evo := MockAttributeEvolutionData(attrJSONRelease)
strKey := attrKey("http.status_code", telemetrytypes.FieldDataTypeString, evo)
intKey := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"http.status_code": {&strKey, &intKey},
}
ref := attrKey("http.status_code", telemetrytypes.FieldDataTypeUnspecified, nil)
got, err := fm.ColumnExpressionFor(ctx, valuer.UUID{}, attrWindowAfter[0], attrWindowAfter[1], &ref, telemetrytypes.FieldDataTypeString, fieldKeys)
require.NoError(t, err)
assert.Equal(t,
"multiIf(attributes.`http.status_code` IS NOT NULL, attributes.`http.status_code`::String, if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL) IS NOT NULL, toString(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL)), NULL)",
got)
}
// TestColumnExpressionForAttributeJSONTypeCollisionNumericAgg covers a numeric aggregation over a
// name colliding as Number and String: the cast-guarded numeric branch takes numeric rows (a
// wrong-typed row reads NULL and falls through), and the string branch parses whatever remains —
// non-numeric strings parse to NULL and stay out of the aggregate, the per-type Map union's
// coverage.
func TestColumnExpressionForAttributeJSONTypeCollisionNumericAgg(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper(flaggertest.New(t))
evo := MockAttributeEvolutionData(attrJSONRelease)
numKey := attrKey("http.status_code", telemetrytypes.FieldDataTypeNumber, evo)
strKey := attrKey("http.status_code", telemetrytypes.FieldDataTypeString, evo)
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"http.status_code": {&numKey, &strKey},
}
ref := attrKey("http.status_code", telemetrytypes.FieldDataTypeUnspecified, nil)
got, err := fm.ColumnExpressionFor(ctx, valuer.UUID{}, attrWindowAfter[0], attrWindowAfter[1], &ref, telemetrytypes.FieldDataTypeFloat64, fieldKeys)
require.NoError(t, err)
assert.Equal(t,
"multiIf(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL) IS NOT NULL, toFloat64(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL)), attributes.`http.status_code` IS NOT NULL, toFloat64OrNull(attributes.`http.status_code`::String), NULL)",
got)
}
// TestConditionForAttributeMapTypeCollisionParity anchors the legacy behavior the JSON path must
// preserve: before the rollout the same colliding name fans out to two separate physical map
// columns (attributes_string / attributes_number), each with its own mapContains guard.
func TestConditionForAttributeMapTypeCollisionParity(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper(flaggertest.New(t))
cb := NewConditionBuilder(fm, flaggertest.New(t))
evo := MockAttributeEvolutionData(attrJSONRelease)
strKey := attrKey("http.status_code", telemetrytypes.FieldDataTypeString, evo)
intKey := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"http.status_code": {&strKey, &intKey},
}
ref := attrKey("http.status_code", telemetrytypes.FieldDataTypeUnspecified, nil)
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := cb.ConditionFor(ctx, valuer.UUID{}, attrWindowBefore[0], attrWindowBefore[1], &ref,
fieldKeys, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, float64(200), sb)
require.NoError(t, err)
require.Len(t, conds, 2, "a colliding name must build one condition per data type")
sb.Where(sb.Or(conds...))
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, "toFloat64OrNull(attributes_string['http.status_code']) = ?")
assert.Contains(t, sql, "mapContains(attributes_string, 'http.status_code')")
assert.Contains(t, sql, "toFloat64(attributes_number['http.status_code']) = ?")
assert.Contains(t, sql, "mapContains(attributes_number, 'http.status_code')")
}
// TestColumnForUnspecifiedAttributeNoBranchFlip pins the branch-flip decision: a
// data-type-unspecified attribute key resolves to no column (even with the evolution present), so
// bare attribute keys keep taking the legacy CandidateKeys/synthesis path rather than becoming
// metadata-first resolvable.
func TestColumnForUnspecifiedAttributeNoBranchFlip(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper(flaggertest.New(t))
evo := MockAttributeEvolutionData(attrJSONRelease)
key := attrKey("user.id", telemetrytypes.FieldDataTypeUnspecified, evo)
_, err := fm.ColumnFor(ctx, valuer.UUID{}, attrWindowAfter[0], attrWindowAfter[1], &key)
assert.ErrorIs(t, err, qbtypes.ErrColumnNotFound)
}
// TestConditionForAttributeJSONNegativeOperatorParity pins Map parity for numeric/bool attributes.
// The value reads an absent key as NULL (accurateCastOrNull, or the straddle multiIf else); a
// positive operator excludes such a row via the exists guard, but a negative operator has no guard,
// so the condition builder folds the NULL to the Map's type zero (ifNull) for negatives only.
// String needs no fold — ::String already reads absent as ”. The fold rides the attributes
// evolution: a key without it (the pre-rollout system) is byte-identical to today.
func TestConditionForAttributeJSONNegativeOperatorParity(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper(flaggertest.New(t))
cb := NewConditionBuilder(fm, flaggertest.New(t))
evo := MockAttributeEvolutionData(attrJSONRelease)
build := func(t *testing.T, key telemetrytypes.TelemetryFieldKey, window [2]uint64, op qbtypes.FilterOperator, value any) string {
t.Helper()
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := cb.ConditionFor(ctx, valuer.UUID{}, window[0], window[1], &key,
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {&key}}, qbtypes.ConditionBuilderOptions{}, op, value, sb)
require.NoError(t, err)
sb.Where(conds...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return sql
}
t.Run("not equal number after -> NULL folded to 0", func(t *testing.T) {
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
sql := build(t, key, attrWindowAfter, qbtypes.FilterOperatorNotEqual, float64(200))
assert.Contains(t, sql, "ifNull(toFloat64(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL)), 0) <> ?")
})
t.Run("not equal bool after -> NULL folded to false", func(t *testing.T) {
key := attrKey("http.cache.hit", telemetrytypes.FieldDataTypeBool, evo)
sql := build(t, key, attrWindowAfter, qbtypes.FilterOperatorNotEqual, true)
assert.Contains(t, sql, "ifNull(if(dynamicType(attributes.`http.cache.hit`) = 'Bool', accurateCastOrNull(attributes.`http.cache.hit`, 'Bool'), NULL), false) <> ?")
})
t.Run("equal number after -> not folded, exists guard excludes absent", func(t *testing.T) {
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
sql := build(t, key, attrWindowAfter, qbtypes.FilterOperatorEqual, float64(0))
assert.Contains(t, sql, "(toFloat64(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL)) = ? AND attributes.`http.status_code` IS NOT NULL)")
assert.NotContains(t, sql, "ifNull")
})
t.Run("not in number after -> each operand folded to 0", func(t *testing.T) {
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
sql := build(t, key, attrWindowAfter, qbtypes.FilterOperatorNotIn, []any{float64(200), float64(404)})
assert.Contains(t, sql, "(ifNull(toFloat64(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL)), 0) <> ? AND ifNull(toFloat64(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL)), 0) <> ?)")
})
t.Run("not equal number straddle -> whole multiIf folded", func(t *testing.T) {
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
sql := build(t, key, attrWindowStraddle, qbtypes.FilterOperatorNotEqual, float64(200))
assert.Contains(t, sql, "ifNull(toFloat64(multiIf(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL) IS NOT NULL, if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL), mapContains(attributes_number, 'http.status_code'), attributes_number['http.status_code'], NULL)), 0) <> ?")
})
t.Run("not equal number before -> harmless fold over the map read", func(t *testing.T) {
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
sql := build(t, key, attrWindowBefore, qbtypes.FilterOperatorNotEqual, float64(200))
assert.Contains(t, sql, "ifNull(toFloat64(attributes_number['http.status_code']), 0) <> ?")
})
t.Run("not equal number without rollout -> byte-identical to today", func(t *testing.T) {
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, nil)
sql := build(t, key, attrWindowBefore, qbtypes.FilterOperatorNotEqual, float64(200))
assert.Contains(t, sql, "toFloat64(attributes_number['http.status_code']) <> ?")
assert.NotContains(t, sql, "ifNull")
})
t.Run("not equal string after -> '' default, never folded", func(t *testing.T) {
key := attrKey("user.id", telemetrytypes.FieldDataTypeString, evo)
sql := build(t, key, attrWindowAfter, qbtypes.FilterOperatorNotEqual, "admin")
assert.Contains(t, sql, "attributes.`user.id`::String <> ?")
assert.NotContains(t, sql, "ifNull")
})
}
// TestConditionForAttributeJSONStraddleAbsentKeyExclusion guards the straddle exists path: because
// the value reads absent-in-both-homes as NULL (multiIf else), a positive zero-value comparison and
// EXISTS/NOT EXISTS must still exclude a key absent from every home, rather than matching it.
func TestConditionForAttributeJSONStraddleAbsentKeyExclusion(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper(flaggertest.New(t))
cb := NewConditionBuilder(fm, flaggertest.New(t))
evo := MockAttributeEvolutionData(attrJSONRelease)
build := func(t *testing.T, key telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOperator, value any) string {
t.Helper()
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := cb.ConditionFor(ctx, valuer.UUID{}, attrWindowStraddle[0], attrWindowStraddle[1], &key,
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {&key}}, qbtypes.ConditionBuilderOptions{}, op, value, sb)
require.NoError(t, err)
sb.Where(conds...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return sql
}
guard := "multiIf(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL) IS NOT NULL, if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL), mapContains(attributes_number, 'http.status_code'), attributes_number['http.status_code'], NULL) IS NOT NULL"
t.Run("equal zero keeps the exists guard", func(t *testing.T) {
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
assert.Contains(t, build(t, key, qbtypes.FilterOperatorEqual, float64(0)), guard)
})
t.Run("exists is the raw multiIf, not always-true", func(t *testing.T) {
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
assert.Contains(t, build(t, key, qbtypes.FilterOperatorExists, nil), guard)
})
t.Run("not exists negates the raw multiIf", func(t *testing.T) {
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
sql := build(t, key, qbtypes.FilterOperatorNotExists, nil)
assert.Contains(t, sql, ", NULL) IS NULL")
})
}

View File

@@ -1,104 +0,0 @@
package tracestelemetryschema
import (
"context"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/huandu/go-sqlbuilder"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var (
promoJSONRelease = time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
promoPromoRelease = time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
)
// TestFieldForAttributePromotedEvolution proves promotion is just a third evolution column:
// evolution selection reads a single physical home per window — the legacy Map before the JSON
// rollout, `attributes` between the JSON rollout and the path's promotion, and
// `attributes_promoted` alone after promotion — fanning out only across an evolution boundary.
func TestFieldForAttributePromotedEvolution(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper(flaggertest.New(t))
evo := MockPromotedAttributeEvolutionData("span.operation", promoJSONRelease, promoPromoRelease)
win := func(from, to string) [2]uint64 {
a, _ := time.Parse("2006-01-02", from)
b, _ := time.Parse("2006-01-02", to)
return [2]uint64{uint64(a.UnixNano()), uint64(b.UnixNano())}
}
testCases := []struct {
name string
window [2]uint64
expected string
}{
{"before json rollout -> map", win("2024-01-01", "2024-06-01"), "attributes_string['span.operation']"},
{"between json and promotion -> attributes", win("2025-02-01", "2025-04-01"), "attributes.`span.operation`::String"},
{"after promotion -> promoted only", win("2025-07-01", "2025-08-01"), "attributes_promoted.`span.operation`::String"},
{"straddle promotion -> attributes_promoted + attributes", win("2025-04-01", "2025-08-01"), "multiIf(attributes_promoted.`span.operation` IS NOT NULL, attributes_promoted.`span.operation`::String, attributes.`span.operation` IS NOT NULL, attributes.`span.operation`::String, NULL)"},
{"straddle json rollout -> attributes + map", win("2024-06-01", "2025-03-01"), "multiIf(attributes.`span.operation` IS NOT NULL, attributes.`span.operation`::String, mapContains(attributes_string, 'span.operation'), attributes_string['span.operation'], NULL)"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{
Name: "span.operation",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: evo,
}
got, err := fm.FieldFor(ctx, valuer.UUID{}, tc.window[0], tc.window[1], &key)
require.NoError(t, err)
assert.Equal(t, tc.expected, got)
})
}
}
// TestConditionForAttributePromoted asserts a filter over a window fully after promotion reads
// only the promoted column, with existence testing the promoted raw path (index-eligible via
// attributes_promoted_paths_tokenbf) — not the attributes column.
func TestConditionForAttributePromoted(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper(flaggertest.New(t))
cb := NewConditionBuilder(fm, flaggertest.New(t))
evo := MockPromotedAttributeEvolutionData("span.operation", promoJSONRelease, promoPromoRelease)
afterPromo := [2]uint64{
uint64(time.Date(2025, 7, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
uint64(time.Date(2025, 8, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
}
key := telemetrytypes.TelemetryFieldKey{
Name: "span.operation",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: evo,
}
t.Run("equal reads promoted column only", func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := cb.ConditionFor(ctx, valuer.UUID{}, afterPromo[0], afterPromo[1], &key,
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {&key}}, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, "GET", sb)
require.NoError(t, err)
sb.Where(conds...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, "(attributes_promoted.`span.operation`::String = ? AND attributes_promoted.`span.operation` IS NOT NULL)")
assert.NotContains(t, sql, "attributes.`span.operation`")
})
t.Run("exists uses promoted raw path", func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := cb.ConditionFor(ctx, valuer.UUID{}, afterPromo[0], afterPromo[1], &key,
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {&key}}, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorExists, nil, sb)
require.NoError(t, err)
sb.Where(conds...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, "attributes_promoted.`span.operation` IS NOT NULL")
})
}

View File

@@ -154,34 +154,6 @@ func BuildCompleteFieldKeyMap(releaseTime time.Time) map[string][]*telemetrytype
return keysMap
}
// MockAttributeEvolutionData returns the attribute-context evolution timeline: only the JSON
// `attributes` migration released at releaseTime, field_name "__all__". The legacy map columns
// are the epoch-0 base and are not stored as evolution rows; SelectEvolutionsForColumns
// synthesizes the base entry for whichever typed map getColumn resolves the key to.
func MockAttributeEvolutionData(releaseTime time.Time) []*telemetrytypes.EvolutionEntry {
return []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalTraces,
ColumnName: "attributes",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldName: "__all__",
ReleaseTime: releaseTime,
},
}
}
// MockPromotedAttributeEvolutionData returns a promoted attribute's evolution timeline: the JSON
// `attributes` column at jsonRelease (field_name "__all__") and the per-path `attributes_promoted`
// column at promoteRelease (field_name = path). The legacy map is the synthesized epoch-0 base and
// is not stored as an evolution row.
func MockPromotedAttributeEvolutionData(path string, jsonRelease, promoteRelease time.Time) []*telemetrytypes.EvolutionEntry {
return []*telemetrytypes.EvolutionEntry{
{Signal: telemetrytypes.SignalTraces, ColumnName: "attributes", ColumnType: "JSON()", FieldContext: telemetrytypes.FieldContextAttribute, FieldName: "__all__", ReleaseTime: jsonRelease},
{Signal: telemetrytypes.SignalTraces, ColumnName: "attributes_promoted", ColumnType: "JSON()", FieldContext: telemetrytypes.FieldContextAttribute, FieldName: path, ReleaseTime: promoteRelease},
}
}
// MockEvolutionData returns the canonical resource-column evolution timeline used in tests:
// the legacy resources_string map at epoch 0 and the JSON resource column released at releaseTime.
func MockEvolutionData(releaseTime time.Time) []*telemetrytypes.EvolutionEntry {

View File

@@ -23,21 +23,8 @@ func SelectEvolutionsForColumns(columns []*schema.Column, evolutions []*telemetr
return columns, nil, nil
}
// Derive the base column from the candidate columns.
seen := make(map[string]struct{}, len(evolutions))
for _, e := range evolutions {
seen[e.ColumnName] = struct{}{}
}
// never modify evolutions in place, it may be cached and shared across queries.
sortedEvolutions := make([]*telemetrytypes.EvolutionEntry, 0, len(evolutions)+len(columns))
sortedEvolutions = append(sortedEvolutions, evolutions...)
for _, c := range columns {
if _, ok := seen[c.Name]; ok {
continue
}
sortedEvolutions = append(sortedEvolutions, &telemetrytypes.EvolutionEntry{ColumnName: c.Name, ReleaseTime: time.Unix(0, 0)})
}
sortedEvolutions := make([]*telemetrytypes.EvolutionEntry, len(evolutions))
copy(sortedEvolutions, evolutions)
// sort the evolutions by ReleaseTime ascending
sort.Slice(sortedEvolutions, func(i, j int) bool {

View File

@@ -396,15 +396,14 @@ func (m *MockMetadataStore) updateColumnEvolutionMetadataForKeys(_ context.Conte
FieldContext: selector.FieldContext,
FieldName: "__all__",
}
// column-wide (__all__) homes plus this field's own homes, appended not replaced,
// mirroring the real store
var evolutions []*telemetrytypes.EvolutionEntry
evolutions = append(evolutions, m.ColumnEvolutionMetadataMap[sel.QualifiedName()]...)
key := sel.QualifiedName()
if entries, exists := m.ColumnEvolutionMetadataMap[key]; exists {
result[key] = entries
}
sel.FieldName = metadataKeySelectors[i].FieldName
evolutions = append(evolutions, m.ColumnEvolutionMetadataMap[sel.QualifiedName()]...)
if len(evolutions) > 0 {
keysToUpdate[i].Evolutions = evolutions
result[sel.QualifiedName()] = evolutions
key = sel.QualifiedName()
if entries, exists := m.ColumnEvolutionMetadataMap[key]; exists {
result[key] = entries
}
}
return result

View File

@@ -1,76 +0,0 @@
package telemetrytypestest
import (
"context"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestEvolutionAppendsPerFieldToColumnWide covers the metadata enrichment: a key's column-wide
// (__all__) evolution homes and its per-field homes are appended, not replaced. A promoted
// attribute (whose attributes_promoted entry lives under its own field name) must therefore keep
// its Map and base-JSON homes for time ranges before it was promoted.
func TestEvolutionAppendsPerFieldToColumnWide(t *testing.T) {
jsonRel := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
promoRel := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
mk := func(col, field string, rt time.Time) *telemetrytypes.EvolutionEntry {
return &telemetrytypes.EvolutionEntry{
Signal: telemetrytypes.SignalTraces, ColumnName: col,
FieldContext: telemetrytypes.FieldContextAttribute, FieldName: field, ReleaseTime: rt,
}
}
columnNames := func(entries []*telemetrytypes.EvolutionEntry) []string {
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, e.ColumnName)
}
return out
}
// Only JSON columns are recorded as evolution rows; the legacy Map column is the
// synthesized epoch-0 base and is not stored here.
newStore := func() *MockMetadataStore {
store := NewMockMetadataStore()
store.ColumnEvolutionMetadataMap["traces:attribute:__all__"] = []*telemetrytypes.EvolutionEntry{
mk("attributes", "__all__", jsonRel),
}
return store
}
resolve := func(t *testing.T, store *MockMetadataStore, name string) *telemetrytypes.TelemetryFieldKey {
t.Helper()
key := &telemetrytypes.TelemetryFieldKey{
Name: name, Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString,
}
store.KeysMap[name] = []*telemetrytypes.TelemetryFieldKey{key}
selector := &telemetrytypes.FieldKeySelector{
Name: name, Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextAttribute, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
}
_, _, err := store.GetKeysMulti(context.Background(), valuer.UUID{}, []*telemetrytypes.FieldKeySelector{selector})
require.NoError(t, err)
return key
}
t.Run("promoted key keeps the column-wide attributes home and gains the promoted column", func(t *testing.T) {
store := newStore()
store.ColumnEvolutionMetadataMap["traces:attribute:span.operation"] = []*telemetrytypes.EvolutionEntry{
mk("attributes_promoted", "span.operation", promoRel),
}
key := resolve(t, store, "span.operation")
assert.ElementsMatch(t, []string{"attributes", "attributes_promoted"}, columnNames(key.Evolutions))
})
t.Run("non-promoted key gets only the column-wide home", func(t *testing.T) {
store := newStore()
key := resolve(t, store, "user.id")
assert.ElementsMatch(t, []string{"attributes"}, columnNames(key.Evolutions))
})
}

View File

@@ -114,6 +114,6 @@ def pytest_addoption(parser: pytest.Parser):
parser.addoption(
"--schema-migrator-version",
action="store",
default="v0.144.9", # todo(nikhil): change to 0.144.10
default="v0.144.6",
help="schema migrator version",
)

View File

@@ -292,7 +292,6 @@ class Traces(ABC):
events: list[dict[str, Any]]
links: list[dict[str, Any]]
resource_json: dict[str, str]
attributes_json: dict[str, Any]
response_status_code: str
external_http_url: str
http_url: str
@@ -331,7 +330,6 @@ class Traces(ABC):
flags: np.uint32 = 0,
scope: dict[str, Any] = {},
resource_write_mode: Literal["legacy_only", "dual_write"] = "dual_write",
attribute_write_mode: Literal["legacy_only", "dual_write"] = "dual_write",
) -> None:
if timestamp is None:
timestamp = datetime.datetime.now()
@@ -512,11 +510,6 @@ class Traces(ABC):
)
)
# Spans before the attribute JSON-evolution time populate only the legacy
# attributes_{string,number,bool} maps; spans at or after it dual-write the
# native-typed `attributes` JSON column too.
self.attributes_json = {} if attribute_write_mode == "legacy_only" else dict(attributes)
# Process events and derive error events. self.events holds the parsed
# response shape; np_arr() encodes back to the DB format on insert.
self.events = []
@@ -696,7 +689,6 @@ class Traces(ABC):
self.is_remote,
self.resource_json,
self.scope_json,
self.attributes_json,
],
dtype=object,
)
@@ -868,7 +860,6 @@ def insert_traces_to_clickhouse(conn, traces: list[Traces]) -> None:
"is_remote",
"resource",
"scope",
"attributes",
],
data=[trace.np_arr() for trace in traces],
)
@@ -932,37 +923,6 @@ def insert_traces(
)
def insert_attribute_evolution_to_clickhouse(conn, signal: str, release_time: datetime.datetime) -> None:
"""Seed the `attributes` JSON column-evolution row for a signal at release_time. Unlike the
resource row (seeded by the migrator at install), the attribute JSON rollout is install-specific
and not migrator-seeded, so tests insert it to gate map-vs-JSON resolution across a window."""
conn.command(
"""
INSERT INTO signoz_metadata.distributed_column_evolution_metadata
(signal, column_name, column_type, field_context, field_name, version, release_time)
VALUES (%(signal)s, 'attributes', 'JSON()', 'attribute', '__all__', 1, %(release_time_ns)s)
""",
parameters={"signal": signal, "release_time_ns": int(release_time.timestamp() * 1e9)},
)
@pytest.fixture(name="seed_attribute_evolution", scope="function")
def seed_attribute_evolution(
clickhouse: types.TestContainerClickhouse,
) -> Generator[Callable[[str, datetime.datetime], None], Any]:
def _seed(signal: str, release_time: datetime.datetime) -> None:
insert_attribute_evolution_to_clickhouse(clickhouse.conn, signal, release_time)
yield _seed
cluster = clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
clickhouse.conn.query(
f"ALTER TABLE signoz_metadata.column_evolution_metadata ON CLUSTER '{cluster}' "
"DELETE WHERE column_name = 'attributes' AND field_context = 'attribute' AND field_name = '__all__' "
"SETTINGS mutations_sync = 1"
)
@pytest.fixture(name="insert_top_level_operations", scope="function")
def insert_top_level_operations(
clickhouse: types.TestContainerClickhouse,

View File

@@ -1,339 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import (
RequestType,
assert_grouped_series,
build_aggregation,
build_group_by_field,
build_traces_scalar_query,
index_series_by_label,
make_query_request,
)
from fixtures.traces import TraceIdGenerator, Traces
def test_traces_attributes_json_evolution(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
seed_attribute_evolution: Callable[[str, datetime], None],
) -> None:
"""`http.route` is a dotted key, so the `attributes` JSON column nests it under the path
http.route while the legacy attributes_string map keys it verbatim. Spans before the attribute
JSON-evolution time write only the map; spans at or after it dual-write the JSON column too. A
query window resolves the attribute to the map (before), the JSON nested path (after), or a
map+JSON multiIf (straddling), and must return identical rows across the boundary."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
evolution_time = datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=30)
seed_attribute_evolution("traces", evolution_time)
before_2 = evolution_time - timedelta(minutes=10)
before_1 = evolution_time - timedelta(minutes=5)
after_1 = evolution_time + timedelta(minutes=5)
after_2 = evolution_time + timedelta(minutes=10)
insert_traces(
[
Traces(
timestamp=before_2,
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="before 2",
attributes={"http.route": "/d"},
attribute_write_mode="legacy_only",
),
Traces(
timestamp=before_1,
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="before 1",
attributes={"http.route": "/c"},
attribute_write_mode="legacy_only",
),
Traces(
timestamp=after_1,
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="after 1",
attributes={"http.route": "/a", "http.retry.count": 5, "http.cache.hit": True},
attribute_write_mode="dual_write",
),
Traces(
timestamp=after_2,
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="after 2",
attributes={"http.route": "/b", "http.retry.count": 1, "http.cache.hit": False},
attribute_write_mode="dual_write",
),
]
)
# before window -> map-only resolution
response = make_query_request(
signoz,
token,
start_ms=int((before_2 - timedelta(minutes=1)).timestamp() * 1000),
end_ms=int((before_1 + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.TIME_SERIES,
queries=[
build_traces_scalar_query(
aggregations=[build_aggregation("count()")],
group_by=[build_group_by_field("http.route", field_context="attribute")],
)
],
)
assert response.status_code == HTTPStatus.OK
before_series = index_series_by_label(
response.json()["data"]["data"]["results"][0]["aggregations"][0]["series"], "http.route"
)
assert_grouped_series(
before_series,
expected_values_by_group={
"/d": {int(before_2.timestamp() * 1000): 1},
"/c": {int(before_1.timestamp() * 1000): 1},
},
)
# after window -> JSON-only resolution (nested path)
response = make_query_request(
signoz,
token,
start_ms=int((after_1 - timedelta(minutes=1)).timestamp() * 1000),
end_ms=int((after_2 + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.TIME_SERIES,
queries=[
build_traces_scalar_query(
aggregations=[build_aggregation("count()")],
group_by=[build_group_by_field("http.route", field_context="attribute")],
)
],
)
assert response.status_code == HTTPStatus.OK
after_series = index_series_by_label(
response.json()["data"]["data"]["results"][0]["aggregations"][0]["series"], "http.route"
)
assert_grouped_series(
after_series,
expected_values_by_group={
"/a": {int(after_1.timestamp() * 1000): 1},
"/b": {int(after_2.timestamp() * 1000): 1},
},
)
# straddling window -> map + JSON multiIf resolution
response = make_query_request(
signoz,
token,
start_ms=int(before_2.timestamp() * 1000),
end_ms=int((after_2 + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.TIME_SERIES,
queries=[
build_traces_scalar_query(
aggregations=[build_aggregation("count()")],
group_by=[build_group_by_field("http.route", field_context="attribute")],
)
],
)
assert response.status_code == HTTPStatus.OK
spanning_series = index_series_by_label(
response.json()["data"]["data"]["results"][0]["aggregations"][0]["series"], "http.route"
)
assert_grouped_series(
spanning_series,
expected_values_by_group={
"/d": {int(before_2.timestamp() * 1000): 1},
"/c": {int(before_1.timestamp() * 1000): 1},
"/a": {int(after_1.timestamp() * 1000): 1},
"/b": {int(after_2.timestamp() * 1000): 1},
},
)
def test_traces_attributes_json_typed_filters(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
seed_attribute_evolution: Callable[[str, datetime], None],
) -> None:
"""In the JSON-only window each dotted attribute reads through the nested path with its native
cast: string (::String), Int64 (toFloat64(...::Nullable(Float64))), Bool (::Nullable(Bool)),
and existence via the raw path. Filters must select the same rows the Map path would."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
evolution_time = datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=30)
seed_attribute_evolution("traces", evolution_time)
hit = evolution_time + timedelta(minutes=5)
miss = evolution_time + timedelta(minutes=6)
insert_traces(
[
Traces(
timestamp=hit,
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="hit",
attributes={"http.route": "/a", "http.retry.count": 5, "http.cache.hit": True},
attribute_write_mode="dual_write",
),
Traces(
timestamp=miss,
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="miss",
attributes={"http.route": "/b", "http.retry.count": 1, "http.cache.hit": False},
attribute_write_mode="dual_write",
),
]
)
start_ms = int((hit - timedelta(minutes=1)).timestamp() * 1000)
end_ms = int((miss + timedelta(minutes=1)).timestamp() * 1000)
for label, filter_expression, expected in [
("string_eq", "http.route = '/a'", {"/a"}),
("int_gt", "http.retry.count > 1", {"/a"}),
("bool_eq", "http.cache.hit = true", {"/a"}),
("exists", "http.cache.hit EXISTS", {"/a", "/b"}),
]:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type=RequestType.TIME_SERIES,
queries=[
build_traces_scalar_query(
aggregations=[build_aggregation("count()")],
group_by=[build_group_by_field("http.route", field_context="attribute")],
filter_expression=filter_expression,
)
],
)
assert response.status_code == HTTPStatus.OK, label
series = index_series_by_label(
response.json()["data"]["data"]["results"][0]["aggregations"][0]["series"], "http.route"
)
assert set(series.keys()) == expected, label
def test_traces_attributes_json_collision_and_map_parity(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
seed_attribute_evolution: Callable[[str, datetime], None],
) -> None:
"""A name stored under two types (`app.status` as 200 and 'teapot') is one JSON path with
per-row types. The Map-era behavior must hold on it: group-by reads each row as its stored
type, numeric comparisons self-guard wrong-typed rows out via NULL, negative operators keep
rows lacking a numeric value (Map defaulted them to 0), and EXISTS/NOT EXISTS see the key
across every stored type."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
evolution_time = datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=30)
seed_attribute_evolution("traces", evolution_time)
hit = evolution_time + timedelta(minutes=5)
insert_traces(
[
Traces(
timestamp=hit,
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="numeric",
attributes={"http.route": "/num", "app.status": 200},
attribute_write_mode="dual_write",
),
Traces(
timestamp=hit,
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="string",
attributes={"http.route": "/str", "app.status": "teapot"},
attribute_write_mode="dual_write",
),
Traces(
timestamp=hit,
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="float",
attributes={"http.route": "/float", "app.latency": 2.5},
attribute_write_mode="dual_write",
),
Traces(
timestamp=hit,
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="absent",
attributes={"http.route": "/absent"},
attribute_write_mode="dual_write",
),
]
)
start_ms = int((hit - timedelta(minutes=1)).timestamp() * 1000)
end_ms = int((hit + timedelta(minutes=1)).timestamp() * 1000)
# Group-by on the colliding name: each row reads as its stored type through one path.
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type=RequestType.TIME_SERIES,
queries=[
build_traces_scalar_query(
aggregations=[build_aggregation("count()")],
group_by=[build_group_by_field("app.status", field_context="attribute")],
filter_expression="app.status EXISTS",
)
],
)
assert response.status_code == HTTPStatus.OK, "collision group-by"
series = index_series_by_label(
response.json()["data"]["data"]["results"][0]["aggregations"][0]["series"], "app.status"
)
assert set(series.keys()) == {"200", "teapot"}, "collision group-by"
# Filters against the colliding/numeric names, grouped by route.
for label, filter_expression, expected in [
# numeric comparison: wrong-typed and absent rows self-guard out via NULL
("num_eq", "app.status = 200", {"/num"}),
# = 0 must NOT match absent or string-stored rows (Map: numeric map lacks them)
("num_eq_zero", "app.status = 0", set()),
# negative operator: rows without a numeric value read as the Map default 0 and are kept
("num_ne", "app.status != 500", {"/num", "/str", "/float", "/absent"}),
# cross-numeric: a float-stored value answers a numeric comparison
("float_gt", "app.latency > 2", {"/float"}),
# EXISTS sees the key across every stored type; NOT EXISTS means absent in all of them
("exists_all_types", "app.status EXISTS", {"/num", "/str"}),
("not_exists_all_types", "app.status NOT EXISTS", {"/float", "/absent"}),
]:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type=RequestType.TIME_SERIES,
queries=[
build_traces_scalar_query(
aggregations=[build_aggregation("count()")],
group_by=[build_group_by_field("http.route", field_context="attribute")],
filter_expression=filter_expression,
)
],
)
assert response.status_code == HTTPStatus.OK, label
series = index_series_by_label(
response.json()["data"]["data"]["results"][0]["aggregations"][0]["series"], "http.route"
)
assert set(series.keys()) == expected, label