From 27acdbd9709e67976522b6d6915a5aec0eff2c89 Mon Sep 17 00:00:00 2001 From: Naman Verma Date: Tue, 1 Sep 2026 11:26:43 +0000 Subject: [PATCH 1/4] fix: ensure that alerts created as disabled don't actually fire (#12743) #### Description Patch and Edit Rule APIs would call syncRuleStateWithTask instead of adding the task blindly, which is what the create API was doing. This PR fixes the incorrect call in the create API #### Issues closed by this PR Closes https://github.com/SigNoz/pulse-pod/issues/312 --- pkg/query-service/rules/manager.go | 4 +- tests/fixtures/alerts.py | 10 ++ .../disabled_rule/alert_data.jsonl | 12 ++ .../test_scenarios/disabled_rule/rule.json | 59 ++++++++++ .../tests/alerts/05_disabled_rule.py | 109 ++++++++++++++++++ 5 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 tests/integration/testdata/alerts/test_scenarios/disabled_rule/alert_data.jsonl create mode 100644 tests/integration/testdata/alerts/test_scenarios/disabled_rule/rule.json create mode 100644 tests/integration/tests/alerts/05_disabled_rule.py diff --git a/pkg/query-service/rules/manager.go b/pkg/query-service/rules/manager.go index 402148e7195..4ab5a34c961 100644 --- a/pkg/query-service/rules/manager.go +++ b/pkg/query-service/rules/manager.go @@ -544,7 +544,7 @@ func (m *Manager) deleteTask(taskName string) { } // CreateRule stores rule def into db and also -// starts an executor for the rule +// starts an executor for the rule, unless the rule is disabled func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error) { claims, err := authtypes.ClaimsFromContext(ctx) if err != nil { @@ -611,7 +611,7 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge } taskName := prepareTaskName(id.StringValue()) - if err = m.addTask(ctx, orgID, &parsedRule, taskName); err != nil { + if err = m.syncRuleStateWithTask(ctx, orgID, taskName, &parsedRule); err != nil { return err } diff --git a/tests/fixtures/alerts.py b/tests/fixtures/alerts.py index 5a38188e3e4..abe9b783888 100644 --- a/tests/fixtures/alerts.py +++ b/tests/fixtures/alerts.py @@ -143,6 +143,16 @@ def wait_for_firing_timeline_entry(signoz: types.SigNoz, token: str, rule_id: st raise AssertionError(f"No firing entry recorded in rule state history within {wait_seconds}s, items: {items}") +def get_rule(signoz: types.SigNoz, token: str, rule_id: str) -> dict: + response = requests.get( + signoz.self.host_configs["8080"].get(f"/api/v2/rules/{rule_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.OK, f"Failed to get rule, api returned {response.status_code} with response: {response.text}" + return response.json()["data"] + + def get_rule_history_top_contributors(signoz: types.SigNoz, token: str, rule_id: str, start_ms: int, end_ms: int) -> list[dict]: response = requests.get( signoz.self.host_configs["8080"].get(f"/api/v2/rules/{rule_id}/history/top_contributors"), diff --git a/tests/integration/testdata/alerts/test_scenarios/disabled_rule/alert_data.jsonl b/tests/integration/testdata/alerts/test_scenarios/disabled_rule/alert_data.jsonl new file mode 100644 index 00000000000..6a3ff353c1f --- /dev/null +++ b/tests/integration/testdata/alerts/test_scenarios/disabled_rule/alert_data.jsonl @@ -0,0 +1,12 @@ +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:01:00+00:00","value":1,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:02:00+00:00","value":2,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:03:00+00:00","value":4,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:04:00+00:00","value":4,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:05:00+00:00","value":15,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:06:00+00:00","value":10,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:07:00+00:00","value":36,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:08:00+00:00","value":25,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:09:00+00:00","value":37,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:10:00+00:00","value":35,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:11:00+00:00","value":39,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:12:00+00:00","value":25,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} diff --git a/tests/integration/testdata/alerts/test_scenarios/disabled_rule/rule.json b/tests/integration/testdata/alerts/test_scenarios/disabled_rule/rule.json new file mode 100644 index 00000000000..59aaebd4140 --- /dev/null +++ b/tests/integration/testdata/alerts/test_scenarios/disabled_rule/rule.json @@ -0,0 +1,59 @@ +{ + "alert": "disabled_rule", + "ruleType": "threshold_rule", + "alertType": "METRIC_BASED_ALERT", + "disabled": true, + "condition": { + "thresholds": { + "kind": "basic", + "spec": [ + { + "name": "critical", + "target": 10, + "matchType": "at_least_once", + "op": "above", + "channels": [ + "test channel" + ] + } + ] + }, + "compositeQuery": { + "queryType": "clickhouse_sql", + "panelType": "graph", + "queries": [ + { + "type": "clickhouse_sql", + "spec": { + "name": "A", + "query": "WITH __temporal_aggregation_cte AS (\n SELECT \n fingerprint, \n toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, \n avg(value) AS per_series_value \n FROM signoz_metrics.distributed_samples_v4 AS points \n INNER JOIN (\n SELECT fingerprint \n FROM signoz_metrics.time_series_v4 \n WHERE metric_name IN ('request_total_disabled_rule') \n AND LOWER(temporality) LIKE LOWER('cumulative') \n GROUP BY fingerprint\n ) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint \n WHERE metric_name IN ('request_total_disabled_rule') \n AND unix_milli >= $start_timestamp_ms \n AND unix_milli < $end_timestamp_ms \n GROUP BY fingerprint, ts \n ORDER BY fingerprint, ts\n), \n__spatial_aggregation_cte AS (\n SELECT \n ts, \n sum(per_series_value) AS value \n FROM __temporal_aggregation_cte \n WHERE isNaN(per_series_value) = 0 \n GROUP BY ts\n) \nSELECT * FROM __spatial_aggregation_cte \nORDER BY ts" + } + } + ] + }, + "selectedQueryName": "A" + }, + "evaluation": { + "kind": "rolling", + "spec": { + "evalWindow": "5m0s", + "frequency": "15s" + } + }, + "labels": {}, + "annotations": { + "description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})", + "summary": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})" + }, + "notificationSettings": { + "groupBy": [], + "usePolicy": false, + "renotify": { + "enabled": false, + "interval": "30m", + "alertStates": [] + } + }, + "version": "v5", + "schemaVersion": "v2alpha1" +} diff --git a/tests/integration/tests/alerts/05_disabled_rule.py b/tests/integration/tests/alerts/05_disabled_rule.py new file mode 100644 index 00000000000..4a293730bd2 --- /dev/null +++ b/tests/integration/tests/alerts/05_disabled_rule.py @@ -0,0 +1,109 @@ +import json +import time +import uuid +from collections.abc import Callable +from datetime import UTC, datetime, timedelta + +from wiremock.client import HttpMethods, Mapping, MappingRequest, MappingResponse + +from fixtures import types +from fixtures.alerts import ( + collect_webhook_firing_alerts, + get_rule, + update_rule_channel_name, +) +from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD +from fixtures.fs import get_testdata_file_path +from fixtures.logger import setup_logger + +logger = setup_logger(__name__) + +# The rule evaluates every 15s and the alert data is set up to fire on the +# first evaluation, so a buggy evaluator would transition the rule and fire +# well within this window. +OBSERVATION_WINDOW_SECONDS = 35 + + +def test_disabled_rule_does_not_evaluate_or_notify( + signoz: types.SigNoz, + # Notification channel related fixtures + notification_channel: types.TestContainerDocker, + make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None], + create_webhook_notification_channel: Callable[[str, str, dict, bool], str], + # Alert rule related fixtures + create_alert_rule: Callable[[dict], str], + # Alert data insertion related fixtures + insert_alert_data: Callable[[list[types.AlertData], datetime], None], + get_token: Callable[[str, str], str], +): + """ + A rule created with disabled: true must not be evaluated: its state must + stay "disabled" and it must not send any notification, even though the + inserted data would fire the rule if it were evaluated. The companion + scenario threshold_above_at_least_once in 02_basic_alert_conditions.py + uses the same data shape and fires when the rule is enabled. + """ + token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + + # Prepare notification channel name and webhook endpoint + notification_channel_name = str(uuid.uuid4()) + webhook_endpoint_path = f"/alert/{notification_channel_name}" + notification_url = notification_channel.container_configs["8080"].get(webhook_endpoint_path) + + # register the mock endpoint in notification channel + make_http_mocks( + notification_channel, + [ + Mapping( + request=MappingRequest( + method=HttpMethods.POST, + url=webhook_endpoint_path, + ), + response=MappingResponse( + status=200, + json_body={}, + ), + persistent=False, + ) + ], + ) + + # Create an alert channel using the given route + create_webhook_notification_channel( + channel_name=notification_channel_name, + webhook_url=notification_url, + http_config={}, + send_resolved=False, + ) + + # Insert alert data that would fire the rule if it were evaluated + insert_alert_data( + [types.AlertData(type="metrics", data_path="alerts/test_scenarios/disabled_rule/alert_data.jsonl")], + base_time=datetime.now(tz=UTC) - timedelta(minutes=5), + ) + + # Create the disabled alert rule + rule_path = get_testdata_file_path("alerts/test_scenarios/disabled_rule/rule.json") + with open(rule_path, encoding="utf-8") as f: + rule_data = json.loads(f.read()) + update_rule_channel_name(rule_data, notification_channel_name) + rule_id = create_alert_rule(rule_data) + logger.info( + "disabled rule created with id: %s", + {"rule_id": rule_id, "rule_name": rule_data["alert"]}, + ) + + # The rule must stay disabled and must not fire for the whole observation + # window; poll to give a buggy evaluator several chances to run. + deadline = time.time() + OBSERVATION_WINDOW_SECONDS + while time.time() < deadline: + rule = get_rule(signoz, token, rule_id) + assert rule["state"] == "disabled", f"disabled rule transitioned to unexpected state: {rule['state']}" + assert rule["disabled"] is True, "disabled rule was unexpectedly re-enabled" + + firing_alerts = collect_webhook_firing_alerts(notification_channel, notification_channel_name) + assert len(firing_alerts) == 0, f"disabled rule fired alerts: {[alert.labels for alert in firing_alerts]}" + + time.sleep(2) + + logger.info("disabled rule stayed disabled and sent no notifications, as expected") From 160a1b018cd9cd7396ff8b6906be158ed16cfb0b Mon Sep 17 00:00:00 2001 From: Nikhil Mantri Date: Tue, 1 Sep 2026 13:03:48 +0000 Subject: [PATCH 2/4] feat(alert-channel-integrations): jira + jsm ops channel backend (#12478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### Description Adds two Atlassian alert channels. Backend only — frontend is #12488; channels are created via the API. **Jira issues — `jira_configs`** - A firing alert creates a Jira Cloud issue; when the alert resolves, the issue is transitioned to done. A re-fire within 3 days reopens the same issue instead of creating a new one. 3 days is default but can be edited via frontend form. - The issue body is rich **Atlassian Document Format (ADF)**: a status panel, the rendered alert description, and deep-links back to SigNoz. - Re-fires keep the issue in sync (summary and description are refreshed), and every notification after the first — re-fire, resolve, reopen — also posts a **comment** carrying the same rich ADF snapshot, so the issue holds a full lifecycle timeline. - Per-rule custom notification templates (title/body) are honored, same as every other channel; multi-alert custom bodies render as divider-separated sections. - Auth is Atlassian email + API token; Atlassian **service accounts** also work (routed via the `api.atlassian.com` gateway automatically — the cloud id is resolved server-side and client-supplied values are ignored). Jira Cloud only. **JSM Ops alerts — `jsmops_configs`** - A firing alert opens a JSM Operations alert (the ex-Opsgenie alert product); resolve **closes** it. A fire after close opens a fresh alert — there is no reopen window. - Re-fires dedupe into the same alert and increment its count. The alert description keeps the first-fire snapshot; the value-over-time story lives in the notes. - Every fire and the resolve appends a **note** to the alert. JSM Ops notes support **plain text only** (they render neither HTML nor markdown), so notes use a new plain-text renderer with links flattened to `text (url)`. - The alert description supports JSM's **HTML subset**, rendered from the same markdown templates. - Auth is the JSM integration API key. No region/site config needed. **Also in this PR** - Unit tests for both config types, both notifiers, and the new ADF + plain-text renderers. - OpenAPI spec regenerated (adds `jsmops_configs`). #### Issues closed by this PR Closes SigNoz/pulse-pod#168 · Discussion: SigNoz/pulse-pod#169 #### Screenshots / Screen Recordings Jira alert issue: Screenshot 2026-08-18 at 12 49
31 PM Jira issue comments as a timeline: Screenshot 2026-08-18 at 12 50
32 PM JSM Ops alerts page look: Screenshot 2026-08-18 at 12 52
29 PM JSM Ops alerts main body + comment timeline ( comments only support plain text today ) : Screenshot 2026-08-18 at 12 53
10 PM #### Additional Information Notes for reviewers: - Jira shadows upstream Alertmanager's `jira_configs` so our notifier handles it instead of upstream's; this needs a small dedupe in `PostableChannel.JSONSchema()` and leaves every other channel type untouched. - JSM Ops reuses the existing Opsgenie notifier; all new behaviour sits behind a single `advancedFeatures` flag, so plain Opsgenie is unchanged when it's off. - `send_resolved` defaults off for both channels, so resolve-time behaviour (Jira transition, JSM close + resolved note) needs it set on the channel; the frontend will send it on by default. - Notes are best-effort: a permanently-failed note (e.g. the first-fire note racing JSM's asynchronous alert create) is dropped with a warning instead of failing the whole notification. Nothing is lost — that first datapoint is already in the alert body; retryable failures (429) still retry. --------- Co-authored-by: Naman Verma --- docs/api/openapi.yml | 69 ++- .../api/generated/services/sigNoz.schemas.ts | 223 +++++-- .../alertmanagernotify/jira/jira.go | 557 ++++++++++++++++++ .../alertmanagernotify/jira/jira_test.go | 489 +++++++++++++++ .../alertmanagernotify/jsmops/jsmops.go | 61 ++ .../alertmanagernotify/jsmops/jsmops_test.go | 41 ++ .../alertmanagernotify/opsgenie/opsgenie.go | 255 +++++++- .../opsgenie/opsgenie_test.go | 221 ++++++- .../alertmanagernotify/receiver.go | 16 +- pkg/templating/markdownrenderer/adf/adf.go | 173 ++++++ .../markdownrenderer/adf/adf_test.go | 85 +++ .../markdownrenderer/markdownrenderer.go | 14 + .../markdownrenderer/plaintext/plaintext.go | 301 ++++++++++ .../plaintext/plaintext_test.go | 55 ++ pkg/types/alertmanagertypes/channel.go | 9 +- pkg/types/alertmanagertypes/config.go | 20 +- pkg/types/alertmanagertypes/jira.go | 119 ++++ pkg/types/alertmanagertypes/jira_test.go | 119 ++++ pkg/types/alertmanagertypes/jsmops.go | 75 +++ pkg/types/alertmanagertypes/jsmops_test.go | 70 +++ pkg/types/alertmanagertypes/receiver.go | 21 + 21 files changed, 2889 insertions(+), 104 deletions(-) create mode 100644 pkg/alertmanager/alertmanagernotify/jira/jira.go create mode 100644 pkg/alertmanager/alertmanagernotify/jira/jira_test.go create mode 100644 pkg/alertmanager/alertmanagernotify/jsmops/jsmops.go create mode 100644 pkg/alertmanager/alertmanagernotify/jsmops/jsmops_test.go create mode 100644 pkg/templating/markdownrenderer/adf/adf.go create mode 100644 pkg/templating/markdownrenderer/adf/adf_test.go create mode 100644 pkg/templating/markdownrenderer/plaintext/plaintext.go create mode 100644 pkg/templating/markdownrenderer/plaintext/plaintext_test.go create mode 100644 pkg/types/alertmanagertypes/jira.go create mode 100644 pkg/types/alertmanagertypes/jira_test.go create mode 100644 pkg/types/alertmanagertypes/jsmops.go create mode 100644 pkg/types/alertmanagertypes/jsmops_test.go diff --git a/docs/api/openapi.yml b/docs/api/openapi.yml index a7b713179ce..973f40ae10d 100644 --- a/docs/api/openapi.yml +++ b/docs/api/openapi.yml @@ -109,6 +109,57 @@ components: webhook_url: $ref: '#/components/schemas/ConfigSecretURL' type: object + AlertmanagertypesJSMOpsReceiverConfig: + properties: + api_key: + type: string + description: + type: string + http_config: + $ref: '#/components/schemas/ConfigHTTPClientConfig' + message: + type: string + priority: + type: string + send_resolved: + type: boolean + tags: + type: string + type: object + AlertmanagertypesJiraReceiverConfig: + properties: + custom_fields: + additionalProperties: {} + type: object + description: + type: string + http_config: + $ref: '#/components/schemas/ConfigHTTPClientConfig' + issue_type: + type: string + labels: + items: + type: string + type: array + priority: + type: string + project: + type: string + reopen_duration: + $ref: '#/components/schemas/ModelDuration' + reopen_transition: + type: string + resolve_transition: + type: string + send_resolved: + type: boolean + site: + type: string + summary: + type: string + wont_fix_resolution: + type: string + type: object AlertmanagertypesMaintenanceKind: enum: - fixed @@ -162,6 +213,10 @@ components: oneOf: - required: - googlechat_configs + - required: + - jira_configs + - required: + - jsmops_configs - required: - discord_configs - required: @@ -192,8 +247,6 @@ components: - msteams_configs - required: - msteamsv2_configs - - required: - - jira_configs - required: - rocketchat_configs - required: @@ -217,7 +270,11 @@ components: type: array jira_configs: items: - $ref: '#/components/schemas/ConfigJiraConfig' + $ref: '#/components/schemas/AlertmanagertypesJiraReceiverConfig' + type: array + jsmops_configs: + items: + $ref: '#/components/schemas/AlertmanagertypesJSMOpsReceiverConfig' type: array mattermost_configs: items: @@ -344,7 +401,11 @@ components: type: array jira_configs: items: - $ref: '#/components/schemas/ConfigJiraConfig' + $ref: '#/components/schemas/AlertmanagertypesJiraReceiverConfig' + type: array + jsmops_configs: + items: + $ref: '#/components/schemas/AlertmanagertypesJSMOpsReceiverConfig' type: array mattermost_configs: items: diff --git a/frontend/src/api/generated/services/sigNoz.schemas.ts b/frontend/src/api/generated/services/sigNoz.schemas.ts index 2a86ee28f69..0822eed1142 100644 --- a/frontend/src/api/generated/services/sigNoz.schemas.ts +++ b/frontend/src/api/generated/services/sigNoz.schemas.ts @@ -385,6 +385,93 @@ export interface AlertmanagertypesGoogleChatReceiverConfigDTO { webhook_url?: ConfigSecretURLDTO; } +export interface AlertmanagertypesJSMOpsReceiverConfigDTO { + /** + * @type string + */ + api_key?: string; + /** + * @type string + */ + description?: string; + http_config?: ConfigHTTPClientConfigDTO; + /** + * @type string + */ + message?: string; + /** + * @type string + */ + priority?: string; + /** + * @type boolean + */ + send_resolved?: boolean; + /** + * @type string + */ + tags?: string; +} + +export type AlertmanagertypesJiraReceiverConfigDTOCustomFields = { + [key: string]: unknown; +}; + +export type ModelDurationDTO = number; + +export interface AlertmanagertypesJiraReceiverConfigDTO { + /** + * @type object + */ + custom_fields?: AlertmanagertypesJiraReceiverConfigDTOCustomFields; + /** + * @type string + */ + description?: string; + http_config?: ConfigHTTPClientConfigDTO; + /** + * @type string + */ + issue_type?: string; + /** + * @type array + */ + labels?: string[]; + /** + * @type string + */ + priority?: string; + /** + * @type string + */ + project?: string; + reopen_duration?: ModelDurationDTO; + /** + * @type string + */ + reopen_transition?: string; + /** + * @type string + */ + resolve_transition?: string; + /** + * @type boolean + */ + send_resolved?: boolean; + /** + * @type string + */ + site?: string; + /** + * @type string + */ + summary?: string; + /** + * @type string + */ + wont_fix_resolution?: string; +} + export enum AlertmanagertypesMaintenanceKindDTO { fixed = 'fixed', recurring = 'recurring', @@ -631,69 +718,6 @@ export interface ConfigIncidentioConfigDTO { url_file?: string; } -export interface ConfigJiraFieldConfigDTO { - /** - * @type boolean,null - */ - enable_update?: boolean | null; - /** - * @type string - */ - template?: string; -} - -export type ModelDurationDTO = number; - -export type ConfigJiraConfigDTOCustomFields = { [key: string]: unknown }; - -export interface ConfigJiraConfigDTO { - /** - * @type string - */ - api_type?: string; - api_url?: ConfigURLType2DTO; - /** - * @type object - */ - custom_fields?: ConfigJiraConfigDTOCustomFields; - description?: ConfigJiraFieldConfigDTO; - http_config?: ConfigHTTPClientConfigDTO; - /** - * @type string - */ - issue_type?: string; - /** - * @type array - */ - labels?: string[]; - /** - * @type string - */ - priority?: string; - /** - * @type string - */ - project?: string; - reopen_duration?: ModelDurationDTO; - /** - * @type string - */ - reopen_transition?: string; - /** - * @type string - */ - resolve_transition?: string; - /** - * @type boolean - */ - send_resolved?: boolean; - summary?: ConfigJiraFieldConfigDTO; - /** - * @type string - */ - wont_fix_resolution?: string; -} - export interface ConfigMattermostFieldDTO { /** * @type boolean,null @@ -1652,7 +1676,11 @@ export type AlertmanagertypesPostableChannelDTO = unknown & { /** * @type array */ - jira_configs?: ConfigJiraConfigDTO[]; + jira_configs?: AlertmanagertypesJiraReceiverConfigDTO[]; + /** + * @type array + */ + jsmops_configs?: AlertmanagertypesJSMOpsReceiverConfigDTO[]; /** * @type array */ @@ -1779,7 +1807,11 @@ export interface AlertmanagertypesReceiverDTO { /** * @type array */ - jira_configs?: ConfigJiraConfigDTO[]; + jira_configs?: AlertmanagertypesJiraReceiverConfigDTO[]; + /** + * @type array + */ + jsmops_configs?: AlertmanagertypesJSMOpsReceiverConfigDTO[]; /** * @type array */ @@ -3268,6 +3300,67 @@ export interface CommonJSONRefDTO { $ref?: string; } +export type ConfigJiraConfigDTOCustomFields = { [key: string]: unknown }; + +export interface ConfigJiraFieldConfigDTO { + /** + * @type boolean,null + */ + enable_update?: boolean | null; + /** + * @type string + */ + template?: string; +} + +export interface ConfigJiraConfigDTO { + /** + * @type string + */ + api_type?: string; + api_url?: ConfigURLType2DTO; + /** + * @type object + */ + custom_fields?: ConfigJiraConfigDTOCustomFields; + description?: ConfigJiraFieldConfigDTO; + http_config?: ConfigHTTPClientConfigDTO; + /** + * @type string + */ + issue_type?: string; + /** + * @type array + */ + labels?: string[]; + /** + * @type string + */ + priority?: string; + /** + * @type string + */ + project?: string; + reopen_duration?: ModelDurationDTO; + /** + * @type string + */ + reopen_transition?: string; + /** + * @type string + */ + resolve_transition?: string; + /** + * @type boolean + */ + send_resolved?: boolean; + summary?: ConfigJiraFieldConfigDTO; + /** + * @type string + */ + wont_fix_resolution?: string; +} + export interface DashboardGridItemDTO { content?: CommonJSONRefDTO; /** diff --git a/pkg/alertmanager/alertmanagernotify/jira/jira.go b/pkg/alertmanager/alertmanagernotify/jira/jira.go new file mode 100644 index 00000000000..638dfacb4f7 --- /dev/null +++ b/pkg/alertmanager/alertmanagernotify/jira/jira.go @@ -0,0 +1,557 @@ +// Copyright (c) 2026 SigNoz, Inc. +// Copyright 2023 Prometheus Team +// SPDX-License-Identifier: Apache-2.0 + +package jira + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "sort" + "strings" + "time" + "unicode/utf16" + + "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate" + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/templating/markdownrenderer/adf" + "github.com/SigNoz/signoz/pkg/types/alertmanagertypes" + "github.com/SigNoz/signoz/pkg/types/ruletypes" + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/template" + "github.com/prometheus/alertmanager/types" +) + +const Integration = "jira" + +const ( + maxSummaryLenRunes = 255 + maxDescriptionLenRunes = 32767 +) + +// Notifier implements notify.Notifier for Jira. +type Notifier struct { + conf *alertmanagertypes.JiraReceiverConfig + logger *slog.Logger + client *http.Client + retrier *notify.Retrier + templater alertmanagertypes.Templater +} + +func New(conf *alertmanagertypes.JiraReceiverConfig, _ *template.Template, l *slog.Logger, templater alertmanagertypes.Templater) (*Notifier, error) { + if conf.HTTPConfig == nil { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira http_config is nil") + } + client, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration) + if err != nil { + return nil, err + } + return &Notifier{ + conf: conf, + logger: l, + client: client, + retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}}, + templater: templater, + }, nil +} + +func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { + key, err := notify.ExtractGroupKey(ctx) + if err != nil { + return false, err + } + groupID := key.Hash() + firing := types.Alerts(as...).HasFiring() + n.logger.DebugContext(ctx, "sending jira notification", slog.String("group_key", key.String()), slog.Bool("firing", firing)) + + customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(as) + result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{ + TitleTemplate: customTitle, + BodyTemplate: customBody, + DefaultTitleTemplate: n.conf.Summary, + DefaultBodyTemplate: n.conf.Description, + }, as) + if err != nil { + return false, err + } + summary := truncateRunes(result.Title, maxSummaryLenRunes) + + var parts []string + for _, body := range result.Body { + if body != "" { + parts = append(parts, body) + } + } + // custom body templates render per alert; join them under ADF rule dividers. + // The default body is a single combined part, so the join is a no-op there. + descText := truncateRunes(strings.Join(parts, "\n\n---\n\n"), maxDescriptionLenRunes) + + baseURL, retry, err := n.resolveAPIBaseURL(ctx) + if err != nil { + return retry, err + } + + existing, retry, err := n.searchIssue(ctx, baseURL, groupID, firing) + if err != nil { + return retry, err + } + + fields := n.buildFields(groupID, summary, descText, as, firing) + + // No existing issue: create for firing groups; never create for resolved-only. + if existing == nil { + if !firing { + return false, nil + } + return n.createIssue(ctx, baseURL, fields) + } + + // Existing issue: refresh it, then transition + comment based on the new state. + if retry, err := n.updateIssue(ctx, baseURL, existing, fields); err != nil { + return retry, err + } + + // Each state-change comment carries the same rich snapshot as the description + // (panel + details + deep-links), so the comment timeline mirrors the card + // Google Chat re-posts on every notification. + switch { + case firing && existing.isDone(): // re-fired after resolution → reopen + if retry, err := n.applyTransition(ctx, baseURL, existing.Key, false, n.conf.ReopenTransition); err != nil { + return retry, err + } + case !firing: // resolved (search returns only open issues, so this one is open) + if retry, err := n.applyTransition(ctx, baseURL, existing.Key, true, n.conf.ResolveTransition); err != nil { + return retry, err + } + } + // firing && !isDone (still firing) needs no transition. + return n.addComment(ctx, baseURL, existing.Key, fields.Description) +} + +func (n *Notifier) buildFields(groupID, summary, descText string, alerts []*types.Alert, firing bool) *issueFields { + f := &issueFields{ + Project: &idKey{Key: n.conf.Project}, + Issuetype: &idName{Name: n.conf.IssueType}, + Summary: summary, + Labels: n.labels(groupID), + Description: n.buildBoundedDescription(descText, alerts, firing), + } + if n.conf.Priority != "" { + f.Priority = &idName{Name: n.conf.Priority} + } + return f +} + +// buildBoundedDescription builds the ADF issue body and keeps it within Jira's +// description limit, which counts text characters plus per-node overhead — so a +// text-only markdown cap is not enough. Over-limit bodies are shrunk at the +// markdown level and rebuilt; the panel and deep-links are part of the measured +// document, so the result always fits. +func (n *Notifier) buildBoundedDescription(descText string, alerts []*types.Alert, firing bool) map[string]any { + doc := n.buildDescription(descText, alerts, firing) + for range 4 { + size := adfDocLen(doc) + if size <= maxDescriptionLenRunes { + return doc + } + runes := []rune(descText) + keep := len(runes) * maxDescriptionLenRunes / size * 9 / 10 + if keep >= len(runes) { + keep = len(runes) - 1 + } + if keep <= 0 { + break + } + descText = string(runes[:keep]) + "…" + doc = n.buildDescription(descText, alerts, firing) + } + if adfDocLen(doc) <= maxDescriptionLenRunes { + return doc + } + // still over after shrinking: keep just the panel and deep-links + return n.buildDescription("", alerts, firing) +} + +// adfDocLen approximates how Jira measures an ADF document against the 32767 +// limit: text length in UTF-16 code units, plus per-node overhead (block +// boundaries count like newlines), plus link targets. Deliberately counts on +// the high side so a passing measurement never 400s. +func adfDocLen(node any) int { + m, ok := node.(map[string]any) + if !ok { + return 0 + } + size := 2 + if text, ok := m["text"].(string); ok { + for _, r := range text { + size += utf16.RuneLen(r) + } + } + if marks, ok := m["marks"].([]any); ok { + for _, mark := range marks { + if mm, ok := mark.(map[string]any); ok { + if attrs, ok := mm["attrs"].(map[string]any); ok { + if href, ok := attrs["href"].(string); ok { + size += len(href) + } + } + } + } + } + if content, ok := m["content"].([]any); ok { + for _, child := range content { + size += adfDocLen(child) + } + } + return size +} + +// buildDescription assembles the ADF issue body: a firing/resolved status panel, +// the rendered markdown body, and SigNoz deep-links. +func (n *Notifier) buildDescription(descText string, alerts []*types.Alert, firing bool) map[string]any { + content := []any{statusPanel(firing)} + content = append(content, adf.Render(descText)...) + if links := deepLinks(alerts); links != nil { + content = append(content, links) + } + return map[string]any{"type": "doc", "version": 1, "content": content} +} + +func statusPanel(firing bool) map[string]any { + panelType, label := "success", "🟢 RESOLVED" + if firing { + panelType, label = "error", "🔴 FIRING" + } + return map[string]any{ + "type": "panel", + "attrs": map[string]any{"panelType": panelType}, + "content": []any{map[string]any{ + "type": "paragraph", + "content": []any{map[string]any{"type": "text", "text": label, "marks": []any{map[string]any{"type": "strong"}}}}, + }}, + } +} + +// deepLinks builds a paragraph of SigNoz links from the per-rule ruleSource label +// and the related-logs/traces annotations. Returns nil when none are present. +func deepLinks(alerts []*types.Alert) map[string]any { + if len(alerts) == 0 { + return nil + } + a := alerts[0] + var parts []any + add := func(label, url string) { + if url == "" { + return + } + if len(parts) > 0 { + parts = append(parts, map[string]any{"type": "text", "text": " · "}) + } + parts = append(parts, map[string]any{ + "type": "text", + "text": label, + "marks": []any{map[string]any{"type": "link", "attrs": map[string]any{"href": url}}}, + }) + } + add("Open in SigNoz", string(a.Labels[ruletypes.LabelRuleSource])) + add("View Related Logs", string(a.Annotations[ruletypes.AnnotationRelatedLogs])) + add("View Related Traces", string(a.Annotations[ruletypes.AnnotationRelatedTraces])) + if len(parts) == 0 { + return nil + } + return map[string]any{"type": "paragraph", "content": parts} +} + +func (n *Notifier) labels(groupID string) []string { + out := append([]string{}, n.conf.Labels...) + out = append(out, "signoz-alert", fmt.Sprintf("ALERT{%s}", groupID)) + sort.Strings(out) + return out +} + +func (n *Notifier) searchIssue(ctx context.Context, baseURL, groupID string, firing bool) (*issue, bool, error) { + var jql strings.Builder + if n.conf.WontFixResolution != "" { + // != alone also drops unresolved (EMPTY) issues, so keep those explicitly. + fmt.Fprintf(&jql, `(resolution is EMPTY or resolution != %q) and `, n.conf.WontFixResolution) + } + if reopenMin := int64(time.Duration(n.conf.ReopenDuration).Minutes()); firing && reopenMin > 0 { + fmt.Fprintf(&jql, `(resolutiondate is EMPTY OR resolutiondate >= -%dm) and `, reopenMin) + } else { + jql.WriteString(`statusCategory != Done and `) + } + fmt.Fprintf(&jql, `project=%q and labels=%q order by status ASC, resolutiondate DESC`, n.conf.Project, fmt.Sprintf("ALERT{%s}", groupID)) + + body, retry, err := n.callAPI(ctx, http.MethodPost, baseURL+"/search/jql", searchRequest{ + JQL: jql.String(), MaxResults: 2, Fields: []string{"status", "labels"}, + }) + if err != nil { + return nil, retry, err + } + var res searchResult + if err := json.Unmarshal(body, &res); err != nil { + return nil, false, err + } + if len(res.Issues) == 0 { + return nil, false, nil + } + // the JQL order is not category-aware, so prefer an open issue over a done + // one; all done falls back to the most recently resolved (resolutiondate DESC) + for i := range res.Issues { + if !res.Issues[i].isDone() { + return &res.Issues[i], false, nil + } + } + return &res.Issues[0], false, nil +} + +func (n *Notifier) createIssue(ctx context.Context, baseURL string, fields *issueFields) (bool, error) { + _, retry, err := n.callAPI(ctx, http.MethodPost, baseURL+"/issue", issue{Fields: fields}) + return retry, err +} + +func (n *Notifier) updateIssue(ctx context.Context, baseURL string, existing *issue, fields *issueFields) (bool, error) { + // project and issue type are set at creation and cannot be edited. + upd := *fields + upd.Project = nil + upd.Issuetype = nil + // Jira replaces the labels array wholesale, so union in the labels already + // on the issue to keep user-added ones. + if existing.Fields != nil { + upd.Labels = mergeLabels(existing.Fields.Labels, fields.Labels) + } + _, retry, err := n.callAPI(ctx, http.MethodPut, n.issueURL(baseURL, existing.Key, ""), issue{Fields: &upd}) + return retry, err +} + +func mergeLabels(existing, ours []string) []string { + seen := make(map[string]bool, len(existing)+len(ours)) + var merged []string + for _, label := range append(append([]string{}, existing...), ours...) { + if !seen[label] { + seen[label] = true + merged = append(merged, label) + } + } + sort.Strings(merged) + return merged +} + +// applyTransition moves the issue into (toDone) or out of (!toDone) the "done" +// status category, preferring the named override, else the first matching +// transition, else skipping without error when none is available. +func (n *Notifier) applyTransition(ctx context.Context, baseURL, key string, toDone bool, override string) (bool, error) { + transitions, retry, err := n.getTransitions(ctx, baseURL, key) + if err != nil { + return retry, err + } + id := selectTransition(transitions, toDone, override) + if id == "" { + n.logger.WarnContext(ctx, "jira: no matching transition, leaving issue as-is", slog.String("issue", key), slog.Bool("to_done", toDone)) + return false, nil + } + _, retry, err = n.callAPI(ctx, http.MethodPost, n.issueURL(baseURL, key, "transitions"), issue{Transition: &idName{ID: id}}) + return retry, err +} + +func (n *Notifier) getTransitions(ctx context.Context, baseURL, key string) ([]jiraTransition, bool, error) { + body, retry, err := n.callAPI(ctx, http.MethodGet, n.issueURL(baseURL, key, "transitions"), nil) + if err != nil { + return nil, retry, err + } + var tr transitionsResponse + if err := json.Unmarshal(body, &tr); err != nil { + return nil, false, err + } + return tr.Transitions, false, nil +} + +func (n *Notifier) addComment(ctx context.Context, baseURL, key string, body any) (bool, error) { + _, retry, err := n.callAPI(ctx, http.MethodPost, n.issueURL(baseURL, key, "comment"), comment{Body: body}) + return retry, err +} + +func (n *Notifier) issueURL(baseURL, key, sub string) string { + u := baseURL + "/issue/" + key + if sub != "" { + u += "/" + sub + } + return u +} + +func (n *Notifier) callAPI(ctx context.Context, method, url string, reqBody any) ([]byte, bool, error) { + var body io.Reader + if reqBody != nil { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(reqBody); err != nil { + return nil, false, err + } + body = &buf + } + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, false, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := n.client.Do(req) //nolint:bodyclose // notify.Drain closes the body + if err != nil { + return nil, true, notify.RedactURL(err) + } + defer notify.Drain(resp) + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, false, err + } + shouldRetry, err := n.retrier.Check(resp.StatusCode, bytes.NewReader(respBody)) + if err != nil { + return respBody, shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) + } + return respBody, false, nil +} + +// resolveAPIBaseURL resolves the service-account cloud id per notification (it +// is never persisted); personal API tokens use the site host directly. +func (n *Notifier) resolveAPIBaseURL(ctx context.Context) (string, bool, error) { + if !n.conf.IsServiceAccount() { + return n.conf.APIBaseURL(""), false, nil + } + cloudID, retry, err := n.resolveCloudID(ctx) + if err != nil { + return "", retry, err + } + return n.conf.APIBaseURL(cloudID), false, nil +} + +// resolveCloudID fetches the site's cloud id from its unauthenticated +// tenant_info endpoint; transport failures are retryable, bad responses are not. +func (n *Notifier) resolveCloudID(ctx context.Context) (string, bool, error) { + url := strings.TrimRight(n.conf.Site, "/") + "/_edge/tenant_info" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", false, err + } + req.Header.Set("Accept", "application/json") + + resp, err := n.client.Do(req) + if err != nil { + return "", true, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch jira cloud id") + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", true, err + } + if resp.StatusCode != http.StatusOK { + return "", false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to resolve jira cloud id from %s: status %d", url, resp.StatusCode) + } + + var out struct { + CloudID string `json:"cloudId"` + } + if err := json.Unmarshal(body, &out); err != nil { + return "", false, errors.WrapInternalf(err, errors.CodeInternal, "failed to parse jira tenant_info response") + } + if out.CloudID == "" { + return "", false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira tenant_info returned an empty cloud id for %s", n.conf.Site) + } + return out.CloudID, false, nil +} + +// selectTransition returns the id of the transition whose target status category +// matches toDone, preferring one named override when present. +func selectTransition(transitions []jiraTransition, toDone bool, override string) string { + if override != "" { + for _, t := range transitions { + if t.Name == override { + return t.ID + } + } + } + for _, t := range transitions { + if (t.To.StatusCategory.Key == "done") == toDone { + return t.ID + } + } + return "" +} + +// Jira API types. +type issue struct { + Key string `json:"key,omitempty"` + Fields *issueFields `json:"fields,omitempty"` + Transition *idName `json:"transition,omitempty"` +} + +type issueFields struct { + Project *idKey `json:"project,omitempty"` + Issuetype *idName `json:"issuetype,omitempty"` + Summary string `json:"summary,omitempty"` + Labels []string `json:"labels,omitempty"` + Priority *idName `json:"priority,omitempty"` + Description any `json:"description,omitempty"` + Status *issueStatus `json:"status,omitempty"` +} + +type idKey struct { + Key string `json:"key"` +} + +type idName struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + +type issueStatus struct { + StatusCategory struct { + Key string `json:"key"` + } `json:"statusCategory"` +} + +func (i *issue) isDone() bool { + return i.Fields != nil && i.Fields.Status != nil && i.Fields.Status.StatusCategory.Key == "done" +} + +type searchRequest struct { + JQL string `json:"jql"` + MaxResults int `json:"maxResults"` + Fields []string `json:"fields"` +} + +type searchResult struct { + Issues []issue `json:"issues"` +} + +type transitionsResponse struct { + Transitions []jiraTransition `json:"transitions"` +} + +type jiraTransition struct { + ID string `json:"id"` + Name string `json:"name"` + To struct { + StatusCategory struct { + Key string `json:"key"` + } `json:"statusCategory"` + } `json:"to"` +} + +type comment struct { + Body any `json:"body"` +} + +func truncateRunes(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) +} diff --git a/pkg/alertmanager/alertmanagernotify/jira/jira_test.go b/pkg/alertmanager/alertmanagernotify/jira/jira_test.go new file mode 100644 index 00000000000..042efbbdd34 --- /dev/null +++ b/pkg/alertmanager/alertmanagernotify/jira/jira_test.go @@ -0,0 +1,489 @@ +package jira + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate" + "github.com/SigNoz/signoz/pkg/types/alertmanagertypes" + "github.com/SigNoz/signoz/pkg/types/ruletypes" + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/notify/test" + "github.com/prometheus/alertmanager/types" + commoncfg "github.com/prometheus/common/config" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockReq struct { + method string + path string + body map[string]any +} + +type mockJira struct { + srv *httptest.Server + mu sync.Mutex + reqs []mockReq + searchIssues []issue + transitions []jiraTransition + createStatus int +} + +func newMockJira(t *testing.T) *mockJira { + t.Helper() + m := &mockJira{} + m.srv = httptest.NewServer(http.HandlerFunc(m.handle)) + t.Cleanup(m.srv.Close) + return m +} + +func (m *mockJira) handle(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + m.mu.Lock() + m.reqs = append(m.reqs, mockReq{r.Method, r.URL.Path, body}) + m.mu.Unlock() + + p := r.URL.Path + switch { + case strings.HasSuffix(p, "/search/jql"): + _ = json.NewEncoder(w).Encode(searchResult{Issues: m.searchIssues}) + case strings.HasSuffix(p, "/transitions") && r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode(transitionsResponse{Transitions: m.transitions}) + case strings.HasSuffix(p, "/transitions"): + w.WriteHeader(http.StatusNoContent) + case strings.HasSuffix(p, "/comment"): + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"1"}`)) + case strings.HasSuffix(p, "/issue") && r.Method == http.MethodPost: + st := m.createStatus + if st == 0 { + st = http.StatusCreated + } + w.WriteHeader(st) + _, _ = w.Write([]byte(`{"key":"KAN-1"}`)) + case r.Method == http.MethodPut: + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +func (m *mockJira) countPost(suffix string) int { + m.mu.Lock() + defer m.mu.Unlock() + c := 0 + for _, r := range m.reqs { + if r.method == http.MethodPost && strings.HasSuffix(r.path, suffix) { + c++ + } + } + return c +} + +func (m *mockJira) countPuts() int { + m.mu.Lock() + defer m.mu.Unlock() + c := 0 + for _, r := range m.reqs { + if r.method == http.MethodPut { + c++ + } + } + return c +} + +func newNotifier(t *testing.T, m *mockJira) *Notifier { + t.Helper() + tmpl := test.CreateTmpl(t) + n, err := New(&alertmanagertypes.JiraReceiverConfig{ + Site: m.srv.URL, + Project: "KAN", + IssueType: "Task", + Summary: alertmanagertypes.DefaultJiraSummaryTemplate, + Description: alertmanagertypes.DefaultJiraDescriptionTemplate, + HTTPConfig: &commoncfg.HTTPClientConfig{}, + ReopenDuration: model.Duration(3 * 24 * time.Hour), + }, tmpl, slog.New(slog.DiscardHandler), alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))) + require.NoError(t, err) + return n +} + +func alert(firing bool) *types.Alert { + a := &types.Alert{Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "HighCPU", "severity": "critical"}, + Annotations: model.LabelSet{"summary": "cpu high"}, + StartsAt: time.Now().Add(-time.Minute), + }} + if firing { + a.EndsAt = time.Now().Add(time.Hour) + } else { + a.EndsAt = time.Now().Add(-time.Minute) + } + return a +} + +func ctx() context.Context { + return notify.WithGroupKey(context.Background(), "test-jira") +} + +func doneIssue() issue { + i := issue{Key: "KAN-1", Fields: &issueFields{Status: &issueStatus{}}} + i.Fields.Status.StatusCategory.Key = "done" + return i +} + +func openIssue() issue { + i := issue{Key: "KAN-1", Fields: &issueFields{Status: &issueStatus{}}} + i.Fields.Status.StatusCategory.Key = "new" + return i +} + +func transition(id, name, category string) jiraTransition { + tr := jiraTransition{ID: id, Name: name} + tr.To.StatusCategory.Key = category + return tr +} + +func TestNotifyCreatesWhenNoExistingIssue(t *testing.T) { + m := newMockJira(t) + retry, err := newNotifier(t, m).Notify(ctx(), alert(true)) + require.NoError(t, err) + assert.False(t, retry) + assert.Equal(t, 1, m.countPost("/issue")) + assert.Equal(t, 0, m.countPost("/comment")) // no comment on create + assert.Equal(t, 0, m.countPuts()) // no update +} + +func TestNotifyResolvedOnlyWithNoIssueIsNoop(t *testing.T) { + m := newMockJira(t) + retry, err := newNotifier(t, m).Notify(ctx(), alert(false)) + require.NoError(t, err) + assert.False(t, retry) + assert.Equal(t, 1, m.countPost("/search/jql")) + assert.Equal(t, 0, m.countPost("/issue")) +} + +func TestNotifyStillFiringUpdatesAndComments(t *testing.T) { + m := newMockJira(t) + m.searchIssues = []issue{openIssue()} + retry, err := newNotifier(t, m).Notify(ctx(), alert(true)) + require.NoError(t, err) + assert.False(t, retry) + assert.Equal(t, 0, m.countPost("/issue")) // no create + assert.Equal(t, 1, m.countPuts()) // update + assert.Equal(t, 1, m.countPost("/comment")) + assert.Equal(t, 0, m.countPost("/transitions")) // still open, no transition + + // comment carries the full rich snapshot (panel + labeled body), not a one-liner. + cjs, err := json.Marshal(m.lastBody(t, "/comment")) + require.NoError(t, err) + assert.Contains(t, string(cjs), `"panel"`) + assert.Contains(t, string(cjs), "Summary:") +} + +func TestNotifyResolveTransitionsToDoneAndComments(t *testing.T) { + m := newMockJira(t) + m.searchIssues = []issue{openIssue()} + m.transitions = []jiraTransition{transition("11", "To Do", "new"), transition("41", "Done", "done")} + retry, err := newNotifier(t, m).Notify(ctx(), alert(false)) + require.NoError(t, err) + assert.False(t, retry) + assert.Equal(t, 1, m.countPuts()) // update + assert.Equal(t, 1, m.countPost("/transitions")) // resolve transition + assert.Equal(t, 1, m.countPost("/comment")) +} + +func TestNotifyReopensDoneIssue(t *testing.T) { + m := newMockJira(t) + m.searchIssues = []issue{doneIssue()} + m.transitions = []jiraTransition{transition("11", "To Do", "new"), transition("41", "Done", "done")} + retry, err := newNotifier(t, m).Notify(ctx(), alert(true)) + require.NoError(t, err) + assert.False(t, retry) + assert.Equal(t, 1, m.countPost("/transitions")) // reopen transition + assert.Equal(t, 1, m.countPost("/comment")) +} + +func TestNotifySafeSkipsWhenNoMatchingTransition(t *testing.T) { + m := newMockJira(t) + m.searchIssues = []issue{openIssue()} + m.transitions = []jiraTransition{transition("11", "To Do", "new")} // no done-category transition + retry, err := newNotifier(t, m).Notify(ctx(), alert(false)) + require.NoError(t, err) // must not error + assert.False(t, retry) + assert.Equal(t, 0, m.countPost("/transitions")) // skipped + assert.Equal(t, 1, m.countPost("/comment")) // comment still posted +} + +func TestNotifyPrefersOpenIssueOverRecentlyDone(t *testing.T) { + m := newMockJira(t) + open := openIssue() + open.Key = "KAN-2" + // the JQL order can put a recently-done issue first; the open one must win + m.searchIssues = []issue{doneIssue(), open} + + retry, err := newNotifier(t, m).Notify(ctx(), alert(true)) + require.NoError(t, err) + assert.False(t, retry) + assert.Equal(t, 0, m.countPost("/issue")) // no duplicate create + assert.Equal(t, 0, m.countPost("/transitions")) // open issue → no reopen + assert.Equal(t, 1, m.countPuts()) + assert.Equal(t, 1, m.countPost("/comment")) + + m.mu.Lock() + defer m.mu.Unlock() + for _, r := range m.reqs { + if r.method == http.MethodPut || strings.HasSuffix(r.path, "/comment") { + assert.Contains(t, r.path, "KAN-2") + } + } +} + +func TestNotifyRetriesOn429(t *testing.T) { + m := newMockJira(t) + m.createStatus = http.StatusTooManyRequests + retry, err := newNotifier(t, m).Notify(ctx(), alert(true)) + require.Error(t, err) + assert.True(t, retry) +} + +func (m *mockJira) lastBody(t *testing.T, suffix string) map[string]any { + t.Helper() + m.mu.Lock() + defer m.mu.Unlock() + for i := len(m.reqs) - 1; i >= 0; i-- { + if m.reqs[i].method == http.MethodPost && strings.HasSuffix(m.reqs[i].path, suffix) { + return m.reqs[i].body + } + } + t.Fatalf("no POST request to %s", suffix) + return nil +} + +func TestNotifyRichDescriptionPanelAndLinks(t *testing.T) { + m := newMockJira(t) + a := alert(true) + a.Labels[ruletypes.LabelRuleSource] = model.LabelValue("https://app.signoz.io/alerts?ruleId=1") + a.Annotations[ruletypes.AnnotationRelatedLogs] = model.LabelValue("https://app.signoz.io/logs") + + _, err := newNotifier(t, m).Notify(ctx(), a) + require.NoError(t, err) + + body := m.lastBody(t, "/issue") + js, err := json.Marshal(body) + require.NoError(t, err) + s := string(js) + assert.Contains(t, s, `"panel"`) // status panel present + assert.Contains(t, s, `"error"`) // firing → error panel + assert.Contains(t, s, "Open in SigNoz") // rule deep-link + assert.Contains(t, s, "https://app.signoz.io/alerts?ruleId=1") // rule url + assert.Contains(t, s, "View Related Logs") // related-logs deep-link + assert.Contains(t, s, "Summary:") // labeled body section + assert.Contains(t, s, "cpu high") // rendered annotation +} + +func TestNotifyCustomTemplateAnnotationsOverrideDefaults(t *testing.T) { + m := newMockJira(t) + a1 := alert(true) + a1.Labels["service"] = "payment" + a1.Labels["namespace"] = "ns-one" + a1.Annotations[ruletypes.AnnotationTitleTemplate] = "High throughput for $service" + a1.Annotations[ruletypes.AnnotationBodyTemplate] = "Firing in NS: $labels.namespace" + a2 := alert(true) + a2.Labels["service"] = "payment" + a2.Labels["namespace"] = "ns-two" + a2.Annotations[ruletypes.AnnotationTitleTemplate] = "High throughput for $service" + a2.Annotations[ruletypes.AnnotationBodyTemplate] = "Firing in NS: $labels.namespace" + + _, err := newNotifier(t, m).Notify(ctx(), a1, a2) + require.NoError(t, err) + + body := m.lastBody(t, "/issue") + fields, ok := body["fields"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "High throughput for payment", fields["summary"]) + + js, err := json.Marshal(fields["description"]) + require.NoError(t, err) + s := string(js) + assert.Contains(t, s, "Firing in NS: ns-one") + assert.Contains(t, s, "Firing in NS: ns-two") + // per-alert custom bodies are separated by an ADF rule divider + assert.Contains(t, s, `"rule"`) + assert.NotContains(t, s, "Summary:") // default body template not used +} + +// Jira replaces labels wholesale on PUT, so the update must union in the +// labels already on the issue or user-added ones get wiped. +func TestNotifyUpdatePreservesUserAddedLabels(t *testing.T) { + m := newMockJira(t) + existing := openIssue() + existing.Fields.Labels = []string{"user-added-label", "signoz-alert"} + m.searchIssues = []issue{existing} + + _, err := newNotifier(t, m).Notify(ctx(), alert(true)) + require.NoError(t, err) + + search := m.lastBody(t, "/search/jql") + assert.Contains(t, search["fields"], "labels") + + m.mu.Lock() + var putLabels []any + for _, r := range m.reqs { + if r.method == http.MethodPut { + putLabels, _ = r.body["fields"].(map[string]any)["labels"].([]any) + } + } + m.mu.Unlock() + assert.Contains(t, putLabels, "user-added-label") + assert.Contains(t, putLabels, "signoz-alert") + assert.Equal(t, 1, strings.Count(fmt.Sprint(putLabels), "signoz-alert")) // no duplicates + // the dedup label is re-asserted + found := false + for _, l := range putLabels { + if s, ok := l.(string); ok && strings.HasPrefix(s, "ALERT{") { + found = true + } + } + assert.True(t, found) +} + +func TestADFDocLen(t *testing.T) { + text := func(s string) map[string]any { return map[string]any{"type": "text", "text": s} } + para := func(children ...any) map[string]any { + return map[string]any{"type": "paragraph", "content": children} + } + cases := []struct { + name string + node any + want int + }{ + {"text node", text("hello"), 7}, // 5 utf16 + 2 overhead + {"emoji counts utf16", text("🔴"), 4}, // 2 utf16 units + 2 overhead + {"paragraph wraps text", para(text("hi")), 6}, // 2 + (2+2) + {"link href counted", map[string]any{"type": "text", "text": "a", "marks": []any{map[string]any{"type": "link", "attrs": map[string]any{"href": "https://x"}}}}, 12}, // 1 + 9 href + 2 + {"non-map is zero", "junk", 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, adfDocLen(c.node)) + }) + } +} + +// 30 fat custom bodies overflow Jira's description accounting (text + per-node +// overhead); the built doc must be shrunk under the limit, never rejected. +func TestNotifyDescriptionShrunkUnderJiraLimit(t *testing.T) { + m := newMockJira(t) + filler := strings.Repeat("This is a long runbook detail line used to inflate the alert body. ", 25) + alerts := make([]*types.Alert, 0, 30) + for i := range 30 { + a := alert(true) + a.Labels["service"] = model.LabelValue(strings.Repeat("s", 3) + string(rune('a'+i%26))) + a.Annotations[ruletypes.AnnotationTitleTemplate] = "overflow probe" + a.Annotations[ruletypes.AnnotationBodyTemplate] = model.LabelValue("**Alert in service** $labels.service\n\n" + filler) + alerts = append(alerts, a) + } + + _, err := newNotifier(t, m).Notify(ctx(), alerts...) + require.NoError(t, err) + + body := m.lastBody(t, "/issue") + fields, ok := body["fields"].(map[string]any) + require.True(t, ok) + desc := fields["description"] + assert.LessOrEqual(t, adfDocLen(desc), maxDescriptionLenRunes) + + js, err := json.Marshal(desc) + require.NoError(t, err) + assert.Contains(t, string(js), "FIRING") // status panel survives the shrink + assert.Contains(t, string(js), "…") // body ends with the shrink marker +} + +func TestFiringSearchJQLHasReopenWindow(t *testing.T) { + m := newMockJira(t) + _, err := newNotifier(t, m).Notify(ctx(), alert(true)) + require.NoError(t, err) + + body := m.lastBody(t, "/search/jql") + jql, ok := body["jql"].(string) + require.True(t, ok) + // newNotifier uses a 3d window → 4320 minutes. + assert.Contains(t, jql, "resolutiondate >= -4320m") +} + +func TestSelectTransition(t *testing.T) { + ts := []jiraTransition{ + transition("41", "Done", "done"), + transition("51", "Won't Do", "done"), + transition("11", "To Do", "new"), + } + assert.Equal(t, "41", selectTransition(ts, true, "")) // first done-category + assert.Equal(t, "51", selectTransition(ts, true, "Won't Do")) // named override + assert.Equal(t, "11", selectTransition(ts, false, "")) // first non-done + assert.Equal(t, "41", selectTransition(ts, true, "Nonexistent")) // bad override → fallback + assert.Equal(t, "", selectTransition([]jiraTransition{transition("11", "To Do", "new")}, true, "")) // none → skip +} + +func TestResolveCloudID(t *testing.T) { + cases := []struct { + name string + handler http.HandlerFunc + want string + wantErr bool + wantRetry bool + }{ + { + name: "success", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/_edge/tenant_info", r.URL.Path) + _, _ = w.Write([]byte(`{"cloudId":"abc-123"}`)) + }, + want: "abc-123", + }, + { + name: "non-200 is not retryable", + handler: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) }, + wantErr: true, + }, + { + name: "empty cloud id", + handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"cloudId":""}`)) }, + wantErr: true, + }, + { + name: "bad json", + handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`not json`)) }, + wantErr: true, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + srv := httptest.NewServer(c.handler) + defer srv.Close() + + n, err := New(&alertmanagertypes.JiraReceiverConfig{Site: srv.URL, HTTPConfig: &commoncfg.HTTPClientConfig{}}, nil, slog.New(slog.DiscardHandler), nil) + require.NoError(t, err) + + got, retry, err := n.resolveCloudID(context.Background()) + if c.wantErr { + assert.Error(t, err) + assert.Equal(t, c.wantRetry, retry) + return + } + require.NoError(t, err) + assert.Equal(t, c.want, got) + }) + } +} diff --git a/pkg/alertmanager/alertmanagernotify/jsmops/jsmops.go b/pkg/alertmanager/alertmanagernotify/jsmops/jsmops.go new file mode 100644 index 00000000000..2b39fa8edf4 --- /dev/null +++ b/pkg/alertmanager/alertmanagernotify/jsmops/jsmops.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 SigNoz, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package jsmops delivers Jira Service Management Ops alerts by reusing the +// Opsgenie notifier: JSM Ops is the ex-Opsgenie alert API, so we map the JSM +// config onto config.OpsGenieConfig with APIURL pinned to the JSM native +// integration-events gateway. +package jsmops + +import ( + "log/slog" + "net/url" + + "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie" + "github.com/SigNoz/signoz/pkg/types/alertmanagertypes" + "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/template" + commoncfg "github.com/prometheus/common/config" +) + +const ( + Integration = "jsmops" + source = "SigNoz" +) + +// New builds an Opsgenie notifier pointed at the JSM native endpoint. +// advancedFeatures enables the rich treatment: HTML body and a note timeline +// (per fire and on resolve). +func New(c *alertmanagertypes.JSMOpsReceiverConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, advancedFeatures bool) (*opsgenie.Notifier, error) { + conf, err := toOpsGenieConfig(c) + if err != nil { + return nil, err + } + return opsgenie.New(conf, t, l, templater, advancedFeatures) +} + +// toOpsGenieConfig maps the JSM config onto config.OpsGenieConfig with APIURL +// pinned to the JSM native gateway. +func toOpsGenieConfig(c *alertmanagertypes.JSMOpsReceiverConfig) (*config.OpsGenieConfig, error) { + apiURL, err := url.Parse(alertmanagertypes.JSMOpsAPIBaseURL) + if err != nil { + return nil, err + } + + httpConfig := c.HTTPConfig + if httpConfig == nil { + httpConfig = &commoncfg.HTTPClientConfig{} + } + + return &config.OpsGenieConfig{ + NotifierConfig: c.NotifierConfig, + HTTPConfig: httpConfig, + APIKey: c.APIKey, + APIURL: &config.URL{URL: apiURL}, + Message: c.Message, + Description: c.Description, + Priority: c.Priority, + Tags: c.Tags, + Source: source, + }, nil +} diff --git a/pkg/alertmanager/alertmanagernotify/jsmops/jsmops_test.go b/pkg/alertmanager/alertmanagernotify/jsmops/jsmops_test.go new file mode 100644 index 00000000000..757240ff193 --- /dev/null +++ b/pkg/alertmanager/alertmanagernotify/jsmops/jsmops_test.go @@ -0,0 +1,41 @@ +package jsmops + +import ( + "testing" + + "github.com/SigNoz/signoz/pkg/types/alertmanagertypes" + commoncfg "github.com/prometheus/common/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestToOpsGenieConfig(t *testing.T) { + c := &alertmanagertypes.JSMOpsReceiverConfig{ + APIKey: "key-123", + Message: "msg", + Description: "desc", + Priority: "P1", + Tags: "signoz", + HTTPConfig: &commoncfg.HTTPClientConfig{}, + } + + og, err := toOpsGenieConfig(c) + require.NoError(t, err) + + // Trailing slash is required: the Opsgenie notifier appends "v2/alerts..." + // with no separator, yielding /jsm/ops/integration/v2/alerts. + assert.Equal(t, "https://api.atlassian.com/jsm/ops/integration/", og.APIURL.String()) + assert.Equal(t, "key-123", string(og.APIKey)) + assert.Equal(t, "msg", og.Message) + assert.Equal(t, "desc", og.Description) + assert.Equal(t, "P1", og.Priority) + assert.Equal(t, "signoz", og.Tags) + assert.Equal(t, source, og.Source) + assert.Same(t, c.HTTPConfig, og.HTTPConfig) +} + +func TestToOpsGenieConfigNilHTTPConfig(t *testing.T) { + og, err := toOpsGenieConfig(&alertmanagertypes.JSMOpsReceiverConfig{APIKey: "k"}) + require.NoError(t, err) + assert.NotNil(t, og.HTTPConfig) +} diff --git a/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie.go b/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie.go index 96bfc34675f..7a2703896a5 100644 --- a/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie.go +++ b/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie.go @@ -14,6 +14,7 @@ import ( "net/http" "os" "strings" + "unicode/utf8" "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate" "github.com/SigNoz/signoz/pkg/errors" @@ -32,8 +33,13 @@ const ( Integration = "opsgenie" ) -// https://docs.opsgenie.com/docs/alert-api - 130 characters meaning runes. -const maxMessageLenRunes = 130 +// https://support.atlassian.com/opsgenie/docs/alert-fields/ - message 130, +// description 15000, note 25000 runes. +const ( + maxMessageLenRunes = 130 + maxDescriptionLenRunes = 15000 + maxNoteLenRunes = 25000 +) // Notifier implements a Notifier for OpsGenie notifications. type Notifier struct { @@ -43,21 +49,29 @@ type Notifier struct { client *http.Client retrier *notify.Retrier templater alertmanagertypes.Templater + // advancedFeatures bundles the JSM Ops enrichments: render the default body as + // HTML (markdown -> HTML), and post a note per fire and on resolve to build an + // immutable timeline. Off for plain OpsGenie. The alert-refresh-on-refire part + // rides on the upstream UpdateAlerts config flag, set alongside this. + advancedFeatures bool } -// New returns a new OpsGenie notifier. -func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { +// New returns a new OpsGenie notifier. advancedFeatures enables the JSM Ops +// enrichments (HTML default body + a note timeline per fire and on resolve); +// pass false for plain OpsGenie. +func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, advancedFeatures bool, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...) if err != nil { return nil, err } return &Notifier{ - conf: c, - tmpl: t, - logger: l, - client: client, - retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}}, - templater: templater, + conf: c, + tmpl: t, + logger: l, + client: client, + retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}}, + templater: templater, + advancedFeatures: advancedFeatures, }, nil } @@ -94,6 +108,30 @@ type opsGenieUpdateDescriptionMessage struct { Description string `json:"description,omitempty"` } +type opsGenieAddNoteMessage struct { + Note string `json:"note"` + Source string `json:"source"` +} + +// noteRequest builds a POST to the alert's notes endpoint (append-only timeline). +func (n *Notifier) noteRequest(ctx context.Context, alias, note, source string) (*http.Request, error) { + noteEndpointURL := n.conf.APIURL.Copy() + noteEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/notes", alias) + q := noteEndpointURL.Query() + q.Set("identifierType", "alias") + noteEndpointURL.RawQuery = q.Encode() + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(&opsGenieAddNoteMessage{Note: note, Source: source}); err != nil { + return nil, err + } + req, err := http.NewRequest("POST", noteEndpointURL.String(), &buf) + if err != nil { + return nil, err + } + return req.WithContext(ctx), nil +} + // Notify implements the Notifier interface. func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { requests, retry, err := n.createRequests(ctx, as...) @@ -110,12 +148,24 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body) notify.Drain(resp) if err != nil { + // notes are enrichment; a permanently-failed note (e.g. the first-fire + // note racing JSM's async alert create) must not fail the notification + if !shouldRetry && isNoteRequest(req) { + n.logger.WarnContext(ctx, "dropping failed note", slog.Int("status_code", resp.StatusCode), errors.Attr(err)) + continue + } return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) } } return true, nil } +// isNoteRequest reports whether req targets the notes endpoint, the only one +// built by noteRequest. +func isNoteRequest(req *http.Request) bool { + return strings.HasSuffix(req.URL.Path, "/notes") +} + // Like Split but filter out empty strings. func safeSplit(s, sep string) []string { a := strings.Split(strings.TrimSpace(s), sep) @@ -145,28 +195,13 @@ func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (s } var description string - if result.IsDefaultBody { + if result.IsDefaultBody && !n.advancedFeatures { description = strings.Join(result.Body, "\n") } else { - var b strings.Builder - first := true - for _, part := range result.Body { - if part == "" { - continue - } - rendered, renderErr := markdownrenderer.RenderHTML(part) - if renderErr != nil { - return "", "", renderErr - } - if !first { - b.WriteString("
") - } - b.WriteString("
") - b.WriteString(rendered) - b.WriteString("
") - first = false + description, err = buildHTMLDescription(result.Body, maxDescriptionLenRunes) + if err != nil { + return "", "", err } - description = b.String() } title, truncated := notify.TruncateInRunes(result.Title, maxMessageLenRunes) @@ -174,9 +209,141 @@ func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (s n.logger.WarnContext(ctx, "Truncated message", slog.Int("max_runes", maxMessageLenRunes)) } + // The API silently truncates over-limit descriptions, which would drop the + // trailing SigNoz link; cap here with an ellipsis instead. The HTML path is + // pre-fitted above, so this only ever cuts the plain-text default body. + description, descTruncated := notify.TruncateInRunes(description, maxDescriptionLenRunes) + if descTruncated { + n.logger.WarnContext(ctx, "Truncated description", slog.Int("max_runes", maxDescriptionLenRunes)) + } + return title, description, nil } +const ( + // room reserved for the "+N more" trailer appended when parts are dropped. + descriptionTrailerReserveRunes = 80 + // below this rendering budget a shrunk part carries no signal; drop it instead. + minShrinkBudgetRunes = 64 +) + +// buildHTMLDescription renders each markdown part to HTML (
-wrapped, +//
-joined) while keeping the total within budget runes. An over-budget part +// is shrunk at the markdown level and re-rendered so the HTML stays well-formed; +// fully dropped parts are summarized by a "+N more" trailer. +func buildHTMLDescription(parts []string, budget int) (string, error) { + rendering := make([]string, 0, len(parts)) + for _, part := range parts { + if part != "" { + rendering = append(rendering, part) + } + } + + budget -= descriptionTrailerReserveRunes + var b strings.Builder + used, included := 0, 0 + for _, part := range rendering { + rendered, err := markdownrenderer.RenderHTML(part) + if err != nil { + return "", err + } + overhead := len("
") + if included > 0 { + overhead += len("
") + } + if used+overhead+utf8.RuneCountInString(rendered) > budget { + rendered, err = shrinkMarkdownToFit(part, budget-used-overhead) + if err != nil { + return "", err + } + if rendered == "" { + break + } + } + if included > 0 { + b.WriteString("
") + } + b.WriteString("
") + b.WriteString(rendered) + b.WriteString("
") + used += overhead + utf8.RuneCountInString(rendered) + included++ + } + if dropped := len(rendering) - included; dropped > 0 { + fmt.Fprintf(&b, "
…and %d more alerts. Open in SigNoz for the full list.
", dropped) + } + return b.String(), nil +} + +// shrinkMarkdownToFit cuts markdown until its rendered HTML fits within budget +// runes, returning "" when the budget is too small to carry anything useful. +// Only the markdown is ever cut, never the rendered HTML, so goldmark always +// emits balanced markup. +func shrinkMarkdownToFit(md string, budget int) (string, error) { + if budget < minShrinkBudgetRunes { + return "", nil + } + for range 4 { + rendered, err := markdownrenderer.RenderHTML(md) + if err != nil { + return "", err + } + renderedLen := utf8.RuneCountInString(rendered) + if renderedLen <= budget { + return rendered, nil + } + runes := []rune(md) + keep := len(runes) * budget / renderedLen * 9 / 10 + if keep >= len(runes) { + keep = len(runes) - 1 + } + if keep < minShrinkBudgetRunes { + return "", nil + } + md = string(runes[:keep]) + "…" + } + return "", nil +} + +// prepareNote renders the same body template as plain text for a timeline note. +// JSM Ops notes render neither HTML nor markdown, so links flatten to +// "text (url)" and all markers are stripped. +func (n *Notifier) prepareNote(ctx context.Context, alerts []*types.Alert) (string, error) { + customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts) + result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{ + TitleTemplate: customTitle, + BodyTemplate: customBody, + DefaultTitleTemplate: n.conf.Message, + DefaultBodyTemplate: n.conf.Description, + }, alerts) + if err != nil { + return "", err + } + + var b strings.Builder + first := true + for _, part := range result.Body { + text, renderErr := markdownrenderer.RenderPlainText(part) + if renderErr != nil { + return "", renderErr + } + if text = strings.TrimSpace(text); text == "" { + continue + } + if !first { + b.WriteString("\n\n") + } + b.WriteString(text) + first = false + } + + note, truncated := notify.TruncateInRunes(b.String(), maxNoteLenRunes) + if truncated { + n.logger.WarnContext(ctx, "Truncated note", slog.Int("max_runes", maxNoteLenRunes)) + } + return note, nil +} + // Create requests for a list of alerts. func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*http.Request, bool, error) { key, err := notify.ExtractGroupKey(ctx) @@ -206,6 +373,21 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h ) switch alerts.Status() { case model.AlertResolved: + // Post the resolved snapshot to the timeline before closing (closed alerts + // reject notes), so the note lands first. + if n.advancedFeatures { + note, err := n.prepareNote(ctx, as) + if err != nil { + n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err)) + return nil, false, err + } + noteReq, err := n.noteRequest(ctx, alias, note, tmpl(n.conf.Source)) + if err != nil { + return nil, true, err + } + requests = append(requests, noteReq) + } + resolvedEndpointURL := n.conf.APIURL.Copy() resolvedEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/close", alias) q := resolvedEndpointURL.Query() @@ -322,6 +504,21 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h } requests = append(requests, req.WithContext(ctx)) } + + // Append this fire's snapshot to the timeline (every fire, including the + // first, so no datapoint is lost when the description is overwritten). + // Notes are plain text, so this uses the plain-text render, not the HTML body. + if n.advancedFeatures { + note, err := n.prepareNote(ctx, as) + if err != nil { + return nil, false, err + } + noteReq, err := n.noteRequest(ctx, alias, note, tmpl(n.conf.Source)) + if err != nil { + return nil, true, err + } + requests = append(requests, noteReq) + } } var apiKey string diff --git a/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie_test.go b/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie_test.go index 6b935fc4455..87c6ed9013d 100644 --- a/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie_test.go +++ b/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie_test.go @@ -6,14 +6,18 @@ package opsgenie import ( "context" + "encoding/json" "fmt" "io" "log/slog" "net/http" + "net/http/httptest" "net/url" "os" + "strings" "testing" "time" + "unicode/utf8" "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate" "github.com/SigNoz/signoz/pkg/types/alertmanagertypes" @@ -44,6 +48,7 @@ func TestOpsGenieRetry(t *testing.T) { tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), + false, ) require.NoError(t, err) @@ -69,6 +74,7 @@ func TestOpsGenieRedactedURL(t *testing.T) { tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), + false, ) require.NoError(t, err) @@ -96,6 +102,7 @@ func TestGettingOpsGegineApikeyFromFile(t *testing.T) { tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), + false, ) require.NoError(t, err) @@ -216,7 +223,7 @@ func TestOpsGenie(t *testing.T) { }, } { t.Run(tc.title, func(t *testing.T) { - notifier, err := New(tc.cfg, tmpl, logger, newTestTemplater(tmpl)) + notifier, err := New(tc.cfg, tmpl, logger, newTestTemplater(tmpl), false) require.NoError(t, err) ctx := context.Background() @@ -292,7 +299,7 @@ func TestOpsGenieWithUpdate(t *testing.T) { APIURL: &config.URL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, } - notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl)) + notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), false) alert := &types.Alert{ Alert: model.Alert{ StartsAt: time.Now(), @@ -324,6 +331,111 @@ func TestOpsGenieWithUpdate(t *testing.T) { assert.JSONEq(t, `{"description":"new description"}`, body2) } +func TestOpsGenieAdvancedFeatures(t *testing.T) { + u, err := url.Parse("https://test-opsgenie-url") + require.NoError(t, err) + tmpl := test.CreateTmpl(t) + ctx := notify.WithGroupKey(context.Background(), "1") + key, _ := notify.ExtractGroupKey(ctx) + alias := key.Hash() + + cfg := &config.OpsGenieConfig{ + NotifierConfig: config.NotifierConfig{VSendResolved: true}, + Message: `{{ .CommonLabels.Message }}`, + Description: `{{ .CommonLabels.Description }}`, + UpdateAlerts: true, + APIKey: "k", + APIURL: &config.URL{URL: u}, + HTTPConfig: &commoncfg.HTTPClientConfig{}, + } + notifier, err := New(cfg, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), true) + require.NoError(t, err) + + firing := &types.Alert{Alert: model.Alert{ + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + Labels: model.LabelSet{"Message": "m", "Description": "**Alert:** d [View](https://s.io/a)"}, + }} + + // Fire: create + update message + update description + a timeline note. + reqs, _, err := notifier.createRequests(ctx, firing) + require.NoError(t, err) + require.Len(t, reqs, 4) + assert.Equal(t, "https://test-opsgenie-url/v2/alerts", reqs[0].URL.String()) + assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/notes?identifierType=alias", alias), reqs[3].URL.String()) + assert.Equal(t, http.MethodPost, reqs[3].Method) + + // the note body is the plain-text render: markers stripped, link flattened + var noteMsg opsGenieAddNoteMessage + require.NoError(t, json.Unmarshal([]byte(readBody(t, reqs[3])), ¬eMsg)) + assert.Equal(t, "Alert: d View (https://s.io/a)", noteMsg.Note) + + // Resolve: note posted before the close. + resolved := &types.Alert{Alert: model.Alert{ + StartsAt: time.Now().Add(-time.Hour), + EndsAt: time.Now().Add(-time.Minute), + Labels: model.LabelSet{"Message": "m", "Description": "d"}, + }} + reqs, _, err = notifier.createRequests(ctx, resolved) + require.NoError(t, err) + require.Len(t, reqs, 2) + assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/notes?identifierType=alias", alias), reqs[0].URL.String()) + assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/close?identifierType=alias", alias), reqs[1].URL.String()) +} + +func TestOpsGenieNotifyBestEffortNote(t *testing.T) { + tmpl := test.CreateTmpl(t) + ctx := notify.WithGroupKey(context.Background(), "1") + + firing := &types.Alert{Alert: model.Alert{ + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + Labels: model.LabelSet{"Message": "m", "Description": "d"}, + }} + + for _, tc := range []struct { + name string + createStatus int + noteStatus int + wantErr bool + wantRetry bool + }{ + {name: "note_404_is_dropped", createStatus: http.StatusAccepted, noteStatus: http.StatusNotFound, wantErr: false, wantRetry: true}, + {name: "note_429_still_retries", createStatus: http.StatusAccepted, noteStatus: http.StatusTooManyRequests, wantErr: true, wantRetry: true}, + {name: "create_404_still_fails", createStatus: http.StatusNotFound, noteStatus: http.StatusAccepted, wantErr: true, wantRetry: false}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/notes") { + w.WriteHeader(tc.noteStatus) + return + } + w.WriteHeader(tc.createStatus) + })) + defer srv.Close() + + u, err := url.Parse(srv.URL) + require.NoError(t, err) + notifier, err := New(&config.OpsGenieConfig{ + Message: `{{ .CommonLabels.Message }}`, + Description: `{{ .CommonLabels.Description }}`, + APIKey: "k", + APIURL: &config.URL{URL: u}, + HTTPConfig: &commoncfg.HTTPClientConfig{}, + }, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), true) + require.NoError(t, err) + + retry, err := notifier.Notify(ctx, firing) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tc.wantRetry, retry) + }) + } +} + func TestOpsGenieApiKeyFile(t *testing.T) { u, err := url.Parse("https://test-opsgenie-url") require.NoError(t, err) @@ -335,7 +447,7 @@ func TestOpsGenieApiKeyFile(t *testing.T) { APIURL: &config.URL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, } - notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl)) + notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), false) require.NoError(t, err) requests, _, err := notifierWithUpdate.createRequests(ctx) @@ -437,6 +549,109 @@ func TestPrepareContent(t *testing.T) { }) } +func TestShrinkMarkdownToFit(t *testing.T) { + cases := []struct { + name string + md string + budget int + wantEmpty bool + }{ + {"fits untouched", "**bold** text", 1000, false}, + {"shrinks to fit", strings.Repeat("lorem ipsum ", 500), 1000, false}, + {"budget too small", strings.Repeat("lorem ipsum ", 500), 10, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := shrinkMarkdownToFit(c.md, c.budget) + require.NoError(t, err) + if c.wantEmpty { + assert.Empty(t, got) + return + } + assert.NotEmpty(t, got) + assert.LessOrEqual(t, utf8.RuneCountInString(got), c.budget) + assert.Equal(t, strings.Count(got, "

"), strings.Count(got, "

")) + }) + } +} + +func TestBuildHTMLDescriptionOverflow(t *testing.T) { + bigPart := strings.Repeat("alpha beta gamma ", 100) + cases := []struct { + name string + parts []string + budget int + wantTrailer bool + }{ + {"all parts fit", []string{"**a**", "**b**"}, maxDescriptionLenRunes, false}, + {"empty parts skipped", []string{"", "hello", ""}, maxDescriptionLenRunes, false}, + {"overflow drops parts with trailer", repeatParts(bigPart, 12), maxDescriptionLenRunes, true}, + {"single huge part shrunk without trailer", []string{strings.Repeat(bigPart, 20)}, maxDescriptionLenRunes, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := buildHTMLDescription(c.parts, c.budget) + require.NoError(t, err) + assert.LessOrEqual(t, utf8.RuneCountInString(got), c.budget) + assert.Equal(t, strings.Count(got, "
"), strings.Count(got, "
")) + assert.True(t, strings.HasSuffix(got, "
")) + if c.wantTrailer { + assert.Regexp(t, `…and \d+ more alerts\. Open in SigNoz for the full list\.`, got) + } else { + assert.NotContains(t, got, "more alerts") + } + }) + } +} + +// prepareContent end-to-end: 40 custom-template alerts overflow the description +// budget yet the posted HTML stays within limits and well-formed. +func TestPrepareContentDescriptionOverflow(t *testing.T) { + tmpl := test.CreateTmpl(t) + notifier := &Notifier{ + conf: &config.OpsGenieConfig{ + Message: `{{ .CommonLabels.alertname }}`, + Description: `{{ .CommonLabels.alertname }}`, + }, + tmpl: tmpl, + logger: promslog.NewNopLogger(), + templater: newTestTemplater(tmpl), + advancedFeatures: true, + } + + bodyTemplate := "**Alert in** $labels.namespace\n\n" + strings.Repeat("detail line for the runbook ", 30) + alerts := make([]*types.Alert, 0, 40) + for i := range 40 { + alerts = append(alerts, &types.Alert{ + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "overflow", + "namespace": model.LabelValue(fmt.Sprintf("ns-%d", i)), + }, + Annotations: model.LabelSet{ + ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate), + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + }) + } + + _, desc, err := notifier.prepareContent(notify.WithGroupKey(context.Background(), "1"), alerts) + require.NoError(t, err) + assert.LessOrEqual(t, utf8.RuneCountInString(desc), maxDescriptionLenRunes) + assert.Equal(t, strings.Count(desc, "
"), strings.Count(desc, "
")) + assert.Regexp(t, `…and \d+ more alerts\. Open in SigNoz for the full list\.`, desc) +} + +func repeatParts(part string, n int) []string { + parts := make([]string, n) + for i := range parts { + parts[i] = part + } + return parts +} + func readBody(t *testing.T, r *http.Request) string { t.Helper() body, err := io.ReadAll(r.Body) diff --git a/pkg/alertmanager/alertmanagernotify/receiver.go b/pkg/alertmanager/alertmanagernotify/receiver.go index ac904873cea..bdc3d14c4ca 100644 --- a/pkg/alertmanager/alertmanagernotify/receiver.go +++ b/pkg/alertmanager/alertmanagernotify/receiver.go @@ -6,6 +6,8 @@ import ( "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/email" "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/googlechat" + "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/jira" + "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/jsmops" "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/msteamsv2" "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie" "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/pagerduty" @@ -26,6 +28,8 @@ var customNotifierIntegrations = []string{ slack.Integration, msteamsv2.Integration, googlechat.Integration, + jira.Integration, + jsmops.Integration, } func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) { @@ -66,7 +70,7 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp add(pagerduty.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l, templater) }) } for i, c := range nc.OpsGenieConfigs { - add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, templater) }) + add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, templater, false) }) } for i, c := range nc.SlackConfigs { add(slack.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l, templater) }) @@ -81,6 +85,16 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp return googlechat.New(c, tmpl, l, templater) }) } + for i, c := range nc.JiraConfigs { + add(jira.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { + return jira.New(c, tmpl, l, templater) + }) + } + for i, c := range nc.JSMOpsConfigs { + add(jsmops.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { + return jsmops.New(c, tmpl, l, templater, true) + }) + } if errs.Len() > 0 { return nil, &errs diff --git a/pkg/templating/markdownrenderer/adf/adf.go b/pkg/templating/markdownrenderer/adf/adf.go new file mode 100644 index 00000000000..b87bf7bfd0d --- /dev/null +++ b/pkg/templating/markdownrenderer/adf/adf.go @@ -0,0 +1,173 @@ +// Package adf converts Markdown into Atlassian Document Format (ADF) nodes, +// the JSON rich-text format used by Jira Cloud's v3 API. +package adf + +import ( + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + extast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/text" +) + +// parser is stateless across Parse calls and safe for concurrent use; only +// goldmark's renderers hold per-document state (which we don't use). Strikethrough +// is included for the strike mark; linkify is deliberately omitted since it +// fragments plain text into word tokens while scanning for bare URLs. +var parser = goldmark.New(goldmark.WithExtensions(extension.Strikethrough)).Parser() + +// Render returns the ADF block nodes for markdown (without the doc wrapper), +// so callers can embed them alongside their own nodes (panels, links, …). +func Render(markdown string) []any { + src := []byte(markdown) + return blockChildren(parser.Parse(text.NewReader(src)), src) +} + +func blockChildren(parent ast.Node, src []byte) []any { + var out []any + for c := parent.FirstChild(); c != nil; c = c.NextSibling() { + if b := block(c, src); b != nil { + out = append(out, b) + } + } + return out +} + +func block(n ast.Node, src []byte) any { + switch node := n.(type) { + case *ast.Heading: + return map[string]any{"type": "heading", "attrs": map[string]any{"level": node.Level}, "content": inlineChildren(node, src, nil)} + case *ast.Paragraph: + return paragraph(inlineChildren(node, src, nil)) + case *ast.TextBlock: + return paragraph(inlineChildren(node, src, nil)) + case *ast.List: + typ := "bulletList" + if node.IsOrdered() { + typ = "orderedList" + } + return map[string]any{"type": typ, "content": blockChildren(node, src)} + case *ast.ListItem: + return map[string]any{"type": "listItem", "content": blockChildren(node, src)} + case *ast.Blockquote: + return map[string]any{"type": "blockquote", "content": blockChildren(node, src)} + case *ast.FencedCodeBlock: + return codeBlock(codeText(node, src), string(node.Language(src))) + case *ast.CodeBlock: + return codeBlock(codeText(node, src), "") + case *ast.ThematicBreak: + return map[string]any{"type": "rule"} + default: + return nil + } +} + +func paragraph(content []any) map[string]any { + p := map[string]any{"type": "paragraph"} + if len(content) > 0 { + p["content"] = content + } + return p +} + +func codeBlock(code, lang string) map[string]any { + cb := map[string]any{"type": "codeBlock"} + if lang != "" { + cb["attrs"] = map[string]any{"language": lang} + } + if code = strings.TrimRight(code, "\n"); code != "" { + cb["content"] = []any{map[string]any{"type": "text", "text": code}} + } + return cb +} + +// inlineChildren flattens an inline subtree into ADF text nodes, carrying the +// active marks (strong/em/code/strike/link) down the tree. +func inlineChildren(parent ast.Node, src []byte, marks []any) []any { + var out []any + for c := parent.FirstChild(); c != nil; c = c.NextSibling() { + switch node := c.(type) { + case *ast.Text: + if t := string(node.Segment.Value(src)); t != "" { + out = append(out, textNode(t, marks)) + } + if node.HardLineBreak() { + out = append(out, map[string]any{"type": "hardBreak"}) + } else if node.SoftLineBreak() { + out = append(out, textNode(" ", marks)) + } + case *ast.String: + if len(node.Value) > 0 { + out = append(out, textNode(string(node.Value), marks)) + } + case *ast.CodeSpan: + if t := rawText(node, src); t != "" { + out = append(out, textNode(t, withMark(marks, mark("code")))) + } + case *ast.Emphasis: + m := "em" + if node.Level == 2 { + m = "strong" + } + out = append(out, inlineChildren(node, src, withMark(marks, mark(m)))...) + case *extast.Strikethrough: + out = append(out, inlineChildren(node, src, withMark(marks, mark("strike")))...) + case *ast.Link: + out = append(out, inlineChildren(node, src, withMark(marks, linkMark(string(node.Destination))))...) + case *ast.AutoLink: + if u := string(node.URL(src)); u != "" { + out = append(out, textNode(u, withMark(marks, linkMark(u)))) + } + default: + out = append(out, inlineChildren(c, src, marks)...) + } + } + return out +} + +func textNode(s string, marks []any) map[string]any { + tn := map[string]any{"type": "text", "text": s} + if len(marks) > 0 { + tn["marks"] = marks + } + return tn +} + +func mark(typ string) any { return map[string]any{"type": typ} } + +func linkMark(href string) any { + return map[string]any{"type": "link", "attrs": map[string]any{"href": href}} +} + +func withMark(marks []any, m any) []any { + out := make([]any, 0, len(marks)+1) + out = append(out, marks...) + return append(out, m) +} + +func rawText(n ast.Node, src []byte) string { + var b strings.Builder + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + switch t := c.(type) { + case *ast.Text: + b.Write(t.Segment.Value(src)) + case *ast.String: + b.Write(t.Value) + default: + b.WriteString(rawText(c, src)) + } + } + return b.String() +} + +func codeText(n ast.Node, src []byte) string { + var b strings.Builder + lines := n.Lines() + for i := 0; i < lines.Len(); i++ { + seg := lines.At(i) + b.Write(seg.Value(src)) + } + return b.String() +} diff --git a/pkg/templating/markdownrenderer/adf/adf_test.go b/pkg/templating/markdownrenderer/adf/adf_test.go new file mode 100644 index 00000000000..9bd3c58c269 --- /dev/null +++ b/pkg/templating/markdownrenderer/adf/adf_test.go @@ -0,0 +1,85 @@ +package adf + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func toJSON(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + return string(b) +} + +func TestRenderInlineMarks(t *testing.T) { + js := toJSON(t, Render("**bold** and *em* and `code` and [txt](https://x.io)")) + assert.Contains(t, js, `"type":"strong"`) + assert.Contains(t, js, `"type":"em"`) + assert.Contains(t, js, `"type":"code"`) + assert.Contains(t, js, `"type":"link"`) + assert.Contains(t, js, `"href":"https://x.io"`) + assert.Contains(t, js, `"text":"bold"`) +} + +func TestRenderHeadingAndList(t *testing.T) { + js := toJSON(t, Render("# Title\n\n- a\n- b")) + assert.Contains(t, js, `"type":"heading"`) + assert.Contains(t, js, `"level":1`) + assert.Contains(t, js, `"type":"bulletList"`) + assert.Contains(t, js, `"type":"listItem"`) +} + +func TestRenderOrderedList(t *testing.T) { + js := toJSON(t, Render("1. one\n2. two")) + assert.Contains(t, js, `"type":"orderedList"`) +} + +func TestRenderCodeBlock(t *testing.T) { + js := toJSON(t, Render("```go\nx := 1\n```")) + assert.Contains(t, js, `"type":"codeBlock"`) + assert.Contains(t, js, `"language":"go"`) + assert.Contains(t, js, `x := 1`) +} + +func TestRenderStrikethrough(t *testing.T) { + js := toJSON(t, Render("~~gone~~")) + assert.Contains(t, js, `"type":"strike"`) + assert.Contains(t, js, `"text":"gone"`) +} + +func TestRenderBlockquote(t *testing.T) { + js := toJSON(t, Render("> quoted")) + assert.Contains(t, js, `"type":"blockquote"`) + assert.Contains(t, js, `"text":"quoted"`) +} + +func TestRenderAutoLink(t *testing.T) { + js := toJSON(t, Render("see ")) + assert.Contains(t, js, `"type":"link"`) + assert.Contains(t, js, `"href":"https://signoz.io"`) + assert.Contains(t, js, `"text":"https://signoz.io"`) +} + +func TestRenderLineBreaks(t *testing.T) { + js := toJSON(t, Render("one \ntwo")) + assert.Contains(t, js, `"type":"hardBreak"`) + + // a soft break renders as a space, keeping the paragraph intact + js = toJSON(t, Render("one\ntwo")) + assert.NotContains(t, js, `"type":"hardBreak"`) + assert.Contains(t, js, `"text":" "`) +} + +func TestRenderPlainText(t *testing.T) { + js := toJSON(t, Render("just text")) + assert.Contains(t, js, `"type":"paragraph"`) + assert.Contains(t, js, `"text":"just text"`) +} + +func TestRenderEmptyIsEmpty(t *testing.T) { + assert.Empty(t, Render("")) +} diff --git a/pkg/templating/markdownrenderer/markdownrenderer.go b/pkg/templating/markdownrenderer/markdownrenderer.go index 5705193df27..84960c01bc5 100644 --- a/pkg/templating/markdownrenderer/markdownrenderer.go +++ b/pkg/templating/markdownrenderer/markdownrenderer.go @@ -7,6 +7,7 @@ import ( "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/templating/markdownrenderer/blockkit" "github.com/SigNoz/signoz/pkg/templating/markdownrenderer/mrkdwn" + "github.com/SigNoz/signoz/pkg/templating/markdownrenderer/plaintext" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" ) @@ -32,6 +33,11 @@ var ( return goldmark.New(goldmark.WithExtensions(mrkdwn.Extender)) }, } + plaintextPool = sync.Pool{ + New: func() any { + return goldmark.New(goldmark.WithExtensions(plaintext.Extender)) + }, + } ) // RenderHTML converts markdown to HTML. @@ -53,6 +59,14 @@ func RenderSlackMrkdwn(markdown string) (string, error) { return render(md, markdown, "Slack mrkdwn") } +// RenderPlainText converts markdown to plain text: no markers, links flattened +// to "text (url)". +func RenderPlainText(markdown string) (string, error) { + md := plaintextPool.Get().(goldmark.Markdown) + defer plaintextPool.Put(md) + return render(md, markdown, "plain text") +} + func render(md goldmark.Markdown, markdown string, format string) (string, error) { var buf bytes.Buffer if err := md.Convert([]byte(markdown), &buf); err != nil { diff --git a/pkg/templating/markdownrenderer/plaintext/plaintext.go b/pkg/templating/markdownrenderer/plaintext/plaintext.go new file mode 100644 index 00000000000..cdf0f1c580d --- /dev/null +++ b/pkg/templating/markdownrenderer/plaintext/plaintext.go @@ -0,0 +1,301 @@ +// Package plaintext provides a goldmark node renderer that emits plain text: +// no markdown or HTML markers, and links flattened to "text (url)". It is used +// for JSM Ops timeline notes, which render neither HTML nor markdown. +package plaintext + +import ( + "bytes" + "fmt" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + extensionast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/util" +) + +// Extender registers the plain-text node renderer plus the GFM extensions it +// handles (tables, strikethrough). +var Extender goldmark.Extender = &extender{} + +type extender struct{} + +func (e *extender) Extend(m goldmark.Markdown) { + extension.Table.Extend(m) + extension.Strikethrough.Extend(m) + m.Renderer().AddOptions( + renderer.WithNodeRenderers(util.Prioritized(newRenderer(), 1)), + ) +} + +// nodeRenderer holds per-document nesting prefixes, so it is not safe for +// concurrent Convert calls; callers pool one instance per goroutine. +type nodeRenderer struct { + prefixes []string +} + +func newRenderer() renderer.NodeRenderer { + return &nodeRenderer{} +} + +func (r *nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + // Blocks + reg.Register(ast.KindDocument, r.renderDocument) + reg.Register(ast.KindHeading, r.renderBlock) + reg.Register(ast.KindBlockquote, r.renderBlock) + reg.Register(ast.KindCodeBlock, r.renderCodeBlock) + reg.Register(ast.KindFencedCodeBlock, r.renderCodeBlock) + reg.Register(ast.KindHTMLBlock, r.renderHTMLBlock) + reg.Register(ast.KindList, r.renderList) + reg.Register(ast.KindListItem, r.renderListItem) + reg.Register(ast.KindParagraph, r.renderBlock) + reg.Register(ast.KindTextBlock, r.renderTextBlock) + reg.Register(ast.KindThematicBreak, r.renderThematicBreak) + + // Inlines + reg.Register(ast.KindAutoLink, r.renderAutoLink) + reg.Register(ast.KindCodeSpan, r.renderCodeSpan) + reg.Register(ast.KindEmphasis, r.renderPassthrough) + reg.Register(ast.KindImage, r.renderLink) + reg.Register(ast.KindLink, r.renderLink) + reg.Register(ast.KindText, r.renderText) + reg.Register(ast.KindString, r.renderString) + reg.Register(ast.KindRawHTML, r.renderRawHTML) + + // Extensions + reg.Register(extensionast.KindStrikethrough, r.renderPassthrough) + reg.Register(extensionast.KindTable, r.renderTable) +} + +func (r *nodeRenderer) writePrefix(w util.BufWriter) { + for _, p := range r.prefixes { + _, _ = w.WriteString(p) + } +} + +func (r *nodeRenderer) writeLineSeparator(w util.BufWriter) { + _ = w.WriteByte('\n') + r.writePrefix(w) +} + +// writeBlockSeparator writes a blank line between block-level elements. +func (r *nodeRenderer) writeBlockSeparator(w util.BufWriter) { + r.writeLineSeparator(w) + r.writeLineSeparator(w) +} + +func (r *nodeRenderer) separateFromPrevious(w util.BufWriter, n ast.Node) { + if n.PreviousSibling() != nil { + r.writeBlockSeparator(w) + } +} + +func (r *nodeRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + // The renderer is pooled; wipe any prefix stack left over from a prior + // document (e.g. one that errored mid-walk) before starting fresh. + r.prefixes = r.prefixes[:0] + } + return ast.WalkContinue, nil +} + +// renderBlock separates block-level nodes (paragraph, heading, blockquote) from +// their previous sibling with a blank line, emitting no markers of their own. +func (r *nodeRenderer) renderBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + r.separateFromPrevious(w, node) + } + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + r.separateFromPrevious(w, n) + l := n.Lines().Len() + for i := 0; i < l; i++ { + line := n.Lines().At(i) + _, _ = w.Write(line.Value(source)) + } + } + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering && node.PreviousSibling() != nil { + r.writeLineSeparator(w) + if node.Parent() == nil || node.Parent().Kind() != ast.KindListItem { + r.writeLineSeparator(w) + } + } + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderListItem(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + if n.PreviousSibling() != nil { + r.writeLineSeparator(w) + } + parent := n.Parent().(*ast.List) + var prefixStr string + if parent.IsOrdered() { + index := parent.Start + for c := parent.FirstChild(); c != nil && c != n; c = c.NextSibling() { + index++ + } + prefixStr = fmt.Sprintf("%d. ", index) + } else { + prefixStr = "- " + } + _, _ = w.WriteString(prefixStr) + r.prefixes = append(r.prefixes, " ") // indent wrapped/nested lines + } else { + r.prefixes = r.prefixes[:len(r.prefixes)-1] + } + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderTextBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering && n.PreviousSibling() != nil { + r.writeLineSeparator(w) + } + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderThematicBreak(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + r.separateFromPrevious(w, n) + _, _ = w.WriteString("---") + } + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + n := node.(*ast.AutoLink) + url := string(n.URL(source)) + if n.AutoLinkType == ast.AutoLinkEmail && !strings.HasPrefix(strings.ToLower(url), "mailto:") { + url = "mailto:" + url + } + _, _ = w.WriteString(url) + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + segment := c.(*ast.Text).Segment + value := segment.Value(source) + if bytes.HasSuffix(value, []byte("\n")) { + _, _ = w.Write(value[:len(value)-1]) + _ = w.WriteByte(' ') + } else { + _, _ = w.Write(value) + } + } + return ast.WalkSkipChildren, nil + } + return ast.WalkContinue, nil +} + +// renderPassthrough emits no markers; the node's children render as plain text +// (used for emphasis/strong and strikethrough). +func (r *nodeRenderer) renderPassthrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + return ast.WalkContinue, nil +} + +// renderLink flattens links and images to "text (url)": children render the +// label, then the destination is appended in parentheses on exit. +func (r *nodeRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + var dest []byte + switch n := node.(type) { + case *ast.Link: + dest = n.Destination + case *ast.Image: + dest = n.Destination + } + if !entering && len(dest) > 0 { + _, _ = fmt.Fprintf(w, " (%s)", dest) + } + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + n := node.(*ast.Text) + _, _ = w.Write(n.Segment.Value(source)) + if n.HardLineBreak() || n.SoftLineBreak() { + r.writeLineSeparator(w) + } + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + _, _ = w.Write(node.(*ast.String).Value) + } + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + // Drop inline raw HTML tags; a plain-text note should never carry markup. + return ast.WalkSkipChildren, nil +} + +func (r *nodeRenderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + // Drop block-level raw HTML for the same reason as inline raw HTML. + return ast.WalkSkipChildren, nil +} + +func (r *nodeRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + r.separateFromPrevious(w, node) + + first := true + for c := node.FirstChild(); c != nil; c = c.NextSibling() { + if c.Kind() != extensionast.KindTableHeader && c.Kind() != extensionast.KindTableRow { + continue + } + if !first { + r.writeLineSeparator(w) + } + first = false + cellFirst := true + for cc := c.FirstChild(); cc != nil; cc = cc.NextSibling() { + if cc.Kind() != extensionast.KindTableCell { + continue + } + if !cellFirst { + _, _ = w.WriteString(" | ") + } + cellFirst = false + _, _ = w.WriteString(extractPlainText(cc, source)) + } + } + return ast.WalkSkipChildren, nil +} + +// extractPlainText collects the text content of a node. +func extractPlainText(n ast.Node, source []byte) string { + var buf bytes.Buffer + _ = ast.Walk(n, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch t := node.(type) { + case *ast.Text: + buf.Write(t.Segment.Value(source)) + case *ast.String: + buf.Write(t.Value) + } + return ast.WalkContinue, nil + }) + return strings.TrimSpace(buf.String()) +} diff --git a/pkg/templating/markdownrenderer/plaintext/plaintext_test.go b/pkg/templating/markdownrenderer/plaintext/plaintext_test.go new file mode 100644 index 00000000000..0230f03a005 --- /dev/null +++ b/pkg/templating/markdownrenderer/plaintext/plaintext_test.go @@ -0,0 +1,55 @@ +package plaintext + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yuin/goldmark" +) + +func render(t *testing.T, md string) string { + t.Helper() + var b []byte + buf := bytesBuffer{&b} + g := goldmark.New(goldmark.WithExtensions(Extender)) + require.NoError(t, g.Convert([]byte(md), &buf)) + return string(b) +} + +// bytesBuffer is a tiny io.Writer so the test needs no extra imports. +type bytesBuffer struct{ b *[]byte } + +func (w bytesBuffer) Write(p []byte) (int, error) { + *w.b = append(*w.b, p...) + return len(p), nil +} + +func TestPlainText(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"strips bold and italic", "**bold** and *italic*", "bold and italic"}, + {"link becomes text (url)", "[View in SigNoz](https://signoz.io/alert)", "View in SigNoz (https://signoz.io/alert)"}, + {"bold label kept, marker dropped", "**Alert:** name (critical)", "Alert: name (critical)"}, + {"strikethrough stripped", "~~gone~~", "gone"}, + {"inline code unwrapped", "run `foo bar`", "run foo bar"}, + {"paragraphs separated by blank line", "one\n\ntwo", "one\n\ntwo"}, + {"unordered list", "- a\n- b", "- a\n- b"}, + {"ordered list keeps numbering", "1. a\n2. b", "1. a\n2. b"}, + {"nested list indents under parent", "- a\n - b", "- a\n - b"}, + {"fenced code block unwrapped", "```go\nx := 1\n```", "x := 1\n"}, + {"table flattens to pipe-separated rows", "| h1 | h2 |\n|---|---|\n| a | b |\n| c | d |", "h1 | h2\na | b\nc | d"}, + {"autolink kept as bare url", "see ", "see https://signoz.io"}, + {"inline raw html dropped", "a bold word", "a bold word"}, + {"html block dropped", "before\n\n
markup
\n\nafter", "before\n\nafter"}, + {"hard break becomes newline", "one \ntwo", "one\ntwo"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, render(t, c.in)) + }) + } +} diff --git a/pkg/types/alertmanagertypes/channel.go b/pkg/types/alertmanagertypes/channel.go index 3e2e3386c09..c3d9eca5a77 100644 --- a/pkg/types/alertmanagertypes/channel.go +++ b/pkg/types/alertmanagertypes/channel.go @@ -216,13 +216,20 @@ func (PostableChannel) JSONSchema() (jsonschema.Schema, error) { schema.WithRequired("name") var oneOf []jsonschema.SchemaOrBool - // Walk both halves: native fields on Receiver, upstream on the embed. + seen := map[string]struct{}{} + // Walk both halves: native fields on Receiver, upstream on the embed. A native + // field can shadow an upstream one with the same tag (e.g. jira_configs), so + // dedupe to avoid emitting two identical oneOf branches. collect := func(t reflect.Type) { for i := 0; i < t.NumField(); i++ { jsonTag := strings.Split(t.Field(i).Tag.Get("json"), ",")[0] if !strings.HasSuffix(jsonTag, "_configs") { continue } + if _, ok := seen[jsonTag]; ok { + continue + } + seen[jsonTag] = struct{}{} branch := (&jsonschema.Schema{}).WithRequired(jsonTag) oneOf = append(oneOf, branch.ToSchemaOrBool()) } diff --git a/pkg/types/alertmanagertypes/config.go b/pkg/types/alertmanagertypes/config.go index 70b67030eea..7505cfea236 100644 --- a/pkg/types/alertmanagertypes/config.go +++ b/pkg/types/alertmanagertypes/config.go @@ -70,15 +70,19 @@ type Config struct { // on Receiver, and extensions to customConfigsOf + isEmpty. type customReceiverConfigs struct { GoogleChat []*GoogleChatReceiverConfig + Jira []*JiraReceiverConfig + JSMOps []*JSMOpsReceiverConfig } func (c customReceiverConfigs) isEmpty() bool { - return len(c.GoogleChat) == 0 + return len(c.GoogleChat) == 0 && len(c.Jira) == 0 && len(c.JSMOps) == 0 } func customConfigsOf(receiver *Receiver) customReceiverConfigs { return customReceiverConfigs{ GoogleChat: receiver.GoogleChatConfigs, + Jira: receiver.JiraConfigs, + JSMOps: receiver.JSMOpsConfigs, } } @@ -187,6 +191,8 @@ func extendedReceivers(c *config.Config, customConfigs map[string]customReceiver receivers[i] = &Receiver{ Receiver: &base, GoogleChatConfigs: custom.GoogleChat, + JiraConfigs: custom.Jira, + JSMOpsConfigs: custom.JSMOps, } } @@ -362,6 +368,8 @@ func (c *Config) GetReceiver(name string) (*Receiver, error) { return &Receiver{ Receiver: &base, GoogleChatConfigs: custom.GoogleChat, + JiraConfigs: custom.Jira, + JSMOpsConfigs: custom.JSMOps, }, nil } } @@ -441,6 +449,16 @@ func (c *Config) applyNativeDefaults() { gc.HTTPConfig = httpDefault } } + for _, jc := range custom.Jira { + if jc.HTTPConfig == nil { + jc.HTTPConfig = httpDefault + } + } + for _, jc := range custom.JSMOps { + if jc.HTTPConfig == nil { + jc.HTTPConfig = httpDefault + } + } } } diff --git a/pkg/types/alertmanagertypes/jira.go b/pkg/types/alertmanagertypes/jira.go new file mode 100644 index 00000000000..08d9e558c1e --- /dev/null +++ b/pkg/types/alertmanagertypes/jira.go @@ -0,0 +1,119 @@ +package alertmanagertypes + +import ( + "fmt" + "net/url" + "strings" + "time" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/prometheus/alertmanager/config" + commoncfg "github.com/prometheus/common/config" + "github.com/prometheus/common/model" +) + +const defaultJiraReopenDuration = model.Duration(3 * 24 * time.Hour) + +// Service accounts authenticate against the api.atlassian.com gateway (keyed by +// cloud id) instead of the site host; they are identified by their email domain. +const ( + jiraCloudHostSuffix = ".atlassian.net" + jiraServiceAccountEmailDomain = "@serviceaccount.atlassian.com" + jiraGatewayBaseURL = "https://api.atlassian.com/ex/jira/" +) + +// Default templates for the issue title and body. The body is rendered to +// markdown and then wrapped in the ADF status panel + deep-links by the notifier. +const ( + DefaultJiraSummaryTemplate = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}` + + DefaultJiraDescriptionTemplate = `{{ 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 }}` +) + +// JiraReceiverConfig is the SigNoz Jira receiver. Fields are declared explicitly +// instead of embedding upstream config.JiraConfig because that type's own +// UnmarshalYAML would reset our defaults and drop sibling fields on the yaml +// round-trip. Only Jira Cloud (v3/ADF) is supported, so api_url is derived from Site. +type JiraReceiverConfig struct { + config.NotifierConfig `yaml:",inline"` + + Site string `json:"site,omitempty" yaml:"site,omitempty"` + Project string `json:"project,omitempty" yaml:"project,omitempty"` + IssueType string `json:"issue_type,omitempty" yaml:"issue_type,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Priority string `json:"priority,omitempty" yaml:"priority,omitempty"` + Labels []string `json:"labels,omitempty" yaml:"labels,omitempty"` + ResolveTransition string `json:"resolve_transition,omitempty" yaml:"resolve_transition,omitempty"` + ReopenTransition string `json:"reopen_transition,omitempty" yaml:"reopen_transition,omitempty"` + ReopenDuration model.Duration `json:"reopen_duration" yaml:"reopen_duration"` + WontFixResolution string `json:"wont_fix_resolution,omitempty" yaml:"wont_fix_resolution,omitempty"` + CustomFields map[string]any `json:"custom_fields,omitempty" yaml:"custom_fields,omitempty"` + HTTPConfig *commoncfg.HTTPClientConfig `json:"http_config,omitempty" yaml:"http_config,omitempty"` +} + +func (c *JiraReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error { + type plain JiraReceiverConfig + if err := unmarshal((*plain)(c)); err != nil { + return err + } + + if c.ReopenDuration <= 0 { + c.ReopenDuration = defaultJiraReopenDuration + } + // sub-minute windows truncate to 0 in the reopen JQL and silently disable + // reopening, so reject them. + if c.ReopenDuration < model.Duration(time.Minute) { + return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira reopen_duration must be at least 1m") + } + if c.Summary == "" { + c.Summary = DefaultJiraSummaryTemplate + } + if c.Description == "" { + c.Description = DefaultJiraDescriptionTemplate + } + + site := strings.TrimRight(strings.TrimSpace(c.Site), "/") + u, err := url.Parse(site) + if site == "" || err != nil || u.Scheme != "https" || !strings.HasSuffix(strings.ToLower(u.Hostname()), jiraCloudHostSuffix) { + return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("jira site must be a Jira Cloud URL (https://%s)", jiraCloudHostSuffix)) + } + c.Site = site + + if c.Project == "" { + return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira project is required") + } + if c.IssueType == "" { + return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira issue_type is required") + } + if c.HTTPConfig == nil || c.HTTPConfig.BasicAuth == nil { + return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira requires basic auth (email + API token)") + } + return nil +} + +// IsServiceAccount reports whether the basic-auth user is an Atlassian service +// account, identified by its email domain. Service accounts must go through the +// api.atlassian.com gateway; personal API tokens use the site host directly. +func (c *JiraReceiverConfig) IsServiceAccount() bool { + if c.HTTPConfig == nil || c.HTTPConfig.BasicAuth == nil { + return false + } + return strings.HasSuffix(strings.ToLower(c.HTTPConfig.BasicAuth.Username), jiraServiceAccountEmailDomain) +} + +// APIBaseURL returns the Jira Cloud REST v3 base URL: the api.atlassian.com +// gateway when a cloud id is given (service accounts), else the site host. +func (c *JiraReceiverConfig) APIBaseURL(cloudID string) string { + if cloudID != "" { + return fmt.Sprintf("%s%s/rest/api/3", jiraGatewayBaseURL, cloudID) + } + return fmt.Sprintf("%s/rest/api/3", strings.TrimRight(c.Site, "/")) +} diff --git a/pkg/types/alertmanagertypes/jira_test.go b/pkg/types/alertmanagertypes/jira_test.go new file mode 100644 index 00000000000..d3cf95b065b --- /dev/null +++ b/pkg/types/alertmanagertypes/jira_test.go @@ -0,0 +1,119 @@ +package alertmanagertypes + +import ( + "fmt" + "testing" + "time" + + commoncfg "github.com/prometheus/common/config" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func jiraReceiverJSON(site, project, issueType string, withAuth bool) string { + auth := "" + if withAuth { + auth = `,"http_config":{"basic_auth":{"username":"me@acme.com","password":"token"}}` + } + return fmt.Sprintf( + `{"name":"jira","jira_configs":[{"site":%q,"project":%q,"issue_type":%q%s}]}`, + site, project, issueType, auth, + ) +} + +func TestJiraReceiverConfigDefaults(t *testing.T) { + r, err := NewReceiver(jiraReceiverJSON("https://acme.atlassian.net", "KAN", "Task", true)) + require.NoError(t, err) + require.Len(t, r.JiraConfigs, 1) + + jc := r.JiraConfigs[0] + assert.Equal(t, "https://acme.atlassian.net", jc.Site) + assert.Equal(t, "https://acme.atlassian.net/rest/api/3", jc.APIBaseURL("")) + assert.False(t, jc.SendResolved()) // default off when omitted, like other channels + assert.Equal(t, defaultJiraReopenDuration, jc.ReopenDuration) + assert.Equal(t, DefaultJiraSummaryTemplate, jc.Summary) + assert.Equal(t, DefaultJiraDescriptionTemplate, jc.Description) + + ch, err := NewChannelFromReceiver(r, "org-1") + require.NoError(t, err) + assert.Equal(t, "jira", ch.Type) +} + +func TestJiraReceiverConfigSendResolved(t *testing.T) { + withSendResolved := func(v bool) string { + return fmt.Sprintf( + `{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","send_resolved":%t,"http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`, + v, + ) + } + on, err := NewReceiver(withSendResolved(true)) + require.NoError(t, err) + assert.True(t, on.JiraConfigs[0].SendResolved()) + + off, err := NewReceiver(withSendResolved(false)) + require.NoError(t, err) + assert.False(t, off.JiraConfigs[0].SendResolved()) +} + +func TestJiraReceiverConfigReopenDurationMinimum(t *testing.T) { + withReopen := func(v string) string { + return fmt.Sprintf( + `{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":%q,"http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`, + v, + ) + } + r, err := NewReceiver(withReopen("1m")) + require.NoError(t, err) + assert.Equal(t, model.Duration(time.Minute), r.JiraConfigs[0].ReopenDuration) + + _, err = NewReceiver(withReopen("30s")) + assert.Error(t, err) +} + +func TestJiraAPIBaseURL(t *testing.T) { + c := &JiraReceiverConfig{Site: "https://acme.atlassian.net"} + assert.Equal(t, "https://acme.atlassian.net/rest/api/3", c.APIBaseURL("")) + assert.Equal(t, "https://api.atlassian.com/ex/jira/09851b38-1a40-4c01-a36a-0a9336293200/rest/api/3", c.APIBaseURL("09851b38-1a40-4c01-a36a-0a9336293200")) +} + +func TestJiraIsServiceAccount(t *testing.T) { + withUser := func(username string) *JiraReceiverConfig { + return &JiraReceiverConfig{HTTPConfig: &commoncfg.HTTPClientConfig{BasicAuth: &commoncfg.BasicAuth{Username: username}}} + } + assert.True(t, withUser("bot@serviceaccount.atlassian.com").IsServiceAccount()) + assert.True(t, withUser("Bot@ServiceAccount.Atlassian.Com").IsServiceAccount()) + assert.False(t, withUser("temp@signoz.io").IsServiceAccount()) + assert.False(t, (&JiraReceiverConfig{}).IsServiceAccount()) +} + +func TestJiraReceiverConfigTrailingSlashSite(t *testing.T) { + r, err := NewReceiver(jiraReceiverJSON("https://acme.atlassian.net/", "KAN", "Task", true)) + require.NoError(t, err) + assert.Equal(t, "https://acme.atlassian.net", r.JiraConfigs[0].Site) + assert.Equal(t, "https://acme.atlassian.net/rest/api/3", r.JiraConfigs[0].APIBaseURL("")) +} + +func TestJiraReceiverConfigValidation(t *testing.T) { + cases := []struct { + name string + json string + }{ + {"missing site", `{"name":"j","jira_configs":[{"project":"KAN","issue_type":"Task","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`}, + {"http site", jiraReceiverJSON("http://acme.atlassian.net", "KAN", "Task", true)}, + {"non-cloud host", jiraReceiverJSON("https://jira.acme.com", "KAN", "Task", true)}, + {"lookalike host suffix", jiraReceiverJSON("https://www.iamnotatlassian.net", "KAN", "Task", true)}, + {"bare atlassian.net", jiraReceiverJSON("https://atlassian.net", "KAN", "Task", true)}, + {"missing project", jiraReceiverJSON("https://acme.atlassian.net", "", "Task", true)}, + {"missing issue_type", jiraReceiverJSON("https://acme.atlassian.net", "KAN", "", true)}, + {"missing basic auth", jiraReceiverJSON("https://acme.atlassian.net", "KAN", "Task", false)}, + {"invalid reopen_duration format", `{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":"3days","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`}, + {"sub-minute reopen_duration", `{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":"30s","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := NewReceiver(c.json) + assert.Error(t, err) + }) + } +} diff --git a/pkg/types/alertmanagertypes/jsmops.go b/pkg/types/alertmanagertypes/jsmops.go new file mode 100644 index 00000000000..50d72acad4a --- /dev/null +++ b/pkg/types/alertmanagertypes/jsmops.go @@ -0,0 +1,75 @@ +package alertmanagertypes + +import ( + "github.com/SigNoz/signoz/pkg/errors" + "github.com/prometheus/alertmanager/config" + commoncfg "github.com/prometheus/common/config" +) + +// JSMOpsAPIBaseURL is the native JSM Ops integration-events gateway. It is a +// single global host keyed by the integration API key (no region/cloud id in +// the path). The trailing slash is required: the Opsgenie notifier appends +// "v2/alerts..." to APIURL.Path with no separator. +const JSMOpsAPIBaseURL = "https://api.atlassian.com/jsm/ops/integration/" + +// JSM Ops speaks the Opsgenie alert API, so a JSM alert description takes the +// same HTML subset and 15,000-char limit; message caps at 130. The templates +// mirror Google Chat / Jira for a consistent default across channels. +const ( + DefaultJSMOpsMessageTemplate = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}` + + DefaultJSMOpsDescriptionTemplate = `{{ 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 }}` +) + +// JSMOpsReceiverConfig is the SigNoz Jira Service Management Ops receiver. It is +// delivered by reusing the Opsgenie notifier (JSM Ops is the ex-Opsgenie alert +// API): the notifier package maps these fields onto config.OpsGenieConfig with +// APIURL pinned to JSMOpsAPIBaseURL. +type JSMOpsReceiverConfig struct { + config.NotifierConfig `yaml:",inline" json:",inline"` + + HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` + + APIKey config.Secret `yaml:"api_key,omitempty" json:"api_key,omitempty"` + Message string `yaml:"message,omitempty" json:"message,omitempty"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Priority string `yaml:"priority,omitempty" json:"priority,omitempty"` + Tags string `yaml:"tags,omitempty" json:"tags,omitempty"` +} + +// send_resolved has no omitempty upstream, so a var default here is overwritten +// by the yaml round-trip to the request value (false when omitted); the UI sends +// it explicitly, defaulted on, so JSM alerts close on resolve. +var DefaultJSMOpsReceiverConfig = JSMOpsReceiverConfig{ + NotifierConfig: config.NotifierConfig{ + VSendResolved: false, + }, + Message: DefaultJSMOpsMessageTemplate, + Description: DefaultJSMOpsDescriptionTemplate, + Tags: "signoz", +} + +func (c *JSMOpsReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error { + *c = DefaultJSMOpsReceiverConfig + type plain JSMOpsReceiverConfig + if err := unmarshal((*plain)(c)); err != nil { + return err + } + if c.APIKey == "" { + return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jsm ops api_key is required") + } + return nil +} diff --git a/pkg/types/alertmanagertypes/jsmops_test.go b/pkg/types/alertmanagertypes/jsmops_test.go new file mode 100644 index 00000000000..3f105e8e262 --- /dev/null +++ b/pkg/types/alertmanagertypes/jsmops_test.go @@ -0,0 +1,70 @@ +package alertmanagertypes + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJSMOpsReceiverConfigDefaults(t *testing.T) { + r, err := NewReceiver(`{"name":"jsm","jsmops_configs":[{"api_key":"key-123"}]}`) + require.NoError(t, err) + require.Len(t, r.JSMOpsConfigs, 1) + + c := r.JSMOpsConfigs[0] + assert.Equal(t, "key-123", string(c.APIKey)) + assert.Equal(t, DefaultJSMOpsMessageTemplate, c.Message) + assert.Equal(t, DefaultJSMOpsDescriptionTemplate, c.Description) + assert.Equal(t, "signoz", c.Tags) + assert.False(t, c.SendResolved()) // default off when omitted, like other channels + + ch, err := NewChannelFromReceiver(r, "org-1") + require.NoError(t, err) + assert.Equal(t, "jsmops", ch.Type) +} + +func TestJSMOpsReceiverConfigOverrides(t *testing.T) { + r, err := NewReceiver(`{"name":"jsm","jsmops_configs":[{"api_key":"k","message":"m","description":"d","priority":"P1","tags":"a,b","send_resolved":true}]}`) + require.NoError(t, err) + require.Len(t, r.JSMOpsConfigs, 1) + + c := r.JSMOpsConfigs[0] + assert.Equal(t, "m", c.Message) + assert.Equal(t, "d", c.Description) + assert.Equal(t, "P1", c.Priority) + assert.Equal(t, "a,b", c.Tags) + assert.True(t, c.SendResolved()) +} + +func TestJSMOpsReceiverConfigSendResolved(t *testing.T) { + withSendResolved := func(v bool) string { + return fmt.Sprintf(`{"name":"jsm","jsmops_configs":[{"api_key":"k","send_resolved":%t}]}`, v) + } + on, err := NewReceiver(withSendResolved(true)) + require.NoError(t, err) + require.Len(t, on.JSMOpsConfigs, 1) + assert.True(t, on.JSMOpsConfigs[0].SendResolved()) + + off, err := NewReceiver(withSendResolved(false)) + require.NoError(t, err) + require.Len(t, off.JSMOpsConfigs, 1) + assert.False(t, off.JSMOpsConfigs[0].SendResolved()) +} + +func TestJSMOpsReceiverConfigValidation(t *testing.T) { + cases := []struct { + name string + json string + }{ + {"missing api_key", `{"name":"jsm","jsmops_configs":[{"message":"m"}]}`}, + {"empty api_key", `{"name":"jsm","jsmops_configs":[{"api_key":""}]}`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := NewReceiver(c.json) + assert.Error(t, err) + }) + } +} diff --git a/pkg/types/alertmanagertypes/receiver.go b/pkg/types/alertmanagertypes/receiver.go index 695545b9000..d6752a08c89 100644 --- a/pkg/types/alertmanagertypes/receiver.go +++ b/pkg/types/alertmanagertypes/receiver.go @@ -23,6 +23,11 @@ import ( type Receiver struct { *config.Receiver GoogleChatConfigs []*GoogleChatReceiverConfig `json:"googlechat_configs,omitempty" yaml:"googlechat_configs,omitempty"` + // Shadows upstream's jira_configs so our custom notifier (rich ADF, deep-links, + // lifecycle comments) handles it instead of upstream's plain Jira notifier. + JiraConfigs []*JiraReceiverConfig `json:"jira_configs,omitempty" yaml:"jira_configs,omitempty"` + // JSM Ops (ex-Opsgenie alert API); delivered by reusing the Opsgenie notifier. + JSMOpsConfigs []*JSMOpsReceiverConfig `json:"jsmops_configs,omitempty" yaml:"jsmops_configs,omitempty"` } // NewReceiver builds a Receiver from its JSON input, applying each notifier @@ -51,6 +56,22 @@ func NewReceiver(input string) (*Receiver, error) { receiver.GoogleChatConfigs[i] = defaulted } + for i, jc := range receiver.JiraConfigs { + defaulted, err := defaultedNotifierConfig(jc) + if err != nil { + return nil, err + } + receiver.JiraConfigs[i] = defaulted + } + + for i, jc := range receiver.JSMOpsConfigs { + defaulted, err := defaultedNotifierConfig(jc) + if err != nil { + return nil, err + } + receiver.JSMOpsConfigs[i] = defaulted + } + return receiver, nil } From 7c50fe376341dba2872026f9087ae8f3e6aaa67a Mon Sep 17 00:00:00 2001 From: Nityananda Gohain Date: Tue, 1 Sep 2026 13:54:36 +0000 Subject: [PATCH 3/4] fix: quick filters old migration cleanup (#12746) #### Description This PR makes sure that the old migrations of quick filters are decoupled from types as they should and not imported. Part of https://github.com/SigNoz/engineering-pod/issues/5947 --- pkg/sqlmigration/030_create_quick_filters.go | 75 +++++++++++++++-- pkg/sqlmigration/031_update_quick_filters.go | 82 +++++++++++++++++-- .../035_update_api_monitoring_filters.go | 78 +++++------------- 3 files changed, 163 insertions(+), 72 deletions(-) diff --git a/pkg/sqlmigration/030_create_quick_filters.go b/pkg/sqlmigration/030_create_quick_filters.go index 2a7016e0ec8..5c451ed9087 100644 --- a/pkg/sqlmigration/030_create_quick_filters.go +++ b/pkg/sqlmigration/030_create_quick_filters.go @@ -3,12 +3,13 @@ package sqlmigration import ( "context" "database/sql" + "encoding/json" + "time" "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/types" - "github.com/SigNoz/signoz/pkg/types/quickfiltertypes" "github.com/SigNoz/signoz/pkg/valuer" "github.com/uptrace/bun" "github.com/uptrace/bun/migrate" @@ -39,6 +40,52 @@ func (m *createQuickFilters) Register(migrations *migrate.Migrations) error { } func (m *createQuickFilters) Up(ctx context.Context, db *bun.DB) error { + // Frozen copy of the defaults as this migration shipped (hence the old + // camelCase keys); migrations must not read live types. 031 replaces these rows. + defaultFilters := []struct { + signal string + filters []map[string]any + }{ + {"traces", []map[string]any{ + {"key": "duration_nano", "dataType": "float64", "type": "tag"}, + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "hasError", "dataType": "bool", "type": "tag"}, + {"key": "serviceName", "dataType": "string", "type": "tag"}, + {"key": "name", "dataType": "string", "type": "resource"}, + {"key": "rpcMethod", "dataType": "string", "type": "tag"}, + {"key": "responseStatusCode", "dataType": "string", "type": "resource"}, + {"key": "httpHost", "dataType": "string", "type": "tag"}, + {"key": "httpMethod", "dataType": "string", "type": "tag"}, + {"key": "httpRoute", "dataType": "string", "type": "tag"}, + {"key": "httpUrl", "dataType": "string", "type": "tag"}, + {"key": "traceID", "dataType": "string", "type": "tag"}, + }}, + {"logs", []map[string]any{ + {"key": "severity_text", "dataType": "string", "type": "resource"}, + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "serviceName", "dataType": "string", "type": "tag"}, + {"key": "host.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.cluster.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.deployment.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.namespace.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.pod.name", "dataType": "string", "type": "resource"}, + }}, + {"api_monitoring", []map[string]any{ + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "serviceName", "dataType": "string", "type": "tag"}, + {"key": "rpcMethod", "dataType": "string", "type": "tag"}, + }}, + {"exceptions", []map[string]any{ + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "serviceName", "dataType": "string", "type": "tag"}, + {"key": "host.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.cluster.name", "dataType": "string", "type": "tag"}, + {"key": "k8s.deployment.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.namespace.name", "dataType": "string", "type": "tag"}, + {"key": "k8s.pod.name", "dataType": "string", "type": "tag"}, + }}, + } + tx, err := db.BeginTx(ctx, nil) if err != nil { return err @@ -72,15 +119,31 @@ func (m *createQuickFilters) Up(ctx context.Context, db *bun.DB) error { return err } - // Get the default quick filters - storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(defaultOrg) - if err != nil { - return err + now := time.Now() + quickFilters := make([]*quickFilter, 0, len(defaultFilters)) + for _, defaultFilter := range defaultFilters { + filterJSON, err := json.Marshal(defaultFilter.filters) + if err != nil { + return err + } + + quickFilters = append(quickFilters, &quickFilter{ + Identifiable: types.Identifiable{ + ID: valuer.GenerateUUID(), + }, + OrgID: defaultOrg.StringValue(), + Filter: string(filterJSON), + Signal: defaultFilter.signal, + TimeAuditable: types.TimeAuditable{ + CreatedAt: now, + UpdatedAt: now, + }, + }) } // Insert all filters at once _, err = tx.NewInsert(). - Model(&storableQuickFilters). + Model(&quickFilters). Exec(ctx) if err != nil { diff --git a/pkg/sqlmigration/031_update_quick_filters.go b/pkg/sqlmigration/031_update_quick_filters.go index 15241011a86..ca8621be975 100644 --- a/pkg/sqlmigration/031_update_quick_filters.go +++ b/pkg/sqlmigration/031_update_quick_filters.go @@ -3,11 +3,13 @@ package sqlmigration import ( "context" "database/sql" + "encoding/json" + "time" "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/sqlstore" - "github.com/SigNoz/signoz/pkg/types/quickfiltertypes" + "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/valuer" "github.com/uptrace/bun" "github.com/uptrace/bun/migrate" @@ -38,6 +40,61 @@ func (migration *updateQuickFilters) Register(migrations *migrate.Migrations) er } func (migration *updateQuickFilters) Up(ctx context.Context, db *bun.DB) error { + // Frozen copy of the defaults as this migration shipped; migrations must not + // read live types. api_monitoring's service.name is "tag" here — 035 fixes it. + defaultFilters := []struct { + signal string + filters []map[string]any + }{ + {"traces", []map[string]any{ + {"key": "duration_nano", "dataType": "float64", "type": "tag"}, + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "hasError", "dataType": "bool", "type": "tag"}, + {"key": "service.name", "dataType": "string", "type": "resource"}, + {"key": "name", "dataType": "string", "type": "tag"}, + {"key": "rpc.method", "dataType": "string", "type": "tag"}, + {"key": "response_status_code", "dataType": "string", "type": "tag"}, + {"key": "http_host", "dataType": "string", "type": "tag"}, + {"key": "http.method", "dataType": "string", "type": "tag"}, + {"key": "http.route", "dataType": "string", "type": "tag"}, + {"key": "http_url", "dataType": "string", "type": "tag"}, + {"key": "trace_id", "dataType": "string", "type": "tag"}, + }}, + {"logs", []map[string]any{ + {"key": "severity_text", "dataType": "string", "type": "resource"}, + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "service.name", "dataType": "string", "type": "resource"}, + {"key": "host.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.cluster.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.deployment.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.namespace.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.pod.name", "dataType": "string", "type": "resource"}, + }}, + {"api_monitoring", []map[string]any{ + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "service.name", "dataType": "string", "type": "tag"}, + {"key": "rpc.method", "dataType": "string", "type": "tag"}, + }}, + {"exceptions", []map[string]any{ + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "service.name", "dataType": "string", "type": "resource"}, + {"key": "host.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.cluster.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.deployment.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.namespace.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.pod.name", "dataType": "string", "type": "resource"}, + }}, + } + + signalFilters := make([]struct{ signal, filter string }, 0, len(defaultFilters)) + for _, defaultFilter := range defaultFilters { + filterJSON, err := json.Marshal(defaultFilter.filters) + if err != nil { + return err + } + signalFilters = append(signalFilters, struct{ signal, filter string }{defaultFilter.signal, string(filterJSON)}) + } + tx, err := db.BeginTx(ctx, nil) if err != nil { return err @@ -73,17 +130,28 @@ func (migration *updateQuickFilters) Up(ctx context.Context, db *bun.DB) error { return err } - // For each organization, create new quick filters with the updated NewDefaultQuickFilter function + // For each organization, create new quick filters with the updated defaults for _, orgID := range orgIDs { - // Get the updated default quick filters - storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(valuer.MustNewUUID(orgID)) - if err != nil { - return err + now := time.Now() + quickFilters := make([]*quickFilter, 0, len(signalFilters)) + for _, signalFilter := range signalFilters { + quickFilters = append(quickFilters, &quickFilter{ + Identifiable: types.Identifiable{ + ID: valuer.GenerateUUID(), + }, + OrgID: orgID, + Filter: signalFilter.filter, + Signal: signalFilter.signal, + TimeAuditable: types.TimeAuditable{ + CreatedAt: now, + UpdatedAt: now, + }, + }) } // Insert all filters for this organization _, err = tx.NewInsert(). - Model(&storableQuickFilters). + Model(&quickFilters). Exec(ctx) if err != nil { diff --git a/pkg/sqlmigration/035_update_api_monitoring_filters.go b/pkg/sqlmigration/035_update_api_monitoring_filters.go index a1efc607667..6de816ef267 100644 --- a/pkg/sqlmigration/035_update_api_monitoring_filters.go +++ b/pkg/sqlmigration/035_update_api_monitoring_filters.go @@ -2,20 +2,16 @@ package sqlmigration import ( "context" - "database/sql" + "encoding/json" "time" "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/sqlstore" - "github.com/SigNoz/signoz/pkg/types/quickfiltertypes" - "github.com/SigNoz/signoz/pkg/valuer" "github.com/uptrace/bun" "github.com/uptrace/bun/migrate" ) -type updateApiMonitoringFilters struct { - store sqlstore.SQLStore -} +type updateApiMonitoringFilters struct{} func NewUpdateApiMonitoringFiltersFactory(store sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] { return factory.NewProviderFactory(factory.MustNewName("update_api_monitoring_filters"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) { @@ -23,10 +19,8 @@ func NewUpdateApiMonitoringFiltersFactory(store sqlstore.SQLStore) factory.Provi }) } -func newUpdateApiMonitoringFilters(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) { - return &updateApiMonitoringFilters{ - store: store, - }, nil +func newUpdateApiMonitoringFilters(_ context.Context, _ factory.ProviderSettings, _ Config, _ sqlstore.SQLStore) (SQLMigration, error) { + return &updateApiMonitoringFilters{}, nil } func (migration *updateApiMonitoringFilters) Register(migrations *migrate.Migrations) error { @@ -38,63 +32,29 @@ func (migration *updateApiMonitoringFilters) Register(migrations *migrate.Migrat } func (migration *updateApiMonitoringFilters) Up(ctx context.Context, db *bun.DB) error { - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return err + // Frozen copy of the api_monitoring defaults as this migration shipped; the + // change over 031 is service.name moving from "tag" to "resource". + apiMonitoringFilters := []map[string]any{ + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "service.name", "dataType": "string", "type": "resource"}, + {"key": "rpc.method", "dataType": "string", "type": "tag"}, } - defer func() { - _ = tx.Rollback() - }() - - // Get all organization IDs as strings - var orgIDs []string - err = tx.NewSelect(). - Table("organizations"). - Column("id"). - Scan(ctx, &orgIDs) + apiMonitoringFilterJSON, err := json.Marshal(apiMonitoringFilters) if err != nil { - if err == sql.ErrNoRows { - if err := tx.Commit(); err != nil { - return err - } - return nil - } return err } - for _, orgID := range orgIDs { - // Get the updated default quick filters which includes the new API monitoring filters - storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(valuer.MustNewUUID(orgID)) - if err != nil { - return err - } - - // Find the API monitoring filter from the storable quick filters - var apiMonitoringFilterJSON string - for _, filter := range storableQuickFilters { - if filter.Signal == quickfiltertypes.SignalApiMonitoring { - apiMonitoringFilterJSON = filter.Filter - break - } - } - - if apiMonitoringFilterJSON != "" { - _, err = tx.NewUpdate(). - Table("quick_filter"). - Set("filter = ?, updated_at = ?", apiMonitoringFilterJSON, time.Now()). - Where("signal = ? AND org_id = ?", quickfiltertypes.SignalApiMonitoring, orgID). - Exec(ctx) - - if err != nil { - return err - } - } - } - - if err := tx.Commit(); err != nil { + // The filter JSON is org-independent, so one update covers every org's row. + _, err = db.NewUpdate(). + Table("quick_filter"). + Set("filter = ?, updated_at = ?", string(apiMonitoringFilterJSON), time.Now()). + Where("signal = ?", "api_monitoring"). + Exec(ctx) + if err != nil { return err } + return nil } From eb2d094b1f7718db157c1b96d6c40ab9ae0382ff Mon Sep 17 00:00:00 2001 From: Ashwin Bhatkal Date: Tue, 1 Sep 2026 14:07:03 +0000 Subject: [PATCH 4/4] feat(infra-monitoring): Kubernetes Containers section (#12636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### Description Adds the **Containers** section to the Kubernetes tab of Infrastructure Monitoring, breaking each pod down into the app and sidecar containers running inside it — which the Pods view rolls up into a single row — so you can tell which container in a pod is throttling, leaking memory or crash-looping. - Backed by `POST /api/v2/infra_monitoring/kube_containers` through the generated client. Filtering, grouping, time range, pagination, column customization and the instrumentation checks callout all come from the shared k8s entity framework, so this is mostly configuration rather than new machinery. - **List columns:** container name, pod, image:tag, kubectl-style status, readiness, restarts, CPU and memory usage plus request/limit utilization. Namespace, node, cluster and deployment sit behind the column selector. Grouped rows show per-status and per-readiness counts. - **Detail drawer:** ten `/v5/query_range` charts scoped to the single container, plus the logs, traces and events tabs. Events are scoped to the container's *pod*, since Kubernetes emits events per pod rather than per container. - A container's identity is the `(k8s.pod.uid, k8s.container.name)` pair — a container name alone repeats across replicas, and a container ID changes on every restart. Every other k8s entity is addressable by a single name, so the first commit widens `SelectedItemParams` with an optional container name, alongside the cluster and namespace slots that already serve that purpose. Columns and charts follow the descriptions in https://github.com/SigNoz/signoz.io/pull/3644. #### Issues closed by this PR Closes https://github.com/SigNoz/engineering-pod/issues/5547 #### Additional Information - Reviewed best commit by commit: identity foundation, then shared constants/helpers, then the entity itself. Each stands on its own. --- frontend/src/constants/events.ts | 1 + .../Base/ColumnHeader.tsx | 5 +- .../Base/EntityGroupHeader.module.scss | 1 - .../Base/EntityGroupHeader.tsx | 5 +- .../Base/K8sBaseDetails.tsx | 34 +- .../Base/K8sBaseDetailsContent.tsx | 47 +- .../InfraMonitoringK8sV2/Base/K8sBaseList.tsx | 7 + .../Base/K8sExpandedRow.module.scss | 6 + .../Base/K8sExpandedRow.tsx | 1 + .../EntityMetadataItem.module.scss | 44 ++ .../EntityMetadataItem/EntityMetadataItem.tsx | 63 +++ .../__tests__/EntityMetadataItem.test.tsx | 110 ++++ .../Base/entity.registry.ts | 2 + .../Containers/__tests__/containers.test.ts | 142 ++++++ .../Containers/constants.ts | 170 +++++++ .../Containers/entity.config.ts | 140 ++++++ .../Containers/metrics.ts | 275 ++++++++++ .../Containers/table.config.tsx | 470 ++++++++++++++++++ .../InfraMonitoringK8sV2/Containers/utils.ts | 169 +++++++ .../EntityMetrics/ChartHeader.tsx | 5 +- .../entityDetails.module.scss | 36 +- .../InfraMonitoringK8s.tsx | 8 + .../InfraMonitoringK8sV2/constants.ts | 179 ++++++- .../container/InfraMonitoringK8sV2/hooks.ts | 14 +- .../InfrastructureMonitoring/constants.tsx | 6 +- 25 files changed, 1849 insertions(+), 91 deletions(-) create mode 100644 frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.module.scss create mode 100644 frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.tsx create mode 100644 frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/__tests__/EntityMetadataItem.test.tsx create mode 100644 frontend/src/container/InfraMonitoringK8sV2/Containers/__tests__/containers.test.ts create mode 100644 frontend/src/container/InfraMonitoringK8sV2/Containers/constants.ts create mode 100644 frontend/src/container/InfraMonitoringK8sV2/Containers/entity.config.ts create mode 100644 frontend/src/container/InfraMonitoringK8sV2/Containers/metrics.ts create mode 100644 frontend/src/container/InfraMonitoringK8sV2/Containers/table.config.tsx create mode 100644 frontend/src/container/InfraMonitoringK8sV2/Containers/utils.ts diff --git a/frontend/src/constants/events.ts b/frontend/src/constants/events.ts index baa4c573af1..5f5a21feed1 100644 --- a/frontend/src/constants/events.ts +++ b/frontend/src/constants/events.ts @@ -29,6 +29,7 @@ export enum InfraMonitoringEvents { MetricsView = 'metrics', Total = 'total', Cluster = 'cluster', + Container = 'container', DaemonSet = 'daemonSet', Deployment = 'deployment', Job = 'job', diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/ColumnHeader.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/ColumnHeader.tsx index d0a97a9e3ea..379254eb7e9 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/Base/ColumnHeader.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/ColumnHeader.tsx @@ -4,8 +4,9 @@ import { TooltipSimple } from '@signozhq/ui/tooltip'; import styles from './ColumnHeader.module.scss'; import cx from 'classnames'; import { MouseEventHandler } from 'react'; +import { DOCS_BASE_URL } from 'constants/app'; -const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`; +const DOCS_ROOT = `${DOCS_BASE_URL}/docs`; interface ColumnHeaderProps { children?: React.ReactNode; @@ -43,7 +44,7 @@ function ColumnHeader({
{tooltipTitle}{' '} {tooltipTitle}{' '} e.stopPropagation()} diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetails.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetails.tsx index 04b68accb3a..5323ce3e8f4 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetails.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetails.tsx @@ -1,12 +1,10 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useQuery } from 'react-query'; -import { useCopyToClipboard } from 'react-use'; -import { Copy, X } from '@signozhq/icons'; +import { X } from '@signozhq/icons'; import { Divider } from '@signozhq/ui/divider'; import { Button } from '@signozhq/ui/button'; import { DrawerWrapper, DrawerWrapperProps } from '@signozhq/ui/drawer'; import { toast } from '@signozhq/ui/sonner'; -import { TooltipSimple } from '@signozhq/ui/tooltip'; import { Typography } from '@signozhq/ui/typography'; import logEvent from 'api/common/logEvent'; import ErrorContent from 'components/ErrorModal/components/ErrorContent'; @@ -20,6 +18,7 @@ import { import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../constants'; import { useInfraMonitoringSelectedItemParams } from '../hooks'; +import CopyButton from 'periscope/components/CopyButton/CopyButton'; import LoadingContainer from '../LoadingContainer'; import K8sBaseDetailsContent from './K8sBaseDetailsContent'; @@ -95,12 +94,14 @@ export default function K8sBaseDetails({ selectedItem, selectedItemParams.clusterName, selectedItemParams.namespaceName, + selectedItemParams.containerName, ), [ queryKeyPrefix, selectedItem, selectedItemParams.clusterName, selectedItemParams.namespaceName, + selectedItemParams.containerName, selectedTime, getAutoRefreshQueryKey, ], @@ -170,14 +171,9 @@ export default function K8sBaseDetails({ [handleClose], ); - const [, copyToClipboard] = useCopyToClipboard(); - const handleCopyId = useCallback((): void => { - if (selectedItem) { - copyToClipboard(selectedItem); - toast.success('ID copied to clipboard', { position: 'bottom-left' }); - } - }, [copyToClipboard, selectedItem]); + toast.success('ID copied to clipboard', { position: 'bottom-left' }); + }, []); const entityName = entity ? getEntityName(entity) : ''; @@ -211,17 +207,13 @@ export default function K8sBaseDetails({ (isEntityLoading && 'Loading...') || '-'} - - - + ) as unknown as string; diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetailsContent.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetailsContent.tsx index 89743fd8885..1d56bb24b68 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetailsContent.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetailsContent.tsx @@ -9,7 +9,6 @@ import { import { Button } from '@signozhq/ui/button'; import { ToggleGroupSimple } from '@signozhq/ui/toggle-group'; import { TooltipSimple } from '@signozhq/ui/tooltip'; -import { Typography } from '@signozhq/ui/typography'; import logEvent from 'api/common/logEvent'; import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils'; import { InfraMonitoringEvents } from 'constants/events'; @@ -42,6 +41,7 @@ import { } from '../hooks'; import { EntityCountsSection } from './components/EntityCountsSection/EntityCountsSection'; +import { EntityMetadataItem } from './components/EntityMetadataItem/EntityMetadataItem'; import { K8sBaseDetailsContentProps } from './types'; import { getDrawerDurationMs } from './useDrawerLifecycleStore'; @@ -239,41 +239,18 @@ export default function K8sBaseDetailsContent({ <>
-
- {metadataConfig.map((config) => ( - - {config.label} - - ))} -
- -
- {metadataConfig.map((config) => { - const value = config.getValue(entity); + {metadataConfig.map((config) => { + const value = config.getValue(entity); - if (config.render) { - return config.render(value, entity); - } - - const displayValue = String(value); - return ( - - {displayValue} - - ); - })} -
+ return ( + + ); + })}
{countsConfig && diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx index 82e65cdc19d..5fd324bc37f 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx @@ -296,6 +296,7 @@ export function K8sBaseList< params.selectedItem, params.clusterName, params.namespaceName, + params.containerName, ); queryClient.setQueryData(detailQueryKey, { data: record }); } @@ -348,6 +349,12 @@ export function K8sBaseList< params.namespaceName, ); } + if (params.containerName) { + url.searchParams.set( + INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME, + params.containerName, + ); + } } else { url.searchParams.set( INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM, diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.module.scss b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.module.scss index b118df1e251..f65f0eabee3 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.module.scss +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.module.scss @@ -19,6 +19,12 @@ & [data-hide-expanded='true'] { display: none; } + + // The icon slot is rendered even when the icon inside it is hidden, and the + // header's flex gap then indents the title past the values below it. + & [data-slot='icon']:has([data-hide-expanded='true']) { + display: none; + } } .expandedTableFooter { diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.tsx index 728a1f5d8e0..3c327ccbd3b 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.tsx @@ -214,6 +214,7 @@ export function K8sExpandedRow< params.selectedItem, params.clusterName, params.namespaceName, + params.containerName, ); queryClient.setQueryData(detailQueryKey, { data: row }); } diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.module.scss b/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.module.scss new file mode 100644 index 00000000000..012a5718f6a --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.module.scss @@ -0,0 +1,44 @@ +.metadataItem { + display: flex; + flex-direction: column; + gap: var(--spacing-1); + min-width: 0; +} + +.valueRow { + display: flex; + align-items: center; + gap: var(--spacing-1); + min-width: 0; +} + +.label { + letter-spacing: 0.44px; + text-transform: uppercase; +} + +// Single-line ellipsis rather than Typography's `truncate`, which line-clamps: +// clamping still wraps the text, so a value breaking at a hyphen ends its line +// early and leaves a gap between the ellipsis and the copy button. +// +// This has to be a block: overflow and text-overflow do not apply to inline +// boxes, and Typography.Text renders an inline span. +.value { + display: block; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.valueText { + font-family: var(--periscope-font-family-mono); +} + +// Sized to the icon rather than the default 2rem tap target, so it sits flush +// against the value instead of floating in its own block of padding. +.copyButton { + --button-padding: 2px; + --button-height: auto; + --button-width: auto; +} diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.tsx new file mode 100644 index 00000000000..0993b419f36 --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.tsx @@ -0,0 +1,63 @@ +import { ReactNode, useCallback } from 'react'; +import { toast } from '@signozhq/ui/sonner'; +import { TooltipSimple } from '@signozhq/ui/tooltip'; +import { Typography } from '@signozhq/ui/typography'; +import CopyButton from 'periscope/components/CopyButton/CopyButton'; + +import styles from './EntityMetadataItem.module.scss'; + +export interface EntityMetadataItemProps { + label: string; + value: string; + /** An entity-supplied renderer, which opts out of clamping, tooltip and copy. */ + renderedValue?: ReactNode; +} + +export function EntityMetadataItem({ + label, + value, + renderedValue, +}: EntityMetadataItemProps): JSX.Element { + const handleCopy = useCallback((): void => { + toast.success(`${label} copied to clipboard`, { position: 'bottom-left' }); + }, [label]); + + return ( +
+ + {label} + + + {renderedValue ?? ( +
+ + + + {value} + + + + {!!value && ( + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/__tests__/EntityMetadataItem.test.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/__tests__/EntityMetadataItem.test.tsx new file mode 100644 index 00000000000..cdfb0a19480 --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/__tests__/EntityMetadataItem.test.tsx @@ -0,0 +1,110 @@ +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; + +import { EntityMetadataItem } from '../EntityMetadataItem'; + +const mockCopyToClipboard = jest.fn(); + +jest.mock('react-use', () => ({ + __esModule: true, + useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopyToClipboard], +})); + +const mockToastSuccess = jest.fn(); + +jest.mock('@signozhq/ui/sonner', () => ({ + ...jest.requireActual('@signozhq/ui/sonner'), + toast: { + success: (...args: unknown[]): unknown => mockToastSuccess(...args), + }, +})); + +describe('EntityMetadataItem', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the label and its value', () => { + render(); + + expect(screen.getByText('Cluster Name')).toBeInTheDocument(); + expect(screen.getByText('prod-cluster')).toBeInTheDocument(); + }); + + it('copies the full value and confirms which field was copied', async () => { + render( + , + ); + + await userEvent.click(screen.getByTestId('copy-metadata-image:tag')); + + await waitFor(() => { + expect(mockCopyToClipboard).toHaveBeenCalledWith( + 'ghcr.io/open-telemetry/demo:1.12.0-loadgenerator', + ); + }); + expect(mockToastSuccess).toHaveBeenCalledWith( + 'Image:Tag copied to clipboard', + expect.anything(), + ); + }); + + it('offers no copy control when the value is empty', () => { + render(); + + expect(screen.queryByTestId('copy-metadata-node')).not.toBeInTheDocument(); + }); + + it('exposes the full value on hover', async () => { + render( + , + ); + + await userEvent.hover( + screen.getByText('gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-zv4t'), + ); + + await waitFor(() => { + expect( + screen.getAllByText('gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-zv4t').length, + ).toBeGreaterThan(1); + }); + }); + + it('never presents the value as clickable', () => { + render(); + + const valueEl = screen.getByText('a-very-long-node-name'); + expect(valueEl).not.toHaveAttribute('data-interactive'); + expect(valueEl).not.toHaveAttribute('data-truncate'); + }); + + it('triggers the tooltip from the wrapper, never from the text itself', () => { + render(); + + // Radix merges its handlers onto the trigger, and Typography styles + // itself interactive off any merged onClick — so the trigger has to stay + // off the text. + const textEl = screen.getByText('a-very-long-node-name'); + expect(textEl).not.toHaveAttribute('data-slot', 'tooltip-trigger'); + expect(textEl.parentElement).toHaveAttribute('data-slot', 'tooltip-trigger'); + }); + + it('leaves an entity-supplied renderer alone', () => { + render( + custom node} + />, + ); + + expect(screen.getByTestId('custom')).toBeInTheDocument(); + expect(screen.queryByTestId('copy-metadata-status')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/entity.registry.ts b/frontend/src/container/InfraMonitoringK8sV2/Base/entity.registry.ts index 9783aa3c150..a5cbeb1846e 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/Base/entity.registry.ts +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/entity.registry.ts @@ -11,6 +11,7 @@ import { jobEntityConfig } from '../Jobs/entity.config'; import { daemonSetEntityConfig } from '../DaemonSets/entity.config'; import { statefulSetEntityConfig } from '../StatefulSets/entity.config'; import { volumeEntityConfig } from '../Volumes/entity.config'; +import { containerEntityConfig } from '../Containers/entity.config'; type AnyEntityConfig = K8sEntityConfig< K8sEntityData, @@ -34,6 +35,7 @@ export const entityRegistry: Record = { [K8sCategories.DAEMONSETS]: registerConfig(daemonSetEntityConfig), [K8sCategories.STATEFULSETS]: registerConfig(statefulSetEntityConfig), [K8sCategories.VOLUMES]: registerConfig(volumeEntityConfig), + [K8sCategories.CONTAINERS]: registerConfig(containerEntityConfig), }; export function getEntityConfig(category: string): AnyEntityConfig | undefined { diff --git a/frontend/src/container/InfraMonitoringK8sV2/Containers/__tests__/containers.test.ts b/frontend/src/container/InfraMonitoringK8sV2/Containers/__tests__/containers.test.ts new file mode 100644 index 00000000000..e11e344bfa9 --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/Containers/__tests__/containers.test.ts @@ -0,0 +1,142 @@ +import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas'; +import { + k8sContainerGetSelectedItemExpression, + k8sContainerInitialEventsExpression, + k8sContainerInitialLogTracesExpression, +} from 'container/InfraMonitoringK8sV2/Containers/constants'; +import { getContainerMetricsQueryPayload } from 'container/InfraMonitoringK8sV2/Containers/metrics'; +import { + getK8sContainerItemKey, + getK8sContainerRowKey, +} from 'container/InfraMonitoringK8sV2/Containers/table.config'; +import { getContainerImageWithTag } from 'container/InfraMonitoringK8sV2/Containers/utils'; + +function makeContainer( + overrides: Partial = {}, +): InframonitoringtypesContainerRecordDTO { + return { + containerName: 'nginx', + podUID: 'pod-uid-1', + meta: { + 'k8s.container.name': 'nginx', + 'k8s.pod.uid': 'pod-uid-1', + 'k8s.pod.name': 'web-0', + 'k8s.namespace.name': 'production', + 'k8s.cluster.name': 'prod-cluster', + 'container.image.name': 'nginx', + 'container.image.tag': '1.27', + }, + ...overrides, + } as InframonitoringtypesContainerRecordDTO; +} + +describe('container identity', () => { + it('keys a row by the (pod UID, container name) pair', () => { + expect(getK8sContainerRowKey(makeContainer())).toBe('pod-uid-1/nginx'); + }); + + it('carries the container name alongside the pod UID into the drawer params', () => { + expect(getK8sContainerItemKey(makeContainer())).toStrictEqual({ + selectedItem: 'pod-uid-1', + containerName: 'nginx', + clusterName: null, + namespaceName: null, + }); + }); + + it('falls back to meta when the record fields are empty', () => { + const container = makeContainer({ containerName: '', podUID: '' }); + + expect(getK8sContainerItemKey(container)).toStrictEqual({ + selectedItem: 'pod-uid-1', + containerName: 'nginx', + clusterName: null, + namespaceName: null, + }); + }); + + it('scopes the details fetch to both halves of the identity', () => { + expect( + k8sContainerGetSelectedItemExpression({ + selectedItem: 'pod-uid-1', + containerName: 'nginx', + }), + ).toBe("k8s.pod.uid = 'pod-uid-1' AND k8s.container.name = 'nginx'"); + }); +}); + +describe('getContainerImageWithTag', () => { + it('renders name and tag together', () => { + expect(getContainerImageWithTag(makeContainer())).toBe('nginx:1.27'); + }); + + it('drops the tag when the image is not pinned', () => { + const container = makeContainer({ + meta: { 'container.image.name': 'nginx' }, + }); + + expect(getContainerImageWithTag(container)).toBe('nginx'); + }); + + it('renders nothing when the image name is missing', () => { + expect(getContainerImageWithTag(makeContainer({ meta: {} }))).toBe(''); + }); +}); + +describe('container drawer expressions', () => { + it('scopes logs and traces to the container within its pod', () => { + expect(k8sContainerInitialLogTracesExpression(makeContainer())).toBe( + "k8s.pod.uid = 'pod-uid-1' AND k8s.cluster.name = 'prod-cluster' AND k8s.namespace.name = 'production' AND k8s.container.name = 'nginx'", + ); + }); + + it('scopes events to the pod, since k8s emits events per pod', () => { + expect(k8sContainerInitialEventsExpression(makeContainer())).toBe( + "k8s.object.kind = 'Pod' AND k8s.object.name = 'web-0' AND k8s.cluster.name = 'prod-cluster' AND attribute.k8s.namespace.name = 'production'", + ); + }); +}); + +describe('getContainerMetricsQueryPayload', () => { + const payloads = getContainerMetricsQueryPayload(makeContainer(), 1000, 2000); + + it('returns one payload per documented chart', () => { + expect(payloads).toHaveLength(10); + }); + + it('scopes every query to the selected container', () => { + payloads.forEach((payload) => { + payload.query.builder.queryData.forEach((query) => { + expect(query.filters?.items).toStrictEqual([ + expect.objectContaining({ + key: expect.objectContaining({ key: 'k8s.pod.uid' }), + op: '=', + value: 'pod-uid-1', + }), + expect.objectContaining({ + key: expect.objectContaining({ key: 'k8s.container.name' }), + op: '=', + value: 'nginx', + }), + ]); + }); + }); + }); + + it('derives cache memory from the working set and RSS queries', () => { + const memoryByState = payloads[4]; + + expect( + memoryByState.query.builder.queryData.map((query) => [ + query.queryName, + query.aggregateAttribute?.key, + ]), + ).toStrictEqual([ + ['A', 'container.memory.rss'], + ['B', 'container.memory.working_set'], + ]); + expect(memoryByState.query.builder.queryFormulas).toStrictEqual([ + expect.objectContaining({ expression: 'B - A', legend: 'Cache Memory' }), + ]); + }); +}); diff --git a/frontend/src/container/InfraMonitoringK8sV2/Containers/constants.ts b/frontend/src/container/InfraMonitoringK8sV2/Containers/constants.ts new file mode 100644 index 00000000000..f98440c48a4 --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/Containers/constants.ts @@ -0,0 +1,170 @@ +import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas'; +import { formatValueForExpression } from 'components/QueryBuilderV2/utils'; + +import { + buildEventsExpression, + buildLogsTracesExpression, +} from '../Base/utils'; +import { K8sDetailsMetadataConfig, K8sDetailsWidgetInfo } from '../Base/types'; +import { INFRA_MONITORING_ATTR_KEYS } from '../constants'; +import { SelectedItemParams } from '../hooks'; +import { + CONTAINERS_DOC_PATH, + getContainerImageWithTag, + getContainerName, + getContainerPodUID, +} from './utils'; + +/** A container row is identified by the (pod UID, container name) pair. */ +export const k8sContainerGetSelectedItemExpression = ( + params: SelectedItemParams, +): string => + [ + `${INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID} = ${formatValueForExpression( + params.selectedItem ?? '', + )}`, + `${INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME} = ${formatValueForExpression( + params.containerName ?? '', + )}`, + ].join(' AND '); + +export const k8sContainerGetEntityName = getContainerName; + +export const k8sContainerDetailsMetadataConfig: K8sDetailsMetadataConfig[] = + [ + { + label: 'Pod', + getValue: (c): string => + c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '', + }, + { + label: 'NAMESPACE', + getValue: (c): string => + c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] || '', + }, + { + label: 'Node', + getValue: (c): string => + c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME] || '', + }, + { + label: 'Cluster Name', + getValue: (c): string => + c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] || '', + }, + { + label: 'Image:Tag', + getValue: getContainerImageWithTag, + }, + ]; + +export const k8sContainerInitialLogTracesExpression = ( + container: InframonitoringtypesContainerRecordDTO, +): string => { + const base = buildLogsTracesExpression({ + mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID, + mainAttributeValue: getContainerPodUID(container), + clusterName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME], + namespaceName: + container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME], + }); + + const containerName = getContainerName(container); + if (!containerName) { + return base; + } + + const containerClause = `${ + INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME + } = ${formatValueForExpression(containerName)}`; + + return base ? `${base} AND ${containerClause}` : containerClause; +}; + +/** + * Kubernetes emits events against the pod, not the container, so the events tab + * is scoped to the container's pod. + */ +export const k8sContainerInitialEventsExpression = ( + container: InframonitoringtypesContainerRecordDTO, +): string => + buildEventsExpression({ + objectKind: 'Pod', + objectName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '', + clusterName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME], + namespaceName: + container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME], + }); + +export const containerWidgetInfo: K8sDetailsWidgetInfo[] = [ + { + title: 'CPU Usage (cores)', + yAxisUnit: '', + docPath: `${CONTAINERS_DOC_PATH}#cpu-usage-cores-1`, + description: + 'Avg, max and min CPU consumption of the container in cores, showing how bursty it is.', + }, + { + title: 'CPU Request, Limit Utilization', + yAxisUnit: 'percentunit', + docPath: `${CONTAINERS_DOC_PATH}#cpu-request-limit-utilization`, + description: + 'Container CPU usage as a fraction of its own CPU request and limit; limit lines near 100% mean throttling.', + }, + { + title: 'Memory Usage (bytes)', + yAxisUnit: 'bytes', + docPath: `${CONTAINERS_DOC_PATH}#memory-usage-bytes`, + description: + 'Total memory charged to the container, including reclaimable page cache, against the headroom left before its limit.', + }, + { + title: 'Memory Request, Limit Utilization', + yAxisUnit: 'percentunit', + docPath: `${CONTAINERS_DOC_PATH}#memory-request-limit-utilization`, + description: + 'Container memory usage as a fraction of its own memory request and limit; limit lines near 100% risk an OOMKill.', + }, + { + title: 'Memory by State', + yAxisUnit: 'bytes', + docPath: `${CONTAINERS_DOC_PATH}#memory-by-state`, + description: + 'RSS, working set and cache memory of the container, separating heap growth from file cache.', + }, + { + title: 'Memory Major Page Faults', + yAxisUnit: '', + docPath: `${CONTAINERS_DOC_PATH}#memory-major-page-faults`, + description: + 'Major page fault rate of the container; sustained values mean the working set is paging to disk.', + }, + { + title: 'File System (bytes)', + yAxisUnit: 'bytes', + docPath: `${CONTAINERS_DOC_PATH}#file-system-bytes`, + description: + 'Capacity, available and used bytes of the container filesystem.', + }, + { + title: 'Container Uptime', + yAxisUnit: 's', + docPath: `${CONTAINERS_DOC_PATH}#container-uptime`, + description: + 'Time since the container last started; a sawtooth of resets means it is restarting repeatedly.', + }, + { + title: 'Node CPU Utilization by Container', + yAxisUnit: 'percentunit', + docPath: `${CONTAINERS_DOC_PATH}#node-cpu-utilization-by-container`, + description: + "The container's CPU usage as a fraction of the whole node's capacity, to spot noisy neighbours.", + }, + { + title: 'Node Memory Utilization by Container', + yAxisUnit: 'percentunit', + docPath: `${CONTAINERS_DOC_PATH}#node-memory-utilization-by-container`, + description: + "The container's memory usage as a fraction of the whole node's capacity.", + }, +]; diff --git a/frontend/src/container/InfraMonitoringK8sV2/Containers/entity.config.ts b/frontend/src/container/InfraMonitoringK8sV2/Containers/entity.config.ts new file mode 100644 index 00000000000..95f33ca214c --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/Containers/entity.config.ts @@ -0,0 +1,140 @@ +import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs'; +import { listContainers } from 'api/generated/services/inframonitoring'; +import { + InframonitoringtypesContainerRecordDTO, + InframonitoringtypesResponseTypeDTO, + Querybuildertypesv5OrderDirectionDTO, + RenderErrorResponseDTO, +} from 'api/generated/services/sigNoz.schemas'; +import { AxiosError } from 'axios'; +import { InfraMonitoringEvents } from 'constants/events'; + +import { K8sEntityConfig } from '../Base/entity.config.types'; +import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types'; +import { InfraMonitoringEntity } from '../constants'; +import { SelectedItemParams } from '../hooks'; +import { + containerWidgetInfo, + k8sContainerDetailsMetadataConfig, + k8sContainerGetEntityName, + k8sContainerGetSelectedItemExpression, + k8sContainerInitialEventsExpression, + k8sContainerInitialLogTracesExpression, +} from './constants'; +import { getContainerMetricsQueryPayload } from './metrics'; +import { + getK8sContainerItemKey, + getK8sContainerRowKey, + k8sContainerColumnsConfig, +} from './table.config'; + +async function fetchListData( + filters: K8sBaseFilters, + signal?: AbortSignal, +): ReturnType< + K8sEntityConfig< + InframonitoringtypesContainerRecordDTO, + SelectedItemParams + >['list']['fetchListData'] +> { + try { + const response = await listContainers( + { + filter: { expression: filters.filter.expression }, + groupBy: filters.groupBy?.map((g) => ({ name: g.name })), + offset: filters.offset, + limit: filters.limit ?? 10, + start: filters.start, + end: filters.end, + orderBy: filters.orderBy + ? { + key: { name: filters.orderBy.key.name }, + direction: + filters.orderBy.direction === 'asc' + ? Querybuildertypesv5OrderDirectionDTO.asc + : Querybuildertypesv5OrderDirectionDTO.desc, + } + : undefined, + }, + signal, + ); + + const data = response.data; + return { + type: + data.type === InframonitoringtypesResponseTypeDTO.grouped_list + ? ('grouped_list' as const) + : ('list' as const), + records: data.records, + total: data.total, + endTimeBeforeRetention: data.endTimeBeforeRetention, + warning: data.warning, + }; + } catch (error) { + return { + type: 'list' as const, + records: [] as InframonitoringtypesContainerRecordDTO[], + total: 0, + error: + convertToApiError(error as AxiosError) ?? null, + }; + } +} + +async function fetchEntityData( + filters: K8sDetailsFilters, + signal?: AbortSignal, +): ReturnType< + K8sEntityConfig['details']['fetchEntityData'] +> { + try { + const response = await listContainers( + { + filter: { expression: filters.filter.expression }, + start: filters.start, + end: filters.end, + limit: 1, + offset: 0, + }, + signal, + ); + + return { + data: response.data.records.length > 0 ? response.data.records[0] : null, + }; + } catch (error) { + return { + data: null, + error: + convertToApiError(error as AxiosError) ?? null, + }; + } +} + +export const containerEntityConfig: K8sEntityConfig< + InframonitoringtypesContainerRecordDTO, + SelectedItemParams +> = { + list: { + entity: InfraMonitoringEntity.CONTAINERS, + eventCategory: InfraMonitoringEvents.Container, + tableColumns: k8sContainerColumnsConfig, + fetchListData, + getRowKey: getK8sContainerRowKey, + getItemKey: getK8sContainerItemKey, + detailsQueryKeyPrefix: 'container', + }, + details: { + category: InfraMonitoringEntity.CONTAINERS, + eventCategory: InfraMonitoringEvents.Container, + queryKeyPrefix: 'container', + getSelectedItemExpression: k8sContainerGetSelectedItemExpression, + fetchEntityData, + getEntityName: k8sContainerGetEntityName, + getInitialLogTracesExpression: k8sContainerInitialLogTracesExpression, + getInitialEventsExpression: k8sContainerInitialEventsExpression, + metadataConfig: k8sContainerDetailsMetadataConfig, + entityWidgetInfo: containerWidgetInfo, + getEntityQueryPayload: getContainerMetricsQueryPayload, + }, +}; diff --git a/frontend/src/container/InfraMonitoringK8sV2/Containers/metrics.ts b/frontend/src/container/InfraMonitoringK8sV2/Containers/metrics.ts new file mode 100644 index 00000000000..6fd9cb24dab --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/Containers/metrics.ts @@ -0,0 +1,275 @@ +import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas'; +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; +import { EQueryType } from 'types/common/dashboard'; +import { DataSource, ReduceOperators } from 'types/common/queryBuilder'; +import { v4 } from 'uuid'; + +import { INFRA_MONITORING_ATTR_KEYS } from '../constants'; +import { getContainerName, getContainerPodUID } from './utils'; + +const QUERY_NAMES = ['A', 'B', 'C', 'D', 'E', 'F']; +const STEP_INTERVAL = 60; + +type TimeAggregation = 'avg' | 'max' | 'min' | 'latest'; +type SpaceAggregation = 'sum' | 'avg' | 'max'; + +interface SeriesSpec { + metricKey: string; + legend: string; + timeAggregation: TimeAggregation; + spaceAggregation: SpaceAggregation; +} + +interface FormulaSpec { + expression: string; + legend: string; +} + +/** + * Every panel is scoped to a single container by the (k8s.pod.uid, + * k8s.container.name) pair that identifies its row in the list. + */ +function buildScopeFilters( + container: InframonitoringtypesContainerRecordDTO, +): TagFilter { + return { + items: [ + { + id: 'pod-uid', + key: { + dataType: DataTypes.String, + id: `k8s_pod_uid--string--tag--false`, + key: INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID, + type: 'tag', + }, + op: '=', + value: getContainerPodUID(container), + }, + { + id: 'container-name', + key: { + dataType: DataTypes.String, + id: `k8s_container_name--string--tag--false`, + key: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME, + type: 'tag', + }, + op: '=', + value: getContainerName(container), + }, + ], + op: 'AND', + }; +} + +function buildQuery( + container: InframonitoringtypesContainerRecordDTO, + start: number, + end: number, + series: SeriesSpec[], + formulas: FormulaSpec[] = [], +): GetQueryResultsProps { + const filters = buildScopeFilters(container); + + return { + selectedTime: 'GLOBAL_TIME', + graphType: PANEL_TYPES.TIME_SERIES, + query: { + builder: { + queryData: series.map((spec, index) => ({ + aggregateAttribute: { + dataType: DataTypes.Float64, + id: `${spec.metricKey.replace(/\./g, '_')}--float64--Gauge--true`, + key: spec.metricKey, + type: 'Gauge', + }, + aggregateOperator: spec.timeAggregation, + dataSource: DataSource.METRICS, + disabled: false, + expression: QUERY_NAMES[index], + filters, + functions: [], + groupBy: [], + having: [], + legend: spec.legend, + limit: null, + orderBy: [], + queryName: QUERY_NAMES[index], + reduceTo: ReduceOperators.AVG, + spaceAggregation: spec.spaceAggregation, + stepInterval: STEP_INTERVAL, + timeAggregation: spec.timeAggregation, + })), + queryFormulas: formulas.map((formula, index) => ({ + disabled: false, + expression: formula.expression, + legend: formula.legend, + queryName: `F${index + 1}`, + })), + queryTraceOperator: [], + }, + clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }], + id: v4(), + promql: [{ disabled: false, legend: '', name: 'A', query: '' }], + queryType: EQueryType.QUERY_BUILDER, + }, + variables: {}, + formatForWeb: false, + start, + end, + }; +} + +/** Absolute usage metrics are summed across series, ratios are averaged. */ +function usageSeries(metricKey: string, legendPrefix = ''): SeriesSpec[] { + const prefix = legendPrefix ? `${legendPrefix} - ` : ''; + return [ + { + metricKey, + legend: `${prefix}Avg`, + timeAggregation: 'avg', + spaceAggregation: 'sum', + }, + { + metricKey, + legend: `${prefix}Max`, + timeAggregation: 'max', + spaceAggregation: 'sum', + }, + { + metricKey, + legend: `${prefix}Min`, + timeAggregation: 'min', + spaceAggregation: 'sum', + }, + ]; +} + +function utilizationSeries( + metricKey: string, + legendPrefix: string, +): SeriesSpec[] { + return (['avg', 'max', 'min'] as TimeAggregation[]).map((timeAggregation) => ({ + metricKey, + legend: `${legendPrefix} - ${ + timeAggregation.charAt(0).toUpperCase() + timeAggregation.slice(1) + }`, + timeAggregation, + spaceAggregation: 'avg' as const, + })); +} + +export const getContainerMetricsQueryPayload = ( + container: InframonitoringtypesContainerRecordDTO, + start: number, + end: number, +): GetQueryResultsProps[] => { + const query = ( + series: SeriesSpec[], + formulas?: FormulaSpec[], + ): GetQueryResultsProps => buildQuery(container, start, end, series, formulas); + + return [ + query(usageSeries(INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE)), + query([ + ...utilizationSeries( + INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_REQUEST_UTILIZATION, + 'Request util %', + ), + ...utilizationSeries( + INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_LIMIT_UTILIZATION, + 'Limit util %', + ), + ]), + query([ + ...usageSeries(INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_USAGE, 'Usage'), + { + metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_AVAILABLE, + legend: 'Available', + timeAggregation: 'avg', + spaceAggregation: 'sum', + }, + ]), + query([ + ...utilizationSeries( + INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_REQUEST_UTILIZATION, + 'Request util %', + ), + ...utilizationSeries( + INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION, + 'Limit util %', + ), + ]), + query( + [ + { + metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_RSS, + legend: 'RSS Memory', + timeAggregation: 'avg', + spaceAggregation: 'sum', + }, + { + metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_WORKING_SET, + legend: 'Working Set Memory', + timeAggregation: 'avg', + spaceAggregation: 'sum', + }, + ], + [{ expression: 'B - A', legend: 'Cache Memory' }], + ), + query([ + { + metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_MAJOR_PAGE_FAULTS, + legend: 'Major Page Faults', + timeAggregation: 'avg', + spaceAggregation: 'sum', + }, + ]), + query([ + { + metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_CAPACITY, + legend: 'Capacity', + timeAggregation: 'avg', + spaceAggregation: 'sum', + }, + { + metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_AVAILABLE, + legend: 'Available', + timeAggregation: 'avg', + spaceAggregation: 'sum', + }, + { + metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_USAGE, + legend: 'Usage', + timeAggregation: 'avg', + spaceAggregation: 'sum', + }, + ]), + query([ + { + metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_UPTIME, + legend: 'Uptime', + timeAggregation: 'latest', + spaceAggregation: 'sum', + }, + ]), + query([ + { + metricKey: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_NODE_UTILIZATION, + legend: 'Node CPU Utilization', + timeAggregation: 'avg', + spaceAggregation: 'avg', + }, + ]), + query([ + { + metricKey: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_NODE_UTILIZATION, + legend: 'Node Memory Utilization', + timeAggregation: 'avg', + spaceAggregation: 'avg', + }, + ]), + ]; +}; diff --git a/frontend/src/container/InfraMonitoringK8sV2/Containers/table.config.tsx b/frontend/src/container/InfraMonitoringK8sV2/Containers/table.config.tsx new file mode 100644 index 00000000000..9b4c1ecc7bb --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/Containers/table.config.tsx @@ -0,0 +1,470 @@ +import { Container } from '@signozhq/icons'; +import { Badge } from '@signozhq/ui/badge'; +import { + InframonitoringtypesContainerReadyDTO, + InframonitoringtypesContainerRecordDTO, + InframonitoringtypesContainerStatusDTO, +} from 'api/generated/services/sigNoz.schemas'; +import TanStackTable, { TableColumnDef } from 'components/TanStackTableView'; +import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components'; + +import ColumnHeader from '../Base/ColumnHeader'; +import EntityGroupHeader from '../Base/EntityGroupHeader'; +import K8sGroupCell from '../Base/K8sGroupCell'; +import { formatBytes } from '../commonUtils'; +import { + EntityProgressBar, + EntityProgressThresholds, + GroupedStatusCounts, + TextNoData, + ValidateColumnValueWrapper, +} from '../components'; +import { + INFRA_MONITORING_ATTR_KEYS, + InfraMonitoringEntity, +} from '../constants'; +import { SelectedItemParams } from '../hooks'; +import { + CONTAINER_READY_COLORS, + CONTAINER_READY_LABELS, + CONTAINER_STATUS_COLORS, + CONTAINER_STATUS_LABELS, + CONTAINERS_DOC_PATH, + getContainerImageWithTag, + getContainerReadyItems, + getContainerStatusItems, +} from './utils'; + +export function getK8sContainerRowKey( + container: InframonitoringtypesContainerRecordDTO, +): string { + return ( + [container.podUID, container.containerName].filter(Boolean).join('/') || + container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] || + '' + ); +} + +export function getK8sContainerItemKey( + container: InframonitoringtypesContainerRecordDTO, +): SelectedItemParams { + return { + selectedItem: + container.podUID || + container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID] || + null, + containerName: + container.containerName || + container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] || + null, + clusterName: null, + namespaceName: null, + }; +} + +export type ContainerTableColumnConfig = + TableColumnDef; + +/** + * The grouped table and its nested rows are separate tables, so a column and the + * one that replaces it while grouped share a width to keep the two aligned. + */ +const NAME_COLUMN_WIDTH = 220; +const STATUS_COLUMN_WIDTH = 250; + +export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [ + { + id: 'containerGroup', + header: (): React.ReactNode => , + accessorFn: (row): string => row.containerName || '', + width: { min: NAME_COLUMN_WIDTH }, + enableSort: false, + enableRemove: false, + enableMove: false, + pin: 'left', + visibilityBehavior: 'hidden-on-collapse', + cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => ( + + + + ), + }, + { + id: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME, + header: (): React.ReactNode => ( + } + docPath={`${CONTAINERS_DOC_PATH}#container-name`} + /> + ), + accessorFn: (row): string => + row.containerName || + row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] || + '', + width: { min: NAME_COLUMN_WIDTH }, + enableSort: true, + enableRemove: false, + enableMove: false, + pin: 'left', + visibilityBehavior: 'hidden-on-expand', + cell: ({ value }): React.ReactNode => ( + {value as string} + ), + }, + { + id: 'podName', + header: (): React.ReactNode => ( + + Pod Name + + ), + accessorFn: (row): string => + row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '', + width: { min: 260 }, + enableSort: false, + cell: ({ value }): React.ReactNode => { + const podName = value as string; + if (!podName) { + return ; + } + return {podName}; + }, + }, + { + id: 'namespace', + header: (): React.ReactNode => ( + + Namespace + + ), + accessorFn: (row): string => + row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] || '', + width: { min: 160 }, + enableSort: false, + cell: ({ value }): React.ReactNode => ( + {value as string} + ), + }, + { + id: 'image', + header: (): React.ReactNode => ( + + Image:Tag + + ), + accessorFn: (row): string => getContainerImageWithTag(row), + width: { min: 240 }, + enableSort: false, + cell: ({ value }): React.ReactNode => { + const image = value as string; + if (!image) { + return ; + } + return {image}; + }, + }, + { + id: 'containerStatus', + header: (): React.ReactNode => ( + Status + ), + accessorFn: (row): string => row.status, + width: { min: STATUS_COLUMN_WIDTH }, + enableSort: false, + visibilityBehavior: 'hidden-on-expand', + cell: ({ row }): React.ReactNode => { + if ( + !row.status || + row.status === InframonitoringtypesContainerStatusDTO.no_data + ) { + return ; + } + + return ( + + {CONTAINER_STATUS_LABELS[row.status]} + + ); + }, + }, + { + id: 'containerCountsByStatus', + header: (): React.ReactNode => ( + Status + ), + accessorFn: ( + row, + ): InframonitoringtypesContainerRecordDTO['containerCountsByStatus'] => + row.containerCountsByStatus, + width: { min: STATUS_COLUMN_WIDTH }, + enableSort: false, + visibilityBehavior: 'hidden-on-collapse', + cell: ({ row, rowId }): React.ReactNode => { + if (!row.containerCountsByStatus) { + return ; + } + return ( + + ); + }, + }, + { + id: 'containerReady', + header: (): React.ReactNode => ( + Ready + ), + accessorFn: (row): string => row.ready, + width: { min: 130 }, + enableSort: false, + visibilityBehavior: 'hidden-on-expand', + cell: ({ row }): React.ReactNode => { + if ( + !row.ready || + row.ready === InframonitoringtypesContainerReadyDTO.no_data + ) { + return ; + } + + return ( + + {CONTAINER_READY_LABELS[row.ready]} + + ); + }, + }, + { + id: 'containerCountsByReady', + header: (): React.ReactNode => ( + Ready + ), + accessorFn: ( + row, + ): InframonitoringtypesContainerRecordDTO['containerCountsByReady'] => + row.containerCountsByReady, + width: { min: 130 }, + enableSort: false, + visibilityBehavior: 'hidden-on-collapse', + cell: ({ row, rowId }): React.ReactNode => { + if (!row.containerCountsByReady) { + return ; + } + return ( + + ); + }, + }, + { + id: 'containerRestarts', + header: (): React.ReactNode => ( + + Restarts + + ), + accessorFn: (row): number => row.restarts, + width: { min: 130 }, + enableSort: false, + cell: ({ value, rowId }): React.ReactNode => ( + + {value as number} + + ), + }, + { + id: 'cpu_request', + header: (): React.ReactNode => ( + } + > + CPU Request Usage (%) + + ), + accessorFn: (row): number => row.cpuRequestUtilization, + width: { min: 210 }, + enableSort: true, + cell: ({ value, rowId }): React.ReactNode => ( + + + + ), + }, + { + id: 'cpu_limit', + header: (): React.ReactNode => ( + } + > + CPU Limit Usage (%) + + ), + accessorFn: (row): number => row.cpuLimitUtilization, + width: { min: 220 }, + enableSort: true, + cell: ({ value, rowId }): React.ReactNode => ( + + + + ), + }, + { + id: 'cpu', + header: (): React.ReactNode => ( + + CPU Usage (cores) + + ), + accessorFn: (row): number => row.cpu, + width: { min: 160 }, + enableSort: true, + cell: ({ value, rowId }): React.ReactNode => ( + + {Number(value).toFixed(2)} + + ), + }, + { + id: 'memory_request', + header: (): React.ReactNode => ( + } + > + Memory Request Usage (%) + + ), + accessorFn: (row): number => row.memoryRequestUtilization, + width: { min: 210 }, + enableSort: true, + cell: ({ value, rowId }): React.ReactNode => ( + + + + ), + }, + { + id: 'memory_limit', + header: (): React.ReactNode => ( + } + > + Memory Limit Usage (%) + + ), + accessorFn: (row): number => row.memoryLimitUtilization, + width: { min: 220 }, + enableSort: true, + cell: ({ value, rowId }): React.ReactNode => ( + + + + ), + }, + { + id: 'memory', + header: (): React.ReactNode => ( + + Memory Usage (WSS) + + ), + accessorFn: (row): number => row.memory, + width: { min: 210, default: '100%' }, + enableSort: true, + cell: ({ value, rowId }): React.ReactNode => ( + + {formatBytes(value as number)} + + ), + }, + { + id: 'node', + header: (): React.ReactNode => ( + + Node + + ), + accessorFn: (row): string => + row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME] || '', + width: { default: 100 }, + enableSort: false, + defaultVisibility: false, + cell: ({ value }): React.ReactNode => ( + {value as string} + ), + }, + { + id: 'cluster', + header: (): React.ReactNode => ( + + Cluster + + ), + accessorFn: (row): string => + row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] || '', + width: { default: 100 }, + enableSort: false, + defaultVisibility: false, + cell: ({ value }): React.ReactNode => ( + {value as string} + ), + }, + { + id: 'deployment', + header: (): React.ReactNode => ( + + Deployment + + ), + accessorFn: (row): string => + row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] || '', + width: { default: 100 }, + enableSort: false, + defaultVisibility: false, + cell: ({ value }): React.ReactNode => ( + {value as string} + ), + }, +]; diff --git a/frontend/src/container/InfraMonitoringK8sV2/Containers/utils.ts b/frontend/src/container/InfraMonitoringK8sV2/Containers/utils.ts new file mode 100644 index 00000000000..ce2dfa0cd6d --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/Containers/utils.ts @@ -0,0 +1,169 @@ +import { Color } from '@signozhq/design-tokens'; +import { BadgeColor } from '@signozhq/ui/badge'; +import { + InframonitoringtypesContainerCountsByReadyDTO, + InframonitoringtypesContainerCountsByStatusDTO, + InframonitoringtypesContainerReadyDTO, + InframonitoringtypesContainerRecordDTO, + InframonitoringtypesContainerStatusDTO, +} from 'api/generated/services/sigNoz.schemas'; + +import { StatusCountItem } from '../components/GroupedStatusCounts'; +import { INFRA_MONITORING_ATTR_KEYS } from '../constants'; + +export const CONTAINERS_DOC_PATH = + '/infrastructure-monitoring/kubernetes/containers'; + +/** Renders as `name:tag`; the tag is dropped when the image is not pinned. */ +export function getContainerImageWithTag( + container: InframonitoringtypesContainerRecordDTO, +): string { + const name = container.meta?.[INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME]; + const tag = container.meta?.[INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_TAG]; + + if (!name) { + return ''; + } + + return tag ? `${name}:${tag}` : name; +} + +export function getContainerName( + container: InframonitoringtypesContainerRecordDTO, +): string { + return ( + container.containerName || + container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] || + '' + ); +} + +export function getContainerPodUID( + container: InframonitoringtypesContainerRecordDTO, +): string { + return ( + container.podUID || + container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID] || + '' + ); +} + +export const CONTAINER_STATUS_COLORS: Record< + InframonitoringtypesContainerStatusDTO, + BadgeColor +> = { + [InframonitoringtypesContainerStatusDTO.running]: 'forest', + [InframonitoringtypesContainerStatusDTO.completed]: 'robin', + [InframonitoringtypesContainerStatusDTO.waiting]: 'amber', + [InframonitoringtypesContainerStatusDTO.containercreating]: 'amber', + [InframonitoringtypesContainerStatusDTO.terminated]: 'sienna', + [InframonitoringtypesContainerStatusDTO.unknown]: 'vanilla', + [InframonitoringtypesContainerStatusDTO.no_data]: 'vanilla', + [InframonitoringtypesContainerStatusDTO.crashloopbackoff]: 'cherry', + [InframonitoringtypesContainerStatusDTO.imagepullbackoff]: 'cherry', + [InframonitoringtypesContainerStatusDTO.errimagepull]: 'cherry', + [InframonitoringtypesContainerStatusDTO.createcontainerconfigerror]: 'cherry', + [InframonitoringtypesContainerStatusDTO.oomkilled]: 'cherry', + [InframonitoringtypesContainerStatusDTO.error]: 'cherry', + [InframonitoringtypesContainerStatusDTO.containercannotrun]: 'cherry', +}; + +/** kubectl prints these as single CamelCase words, so the enum value alone is not a usable label. */ +export const CONTAINER_STATUS_LABELS: Record< + InframonitoringtypesContainerStatusDTO, + string +> = { + [InframonitoringtypesContainerStatusDTO.running]: 'Running', + [InframonitoringtypesContainerStatusDTO.completed]: 'Completed', + [InframonitoringtypesContainerStatusDTO.waiting]: 'Waiting', + [InframonitoringtypesContainerStatusDTO.containercreating]: + 'ContainerCreating', + [InframonitoringtypesContainerStatusDTO.terminated]: 'Terminated', + [InframonitoringtypesContainerStatusDTO.unknown]: 'Unknown', + [InframonitoringtypesContainerStatusDTO.no_data]: 'No data', + [InframonitoringtypesContainerStatusDTO.crashloopbackoff]: 'CrashLoopBackOff', + [InframonitoringtypesContainerStatusDTO.imagepullbackoff]: 'ImagePullBackOff', + [InframonitoringtypesContainerStatusDTO.errimagepull]: 'ErrImagePull', + [InframonitoringtypesContainerStatusDTO.createcontainerconfigerror]: + 'CreateContainerConfigError', + [InframonitoringtypesContainerStatusDTO.oomkilled]: 'OOMKilled', + [InframonitoringtypesContainerStatusDTO.error]: 'Error', + [InframonitoringtypesContainerStatusDTO.containercannotrun]: + 'ContainerCannotRun', +}; + +const CONTAINER_ERROR_STATUS_LABELS: Partial< + Record +> = { + crashLoopBackOff: 'CrashLoopBackOff', + imagePullBackOff: 'ImagePullBackOff', + errImagePull: 'ErrImagePull', + createContainerConfigError: 'CreateContainerConfigError', + oomKilled: 'OOMKilled', + error: 'Error', + containerCannotRun: 'ContainerCannotRun', +}; + +export function getContainerStatusItems( + counts: InframonitoringtypesContainerCountsByStatusDTO, +): StatusCountItem[] { + const errorKeys = Object.keys(CONTAINER_ERROR_STATUS_LABELS) as Array< + keyof typeof CONTAINER_ERROR_STATUS_LABELS + >; + + return [ + { value: counts.running, label: 'Running', color: Color.BG_FOREST_500 }, + { value: counts.completed, label: 'Completed', color: Color.BG_ROBIN_500 }, + { + value: counts.waiting + counts.containerCreating, + label: 'Waiting', + color: Color.BG_AMBER_500, + breakdown: [ + { label: 'Waiting', value: counts.waiting }, + { label: 'ContainerCreating', value: counts.containerCreating }, + ], + }, + { + value: counts.terminated, + label: 'Terminated', + color: Color.BG_SIENNA_500, + }, + { value: counts.unknown, label: 'Unknown', color: Color.BG_SLATE_400 }, + { + value: errorKeys.reduce((sum, key) => sum + counts[key], 0), + label: 'Error Status', + color: Color.BG_CHERRY_500, + breakdown: errorKeys.map((key) => ({ + label: CONTAINER_ERROR_STATUS_LABELS[key] as string, + value: counts[key], + })), + }, + ]; +} + +export const CONTAINER_READY_COLORS: Record< + InframonitoringtypesContainerReadyDTO, + BadgeColor +> = { + [InframonitoringtypesContainerReadyDTO.ready]: 'forest', + [InframonitoringtypesContainerReadyDTO.not_ready]: 'cherry', + [InframonitoringtypesContainerReadyDTO.no_data]: 'vanilla', +}; + +export const CONTAINER_READY_LABELS: Record< + InframonitoringtypesContainerReadyDTO, + string +> = { + [InframonitoringtypesContainerReadyDTO.ready]: 'Ready', + [InframonitoringtypesContainerReadyDTO.not_ready]: 'Not Ready', + [InframonitoringtypesContainerReadyDTO.no_data]: 'No data', +}; + +export function getContainerReadyItems( + counts: InframonitoringtypesContainerCountsByReadyDTO, +): StatusCountItem[] { + return [ + { value: counts.ready, label: 'Ready', color: Color.BG_FOREST_500 }, + { value: counts.notReady, label: 'Not Ready', color: Color.BG_CHERRY_500 }, + ]; +} diff --git a/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/EntityMetrics/ChartHeader.tsx b/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/EntityMetrics/ChartHeader.tsx index acb38f19029..4ede4924307 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/EntityMetrics/ChartHeader.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/EntityMetrics/ChartHeader.tsx @@ -3,8 +3,9 @@ import { Compass, Info } from '@signozhq/icons'; import { TooltipSimple } from '@signozhq/ui/tooltip'; import styles from './ChartHeader.module.scss'; +import { DOCS_BASE_URL } from 'constants/app'; -const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`; +const DOCS_ROOT = `${DOCS_BASE_URL}/docs`; interface ChartHeaderProps { title: string; @@ -33,7 +34,7 @@ function ChartHeader({ <> {tooltipTitle}{' '}
e.stopPropagation()} diff --git a/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/entityDetails.module.scss b/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/entityDetails.module.scss index be86b269387..355ccf47a23 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/entityDetails.module.scss +++ b/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/entityDetails.module.scss @@ -37,38 +37,28 @@ .title { font-family: var(--periscope-font-family-mono); - --typography-margin: 0px var(--spacing-4) 0px 0px; + --typography-margin: 0px var(--spacing-1) 0px 0px; } -.entityDetailsEntity { - display: flex; - flex-direction: column; +// Sized to the icon rather than the default tap target, so it sits beside the +// entity name instead of a gap away from it. +.copyIdButton { + --button-padding: 2px; + --button-height: auto; + --button-width: auto; } -.entityDetailsGrid { +.entityDetailsEntity { display: flex; flex-direction: column; } -.labelsRow, -.valuesRow { +// Tracks size to the entity's field count instead of a fixed four, so entities +// with more fields stay on one row rather than spilling a near-empty second one. +.entityDetailsGrid { display: grid; - grid-template-columns: 1.5fr 1.5fr 1.5fr 1.5fr; - gap: 30px; - align-items: center; -} - -.labelsRow { - margin-bottom: var(--spacing-4); -} - -.entityDetailsMetadataLabel { - letter-spacing: 0.44px; - text-transform: uppercase; -} - -.entityDetailsMetadataValue { - font-family: var(--periscope-font-family-mono); + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--spacing-4) var(--spacing-6); } .viewsTabsContainer { diff --git a/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx b/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx index 94e3b9fada1..4d402d53610 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx @@ -16,6 +16,7 @@ import { ArrowUpDown, ArrowUpToLine, Bolt, + Box, Boxes, Computer, Container, @@ -31,6 +32,7 @@ import { DataSource } from 'types/common/queryBuilder'; import { K8sDynamicList } from './Base/K8sDynamicList'; import { GetClustersQuickFiltersConfig, + GetContainersQuickFiltersConfig, GetDaemonsetsQuickFiltersConfig, GetDeploymentsQuickFiltersConfig, GetJobsQuickFiltersConfig, @@ -152,6 +154,12 @@ export default function InfraMonitoringK8s(): JSX.Element { const categories = useMemo( () => [ + { + key: K8sCategories.CONTAINERS, + label: 'Containers', + icon: , + config: GetContainersQuickFiltersConfig(), + }, { key: K8sCategories.PODS, label: 'Pods', diff --git a/frontend/src/container/InfraMonitoringK8sV2/constants.ts b/frontend/src/container/InfraMonitoringK8sV2/constants.ts index f4c4a5236cd..d3aae6489dc 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/constants.ts +++ b/frontend/src/container/InfraMonitoringK8sV2/constants.ts @@ -61,10 +61,26 @@ export const INFRA_MONITORING_ATTR_KEYS = { K8S_CONTAINER_CPU_LIMIT: 'k8s.container.cpu_limit', K8S_CONTAINER_MEMORY_REQUEST: 'k8s.container.memory_request', K8S_CONTAINER_MEMORY_LIMIT: 'k8s.container.memory_limit', + K8S_CONTAINER_CPU_REQUEST_UTILIZATION: 'k8s.container.cpu_request_utilization', + K8S_CONTAINER_CPU_LIMIT_UTILIZATION: 'k8s.container.cpu_limit_utilization', + K8S_CONTAINER_MEMORY_REQUEST_UTILIZATION: + 'k8s.container.memory_request_utilization', + K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION: + 'k8s.container.memory_limit_utilization', + K8S_CONTAINER_CPU_NODE_UTILIZATION: 'k8s.container.cpu.node.utilization', + K8S_CONTAINER_MEMORY_NODE_UTILIZATION: 'k8s.container.memory.node.utilization', CONTAINER_CPU_USAGE: 'container.cpu.usage', CONTAINER_MEMORY_USAGE: 'container.memory.usage', + CONTAINER_MEMORY_AVAILABLE: 'container.memory.available', CONTAINER_MEMORY_WORKING_SET: 'container.memory.working_set', CONTAINER_MEMORY_RSS: 'container.memory.rss', + CONTAINER_MEMORY_MAJOR_PAGE_FAULTS: 'container.memory.major_page_faults', + CONTAINER_FILESYSTEM_AVAILABLE: 'container.filesystem.available', + CONTAINER_FILESYSTEM_CAPACITY: 'container.filesystem.capacity', + CONTAINER_FILESYSTEM_USAGE: 'container.filesystem.usage', + CONTAINER_UPTIME: 'container.uptime', + CONTAINER_IMAGE_NAME: 'container.image.name', + CONTAINER_IMAGE_TAG: 'container.image.tag', // Deployment K8S_DEPLOYMENT_NAME: 'k8s.deployment.name', @@ -165,6 +181,9 @@ export const K8sCategories = { VOLUMES: 'volumes', }; +/** The section the Kubernetes view opens on when a link names none. */ +export const DEFAULT_K8S_CATEGORY = K8sCategories.CONTAINERS; + const dotMap = { [InfraMonitoringEntity.HOSTS]: INFRA_MONITORING_ATTR_KEYS.SYSTEM_CPU_LOAD_AVERAGE_15M, @@ -181,7 +200,7 @@ const dotMap = { [InfraMonitoringEntity.DAEMONSETS]: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE, [InfraMonitoringEntity.CONTAINERS]: - INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE, + INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, [InfraMonitoringEntity.JOBS]: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_DESIRED_SUCCESSFUL_PODS, [InfraMonitoringEntity.VOLUMES]: @@ -321,6 +340,161 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] { ]; } +export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] { + return [ + { + type: FiltersType.CHECKBOX, + title: 'Container', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: true, + }, + { + type: FiltersType.CHECKBOX, + title: 'Pod', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: true, + }, + { + type: FiltersType.CHECKBOX, + title: 'Namespace', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Node', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Cluster', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Image', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Deployment', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Statefulset', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'DaemonSet', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Job', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Environment', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.DEPLOYMENT_ENVIRONMENT, + dataType: DataTypes.String, + type: 'resource', + }, + defaultOpen: true, + }, + ]; +} + export function GetNodesQuickFiltersConfig(): IQuickFiltersConfig[] { return [ { @@ -767,6 +941,7 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = { SELECTED_ITEM: 'selectedItem', SELECTED_ITEM_CLUSTER_NAME: 'selectedItemClusterName', SELECTED_ITEM_NAMESPACE_NAME: 'selectedItemNamespaceName', + SELECTED_ITEM_CONTAINER_NAME: 'selectedItemContainerName', DETAIL_RELATIVE_TIME: 'detailRelativeTime', DETAIL_START_TIME: 'detailStartTime', DETAIL_END_TIME: 'detailEndTime', @@ -783,7 +958,7 @@ export const METRIC_NAMESPACE_BY_ENTITY: Record = [InfraMonitoringEntity.DEPLOYMENTS]: 'k8s.', [InfraMonitoringEntity.STATEFULSETS]: 'k8s.', [InfraMonitoringEntity.DAEMONSETS]: 'k8s.', - [InfraMonitoringEntity.CONTAINERS]: 'k8s.pod.', + [InfraMonitoringEntity.CONTAINERS]: 'k8s.container.', [InfraMonitoringEntity.JOBS]: 'k8s.', [InfraMonitoringEntity.VOLUMES]: 'k8s.volume.', }; diff --git a/frontend/src/container/InfraMonitoringK8sV2/hooks.ts b/frontend/src/container/InfraMonitoringK8sV2/hooks.ts index 33e4bbd0ba1..80ad35c3023 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/hooks.ts +++ b/frontend/src/container/InfraMonitoringK8sV2/hooks.ts @@ -16,8 +16,8 @@ import { import { parseAsJsonNoValidate } from 'utils/nuqsParsers'; import { + DEFAULT_K8S_CATEGORY, INFRA_MONITORING_K8S_PARAMS_KEYS, - K8sCategories, VIEWS, } from './constants'; import { orderBySchema, OrderBySchemaType } from './schemas'; @@ -130,19 +130,23 @@ export const useInfraMonitoringCategory = (): UseQueryStateReturn< > => useQueryState( INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, - parseAsString.withDefault(K8sCategories.PODS).withOptions(defaultNuqsOptions), + parseAsString + .withDefault(DEFAULT_K8S_CATEGORY) + .withOptions({ ...defaultNuqsOptions, clearOnDefault: false }), ); export interface SelectedItemParams { selectedItem: string | null; clusterName?: string | null; namespaceName?: string | null; + containerName?: string | null; } const selectedItemParamsParsers = { [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: parseAsString, [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: parseAsString, [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: parseAsString, + [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: parseAsString, }; export type UseSelectedItemParamsReturn = [ @@ -167,6 +171,9 @@ export const useInfraMonitoringSelectedItemParams = namespaceName: rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME] ?? null, + containerName: + rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME] ?? + null, }), [rawParams], ); @@ -178,6 +185,7 @@ export const useInfraMonitoringSelectedItemParams = [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: null, [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: null, [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: null, + [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: null, }); return; } @@ -189,6 +197,8 @@ export const useInfraMonitoringSelectedItemParams = newParams.clusterName ?? null, [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: newParams.namespaceName ?? null, + [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: + newParams.containerName ?? null, }); }, [setRawParams], diff --git a/frontend/src/pages/InfrastructureMonitoring/constants.tsx b/frontend/src/pages/InfrastructureMonitoring/constants.tsx index 82c098a5d8f..dfb5efafa5c 100644 --- a/frontend/src/pages/InfrastructureMonitoring/constants.tsx +++ b/frontend/src/pages/InfrastructureMonitoring/constants.tsx @@ -2,6 +2,10 @@ import { TabRoutes } from 'components/RouteTab/types'; import ROUTES from 'constants/routes'; import InfraMonitoringHostsV2 from 'container/InfraMonitoringHostsV2'; import InfraMonitoringK8sV2 from 'container/InfraMonitoringK8sV2'; +import { + DEFAULT_K8S_CATEGORY, + INFRA_MONITORING_K8S_PARAMS_KEYS, +} from 'container/InfraMonitoringK8sV2/constants'; import { Inbox } from '@signozhq/icons'; function HostsContainer(): JSX.Element { @@ -30,6 +34,6 @@ export const Kubernetes: TabRoutes = { Kubernetes
), - route: ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES, + route: `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=${DEFAULT_K8S_CATEGORY}`, key: ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES, };