Compare commits

...

30 Commits

Author SHA1 Message Date
Praneeth Lingam
6094558df6 Merge branch 'main' of github.com:SigNoz/signoz into feat/telegram-alerts 2026-10-19 19:09:42 +05:30
Praneeth Lingam
fdd432aa02 fix(alertmanager): reject empty telegram apiUrl
Migration 136 writes the spec as raw JSON without validation, so it now
leaves out an empty api_url instead of writing an apiUrl the spec would
reject on read.

Assisted-by: Claude Opus 5.5
2026-10-19 19:08:10 +05:30
Aditya Singh
e4e58e2349 fix(explorer): stop reporting dropped order by to sentry (#13153)
#### Description
- The explorer order-by sanitizer sent a Sentry warning each time it
dropped an order-by column. Each new order made a new Sentry issue, and
each new issue paged.
- Dropping a stale order is expected. Since #13085, it happens when a
user switches from List to Time Series or Table.
- The sanitizer no longer reports to Sentry. The filtering is unchanged.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/180
2026-10-19 11:03:04 +00:00
Praneeth Lingam
83b9b01e7f Merge branch 'main' of github.com:SigNoz/signoz into feat/telegram-alerts 2026-10-19 15:48:52 +05:30
Praneeth Lingam
1c71587202 fix(alertmanager): default telegram api url in the notifier
Defaulting in applyNativeDefaults would store the URL, since it also runs
on save. The server-wide telegram_api_url is no longer applied, matching
Jira and JSM Ops.

Assisted-by: Claude Opus 5.5
2026-10-19 15:48:18 +05:30
Gaurav Tewari
6c1f12f5d0 feat(trace-details): waterfall tooltip redesign and AI span badges (#13066)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Redesigned span tooltip with AI usage breakdown; tooltip rows/styles
shared under `TraceTooltip`.
- AI badge and icons on gen-ai spans in the waterfall.
- Header shows trace-level AI tokens (with breakdown tooltip) and cost
from the summary's `ai` field.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Close -
https://github.com/orgs/SigNoz/projects/59/views/18?pane=issue&itemId=263424271&issue=SigNoz%7Cnerve-pod%7C397

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings



https://github.com/user-attachments/assets/b22bcbfb-8f74-40ac-a263-d5ac2a1f23b0


TDD-
https://app.notion.com/p/signoz/Trace-Details-Changes-for-AI-o11y-3ebfcc6bcd1980b185b4c17cd1810075?source=copy_link



<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
figma -
https://www.figma.com/design/UPcVP7G1Scd5PLOQzvI9Q1/Trace-Explorer?node-id=4874-3499&t=5QH0Uv1oiS7v1Rfe-0

- We changed tooltip designed a little bit, we added same font of
subheading ( usage breakdown ) as of heading (span) , min and max
heights also cost icon could be better .
- We have introduced a new logic to calculate total 
```
if input < cache_read + cache_write:
  total_input = input + cache_read + cache_write
else
  total_input = input
  
```
- Also now if backend sends any of ai related field we will display all
field with value set as zero .
- We have updated events tooltip as well (styles ). 
- have added storybook as well . pages-traces-trace-details--ai-trace

-  Span detail changes will be done in  last pr :) 
Stack 2/4, based on #13065.

<!--Please delete paragraphs that you did not use before submitting.-->

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-10-19 05:52:16 +00:00
Praneeth Lingam
e07334b98c Merge branch 'main' of github.com:SigNoz/signoz into feat/telegram-alerts 2026-10-19 11:00:29 +05:30
Aditya Singh
6c6632f940 test(saved-views): authz stories for each missing permission (#13172)
Some checks failed
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / staging (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
#### Description
- The saved views stories now have an `Authz` folder, with one story per
missing permission. The stories follow the dashboard and billing authz
stories.
- `NoList`, `NoCreate`, `NoRead`, `NoUpdate` and `NoDelete` each deny
one permission.
- `NoCreateSaveMenu`, `NoUpdateSaveMenu` and `NoReadOpenView` show the
same denials in the `✓` menu and on an open view.
  - `ReadOnly` denies create, update and delete together.
- `CheckLoading` and `CheckFailed` cover a pending check and a failed
check.
- Each story checks what is disabled and what still works.
- The main stories move to `Components/Saved Views/Overview`, and
`NoAccess` is now `NoList`.
- The header `✓` and `✕` buttons get a gap again. After the ui upgrade
they are link buttons without padding, so they touched.

#### Issues closed by this PR

#### Screenshot

Before

<img width="608" height="120" alt="image"
src="https://github.com/user-attachments/assets/e9fcf890-3355-4cd5-82e4-a4e0221aaacd"
/>


After

<img width="734" height="212" alt="image"
src="https://github.com/user-attachments/assets/7ce0d2d3-7155-4365-bbab-aad0fef6fc29"
/>


Part of https://github.com/SigNoz/events-pod/issues/71
2026-10-16 19:26:53 +00:00
Aditya Singh
39b752c428 feat(logs): move the list row filter chips off v3 attribute keys (#13148)
#### Description

- The "Add to query" chip on a logs list row no longer fetches v3
`attribute_keys` before it adds the filter. The hook builds the filter
item with the same util the log details drawer uses, then redirects. The
stored key has an empty data type and context. The filter expression is
built from key and value, so the request is unchanged.
- `LogDetail` dropped two props it never read, `onAddToQuery` and
`onClickActionItem`, from its type and from six call sites.
- Removed the code that only existed for those props: the `EntityLogs`
callback, `generateFilterQuery`, `getOldLogsOperatorFromNew` and
`ActionItemProps`. Removed `onGroupByAttribute` from `useActiveLog`,
which nothing called.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/160


#### Screenshot



https://github.com/user-attachments/assets/7cce500a-6dbf-45cb-937f-7366d515b0b9



https://github.com/user-attachments/assets/f5cbd1bd-0863-4633-ad6e-2fd15f7ae7c5



https://github.com/user-attachments/assets/2fb7dbeb-c474-4730-9a9d-363257a7fff7



#### Additional Information

- Only one user path runs the changed hook: the field chips on a log row
in the logs explorer list and in live logs.
- The merged where clause shows adjacent clauses without `AND`. The
grammar allows an implicit AND and the query runs. This is pre-existing
in `convertFiltersToExpressionWithExistingQuery` and is not changed
here.
- Part of https://github.com/SigNoz/events-pod/issues/156.
2026-10-16 18:32:25 +00:00
Praneeth Lingam
01d0ee34e0 Merge branch 'main' of github.com:SigNoz/signoz into feat/telegram-alerts 2026-10-16 19:38:57 +05:30
Praneeth Lingam
ed70b41a4c Merge branch 'main' of github.com:SigNoz/signoz into feat/telegram-alerts 2026-10-16 19:12:36 +05:30
Praneeth Lingam
010dbd4cb9 Merge branch 'main' of github.com:SigNoz/signoz into feat/telegram-alerts 2026-10-16 17:16:30 +05:30
Naman Verma
256646003e Merge branch 'main' into feat/telegram-alerts 2026-10-16 15:24:57 +05:30
Praneeth Lingam
3b4f3a5ef5 Merge branch 'main' of github.com:SigNoz/signoz into feat/telegram-alerts 2026-10-16 11:54:46 +05:30
Praneeth Lingam
2c494f8616 Merge branch 'main' into feat/telegram-alerts 2026-10-14 16:02:54 +05:30
Praneeth Lingam
6a45f3ef54 chore(api): regenerate openapi specs 2026-10-14 15:15:46 +05:30
Praneeth Lingam
9f323735d0 feat(alertmanager): add title to telegram channels
The title lives on a SigNoz-owned receiver under telegram_configs, so stored
receivers still decode. A receiver without one sends its message alone.
2026-10-14 15:14:56 +05:30
Praneeth Lingam
852828d374 fix(alertmanager): harden telegram delivery
- retry only on 429 and 5xx
- send as HTML instead of rejecting other parse modes
- backfill v2 spec for existing telegram channels
2026-10-13 17:47:43 +05:30
Praneeth Lingam
d8286d7efd test(alertmanager): use pushover as the unmodelled channel kind 2026-10-12 16:24:41 +05:30
Praneeth Lingam
d51cbb2d55 Merge branch 'main' of github.com:SigNoz/signoz into feat/telegram-alerts 2026-10-12 13:39:23 +05:30
Praneeth Lingam
8e76b91e67 chore(alertmanager): drop unused logger from telegram tests 2026-10-12 13:39:04 +05:30
Praneeth Lingam
c6396c15bd chore(alertmanager): fix go mod tidy, generated api clients and lint 2026-10-12 11:41:10 +05:30
Praneeth Lingam
c9ac8aacec test(alertmanager): added integration tests for telegram 2026-10-12 11:17:22 +05:30
Praneeth Lingam
238293c083 Merge branch 'main' of github.com:SigNoz/signoz into feat/telegram-alerts 2026-10-12 11:16:16 +05:30
Praneeth Lingam
fd9d86d6c3 Merge branch 'main' of github.com:SigNoz/signoz into feat/telegram-alerts 2026-10-09 13:59:23 +05:30
Praneeth Lingam
4105015444 chore(openapi): regenerate specs for the telegram channel 2026-10-08 00:49:08 +05:30
Praneeth Lingam
e312d010db feat(alertmanager): add a telegram notifier with per-rule templates
Rule templates are authored in markdown, and Telegram's parse_mode HTML
accepts only a small tag subset, rejecting the whole message on anything
outside it. The generic HTML renderer cannot be reused, so a dedicated
one emits that subset and flattens constructs with no equivalent.
2026-10-08 00:48:38 +05:30
Praneeth Lingam
f76ab67a90 refactor(alertmanager): drop parseMode from the telegram channel spec
Rendering targets HTML only. MarkdownV2 needs context-dependent escaping
of eighteen characters, and legacy Markdown cannot express strikethrough,
blockquotes or nested entities at all, so neither is worth supporting.
2026-10-08 00:46:24 +05:30
Praneeth Lingam
3c1a78c85e fix(rules): leave private annotations unexpanded
The ruler's expander rewrites every $-ref into a label lookup, which
blanked the rule template placeholders before any notifier saw them.
2026-10-08 00:38:29 +05:30
Praneeth Lingam
b63ad746b2 feat(telegram): basic workflow 2026-10-07 13:05:03 +05:30
83 changed files with 3894 additions and 418 deletions

View File

@@ -251,6 +251,7 @@ components:
opsgenie: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig'
pagerduty: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig'
slack: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig'
telegram: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfig'
webhook: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig'
propertyName: kind
oneOf:
@@ -264,6 +265,7 @@ components:
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfig'
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig:
properties:
@@ -373,6 +375,18 @@ components:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfig:
properties:
kind:
enum:
- telegram
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelTelegramConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig:
properties:
kind:
@@ -525,6 +539,7 @@ components:
- jira
- jsmops
- incidentio
- telegram
type: string
AlertmanagertypesChannelListOrder:
enum:
@@ -729,6 +744,31 @@ components:
- title
- value
type: object
AlertmanagertypesChannelTelegramConfig:
properties:
apiUrl:
type: string
botToken:
format: password
type: string
chatId:
format: int64
type: integer
disableNotifications:
type: boolean
message:
type: string
messageThreadId:
type: integer
sendResolved:
nullable: true
type: boolean
title:
type: string
required:
- botToken
- chatId
type: object
AlertmanagertypesChannelWebhookConfig:
properties:
bearerToken:
@@ -1022,6 +1062,8 @@ components:
- jsmops_configs
- required:
- incidentio_configs
- required:
- telegram_configs
- required:
- discord_configs
- required:
@@ -1042,8 +1084,6 @@ components:
- victorops_configs
- required:
- sns_configs
- required:
- telegram_configs
- required:
- webex_configs
- required:
@@ -1119,7 +1159,7 @@ components:
type: array
telegram_configs:
items:
$ref: '#/components/schemas/ConfigTelegramConfig'
$ref: '#/components/schemas/AlertmanagertypesTelegramReceiverConfig'
type: array
victorops_configs:
items:
@@ -1263,7 +1303,7 @@ components:
type: array
telegram_configs:
items:
$ref: '#/components/schemas/ConfigTelegramConfig'
$ref: '#/components/schemas/AlertmanagertypesTelegramReceiverConfig'
type: array
victorops_configs:
items:
@@ -1334,6 +1374,34 @@ components:
- timezone
- startTime
type: object
AlertmanagertypesTelegramReceiverConfig:
properties:
api_url:
$ref: '#/components/schemas/ConfigURLType2'
chat:
format: int64
type: integer
chat_file:
type: string
disable_notifications:
type: boolean
http_config:
$ref: '#/components/schemas/ConfigHTTPClientConfig'
message:
type: string
message_thread_id:
type: integer
parse_mode:
type: string
send_resolved:
type: boolean
title:
type: string
token:
type: string
token_file:
type: string
type: object
AlertmanagertypesTestableNotificationChannel:
properties:
config:

View File

@@ -277,10 +277,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
annotations := make(ruletypes.Labels, 0, len(r.Annotations().Map()))
for name, value := range r.Annotations().Map() {
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
}
annotations := r.ExpandAnnotations(expand)
if smpl.IsMissing {
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")

View File

@@ -872,6 +872,55 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
spec: AlertmanagertypesChannelIncidentIOConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfigDTOKind {
telegram = 'telegram',
}
export interface AlertmanagertypesChannelTelegramConfigDTO {
/**
* @type string
*/
apiUrl?: string;
/**
* @type string
* @format password
*/
botToken: string;
/**
* @type integer
* @format int64
*/
chatId: number;
/**
* @type boolean
*/
disableNotifications?: boolean;
/**
* @type string
*/
message?: string;
/**
* @type integer
*/
messageThreadId?: number;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
title?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfigDTO {
/**
* @enum telegram
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfigDTOKind;
spec: AlertmanagertypesChannelTelegramConfigDTO;
}
export type AlertmanagertypesChannelConfigDTO =
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTO
@@ -882,7 +931,8 @@ export type AlertmanagertypesChannelConfigDTO =
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO;
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfigDTO;
export enum AlertmanagertypesChannelDefectDTO {
none = 'none',
@@ -902,6 +952,7 @@ export enum AlertmanagertypesChannelKindDTO {
jira = 'jira',
jsmops = 'jsmops',
incidentio = 'incidentio',
telegram = 'telegram',
}
export enum AlertmanagertypesChannelListOrderDTO {
asc = 'asc',
@@ -2462,7 +2513,7 @@ export interface ConfigSNSConfigDTO {
topic_arn?: string;
}
export interface ConfigTelegramConfigDTO {
export interface AlertmanagertypesTelegramReceiverConfigDTO {
api_url?: ConfigURLType2DTO;
/**
* @type integer
@@ -2494,6 +2545,10 @@ export interface ConfigTelegramConfigDTO {
* @type boolean
*/
send_resolved?: boolean;
/**
* @type string
*/
title?: string;
/**
* @type string
*/
@@ -2699,7 +2754,7 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
/**
* @type array
*/
telegram_configs?: ConfigTelegramConfigDTO[];
telegram_configs?: AlertmanagertypesTelegramReceiverConfigDTO[];
/**
* @type array
*/
@@ -2846,7 +2901,7 @@ export interface AlertmanagertypesReceiverDTO {
/**
* @type array
*/
telegram_configs?: ConfigTelegramConfigDTO[];
telegram_configs?: AlertmanagertypesTelegramReceiverConfigDTO[];
/**
* @type array
*/
@@ -4422,6 +4477,48 @@ export interface ConfigJiraConfigDTO {
wont_fix_resolution?: string;
}
export interface ConfigTelegramConfigDTO {
api_url?: ConfigURLType2DTO;
/**
* @type integer
* @format int64
*/
chat?: number;
/**
* @type string
*/
chat_file?: string;
/**
* @type boolean
*/
disable_notifications?: boolean;
http_config?: ConfigHTTPClientConfigDTO;
/**
* @type string
*/
message?: string;
/**
* @type integer
*/
message_thread_id?: number;
/**
* @type string
*/
parse_mode?: string;
/**
* @type boolean
*/
send_resolved?: boolean;
/**
* @type string
*/
token?: string;
/**
* @type string
*/
token_file?: string;
}
export interface DashboardGridItemDTO {
content?: CommonJSONRefDTO;
/**

View File

@@ -1,7 +1,5 @@
import { DrawerProps } from 'antd';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
import { ActionItemProps } from 'container/LogDetailedView/LogDetailedView.types';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
@@ -20,9 +18,7 @@ export type LogDetailProps = {
handleOpenInExplorer?: MouseEventHandler;
getContainer?: DrawerProps['getContainer'];
onApplyLogFilter?: (expression: string) => void;
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
Pick<DrawerProps, 'onClose'>;
} & Pick<DrawerProps, 'onClose'>;
export type LogDetailInnerProps = LogDetailProps & {
log: NonNullable<LogDetailProps['log']>;

View File

@@ -46,8 +46,6 @@ function renderDrawer(props: Partial<LogDetailProps> = {}): void {
<LogDetail
log={mockLog}
selectedTab={VIEW_TYPES.OVERVIEW}
onAddToQuery={jest.fn()}
onClickActionItem={jest.fn()}
onClose={jest.fn()}
{...props}
/>,

View File

@@ -25,10 +25,8 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { generateFilterQuery } from 'lib/logs/generateFilterQuery';
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
import { ILog } from 'types/api/logs/log';
import { DataSource } from 'types/common/queryBuilder';
@@ -82,29 +80,6 @@ function EntityLogsContent({
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
useLogDetailHandlers();
// TODO: Move away from using onAddToQuery after old drawer cleanup
const onAddToQuery = useCallback(
(fieldKey: string, fieldValue: string, operator: string): void => {
handleCloseLogDetail();
const partExpression = generateFilterQuery({
fieldKey,
fieldValue,
type: getOldLogsOperatorFromNew(operator),
});
const currentUser = userExpression;
const newUser = currentUser.trim()
? `${currentUser} AND ${partExpression}`
: partExpression;
querySearchOnRun(newUser);
logInfraDrawerFilterCustomizedEvent(category, 'logs', newUser, 'logs');
},
[userExpression, querySearchOnRun, handleCloseLogDetail, category],
);
const onApplyLogFilter = useCallback(
(expression: string): void => {
handleCloseLogDetail();
@@ -342,8 +317,6 @@ function EntityLogsContent({
logs={logs}
onNavigateLog={handleSetActiveLog}
selectedTab={selectedTab}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onApplyLogFilter={onApplyLogFilter}
onScrollToLog={handleScrollToLog}
handleOpenInExplorer={(e) => handleOpenInExplorer(e, activeLog)}

View File

@@ -259,8 +259,6 @@ function LiveLogsList({
selectedTab={selectedTab}
log={activeLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
logs={formattedLogs}
onNavigateLog={handleSetActiveLog}

View File

@@ -1,5 +1,4 @@
import { MetricsType } from 'container/MetricsApplication/constant';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
export type AnyObject = { [key: string]: any };
@@ -19,15 +18,3 @@ export interface DataType {
field: string;
value: string;
}
export interface ActionItemProps {
fieldKey: string;
fieldValue: string;
onClickActionItem: (
fieldKey: string,
fieldValue: string,
operator: string,
dataType?: DataTypes,
fieldType?: string,
) => void;
}

View File

@@ -172,7 +172,7 @@ const normalizeDataType = (
// key is fabricated locally (empty source list), so no click-time getAggregateKeys fetch.
export const getFilterQueryData = (
item: IBuilderQuery,
target: LogFilterTarget,
target: Pick<LogFilterTarget, 'fieldKey' | 'dataType' | 'metricsType'>,
value: unknown,
operator: string,
): IBuilderQuery => {

View File

@@ -381,8 +381,6 @@ function LogsExplorerList({
selectedTab={selectedTab}
log={activeLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
logs={logs}
onNavigateLog={handleSetActiveLog}

View File

@@ -91,13 +91,8 @@ function LogsPanelComponent({
() => logs.map((log) => FlatLogData(log) as RowData),
[logs],
);
const {
activeLog,
onAddToQuery,
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
} = useLogDetailHandlers();
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
useLogDetailHandlers();
const handleRow = useCallback(
(record: RowData): HTMLAttributes<RowData> => ({
@@ -179,8 +174,6 @@ function LogsPanelComponent({
selectedTab={selectedTab}
log={activeLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
isListViewPanel
listViewPanelSelectedFields={widget?.selectedLogFields}
logs={logs}

View File

@@ -10,8 +10,7 @@ import { ILog } from 'types/api/logs/log';
import './styles.scss';
function LogsList({ logs }: LogsListProps): JSX.Element {
const { activeLog, onSetActiveLog, onClearActiveLog, onAddToQuery } =
useActiveLog();
const { activeLog, onSetActiveLog, onClearActiveLog } = useActiveLog();
const makeLogDetailsHandler = (log: ILog) => (): void => onSetActiveLog(log);
@@ -45,8 +44,6 @@ function LogsList({ logs }: LogsListProps): JSX.Element {
selectedTab={VIEW_TYPES.OVERVIEW}
log={activeLog}
onClose={onClearActiveLog}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
/>
</div>
);

View File

@@ -78,7 +78,7 @@
display: flex;
align-items: center;
flex-shrink: 0;
gap: var(--spacing-1);
gap: var(--spacing-4);
}
.collapseIcon {

View File

@@ -4,6 +4,7 @@
*/
import { rest, type RequestHandler } from 'msw';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import remove from 'api/browser/localstorage/remove';
import {
type AuthtypesTransactionDTO,
@@ -11,11 +12,13 @@ import {
SavedviewtypesSourceDTO,
type SavedviewtypesUpdatableSavedViewDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SIDEBAR_TOOLTIP_DELAY_MS } from 'components/QuickFilters/QuickFiltersLayout/constants';
import { LOCALSTORAGE } from 'constants/localStorage';
import {
AUTHZ_CHECK_URL,
authzMockResponse,
} from 'lib/authz/utils/authz-test-utils';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { accessFor } from '@/storybook/access/access';
import {
@@ -25,6 +28,7 @@ import {
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import type { MockControlValues, StoryMocks } from '@/storybook/controls/types';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import { authzMocks, type AuthzArgs } from '@/storybook/globals/authzMocks';
import { respondWith } from '@/storybook/runtime/responseState';
@@ -43,6 +47,10 @@ import {
unreadableViewId,
} from './__story_mockdata__/savedViews';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
import type { SavedViewRowActionKey } from '../types';
const API = 'http://localhost/api/v2/saved_views';
const LIST = 'Saved Views · list';
@@ -216,3 +224,106 @@ export const savedViewsMocks: StoryMocks<typeof controls> = {
...definition,
read: (args) => ({ ...authzMocks.read(args), ...definition.read(args) }),
};
/** The sidebar slot of an explorer without quick filters. */
export const sidebarCanvas = withCanvas({
width: 280,
height: 640,
padding: 0,
display: 'flex',
flexDirection: 'column',
borderRight: '1px solid var(--l1-border)',
});
/**
* The header above the pinned list. `onOpenViews` adds All views, which the
* header only shows in an explorer whose quick filters hide the list.
*/
export function SavedViewsSidebar({
source,
onOpenViews,
}: {
source: SavedViewSource;
onOpenViews?: () => void;
}): JSX.Element {
return (
<TooltipProvider delay={SIDEBAR_TOOLTIP_DELAY_MS}>
<SavedViewsHeader source={source} onOpenViews={onOpenViews} />
<SavedViewsPanel source={source} />
</TooltipProvider>
);
}
/** The panel fetches the list and checks every row's permissions before it settles. */
export const UNTIL_LOADED = { timeout: 15_000 };
/**
* Re-queried on every attempt: a button whose check settles is a new node, so a
* reference taken while it was pending never changes.
*/
type FindElement = () => HTMLElement;
export const byTestId =
(testId: string): FindElement =>
(): HTMLElement =>
screen.getByTestId(testId);
/** A check still in flight: `aria-busy` on a button, `data-loading` on a menu row. */
const isPending = (element: HTMLElement): boolean =>
element.getAttribute('aria-busy') === 'true' ||
element.hasAttribute('data-loading');
const isDisabled = (element: HTMLElement): boolean =>
element.hasAttribute('disabled') || element.hasAttribute('data-disabled');
export const expectDisabled = (
find: FindElement,
disabled = true,
): Promise<void> =>
waitFor(async () => {
const element = find();
await expect(isPending(element)).toBe(false);
await expect(isDisabled(element)).toBe(disabled);
}, UNTIL_LOADED);
export const expectPending = (find: FindElement): Promise<void> =>
waitFor(() => expect(isPending(find())).toBe(true), UNTIL_LOADED);
export const findRows = (canvasElement: HTMLElement): Promise<HTMLElement[]> =>
within(canvasElement).findAllByTestId(
'saved-views-row',
undefined,
UNTIL_LOADED,
);
export const rowName =
(row: HTMLElement): FindElement =>
(): HTMLElement =>
within(row).getByTestId('saved-views-row-name');
export const openRowMenu = async (row: HTMLElement): Promise<void> => {
await userEvent.click(within(row).getByTestId('saved-views-row-menu'));
await screen.findByRole('menu', undefined, UNTIL_LOADED);
};
export const rowMenuItem = (key: SavedViewRowActionKey): FindElement =>
byTestId(`saved-views-row-menu-item-${key}`);
export const openSaveChangesMenu = async (
canvasElement: HTMLElement,
): Promise<void> => {
const trigger = await waitFor(async () => {
const button = within(canvasElement).getByTestId('saved-views-save-changes');
await expect(button).not.toHaveAttribute('aria-busy');
return button;
}, UNTIL_LOADED);
await userEvent.click(trigger);
await screen.findByRole('menu', undefined, UNTIL_LOADED);
};
export const saveChangesItem = (key: 'save-as-new' | 'update'): FindElement =>
byTestId(`saved-views-save-changes-item-${key}`);

View File

@@ -1,23 +1,22 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { SIDEBAR_TOOLTIP_DELAY_MS } from 'components/QuickFilters/QuickFiltersLayout/constants';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
import { savedViewsMocks } from './SavedViews.stories.mocks';
import {
findRows,
openRowMenu,
openSaveChangesMenu,
SavedViewsSidebar,
savedViewsMocks,
sidebarCanvas,
} from './SavedViews.stories.mocks';
type SavedViewsArgs = PageStoryArgs<typeof savedViewsMocks>;
const componentStory = storyMocks(savedViewsMocks);
/** The panel fetches the list and checks every row's permissions before it settles. */
const untilLoaded = { timeout: 15_000 };
/**
* The saved views sidebar of an explorer without quick filters: the header with
* the open view and its actions, above the list of views created by the user
@@ -28,25 +27,11 @@ const untilLoaded = { timeout: 15_000 };
* `viewKey`.
*/
const meta = {
title: 'Components/Saved Views',
title: 'Components/Saved Views/Overview',
tags: ['authz', 'play'],
component: SavedViewsPanel,
decorators: [
withCanvas({
width: 280,
height: 640,
padding: 0,
display: 'flex',
flexDirection: 'column',
borderRight: '1px solid var(--l1-border)',
}),
],
render: ({ source }): JSX.Element => (
<TooltipProvider delay={SIDEBAR_TOOLTIP_DELAY_MS}>
<SavedViewsHeader source={source} />
<SavedViewsPanel source={source} />
</TooltipProvider>
),
decorators: [sidebarCanvas],
render: ({ source }): JSX.Element => <SavedViewsSidebar source={source} />,
...componentStory,
parameters: { ...componentStory.parameters },
} satisfies Meta<SavedViewsArgs>;
@@ -71,25 +56,15 @@ export const UnsavedChanges: Story = {
args: { openView: 'unsaved-changes' },
};
/** Without `list` on saved views, the panel shows the permission callout. */
export const NoAccess: Story = {
args: { revoked: ['list:saved-view'] },
};
/**
* The first row's menu. Edit details and Delete follow `update` and `delete` on
* that view, so revoking either disables its item.
*/
export const RowMenu: Story = {
play: async ({ canvasElement }): Promise<void> => {
const [row] = await within(canvasElement).findAllByTestId(
'saved-views-row',
undefined,
untilLoaded,
);
const [row] = await findRows(canvasElement);
await userEvent.click(within(row).getByTestId('saved-views-row-menu'));
await screen.findByRole('menu', undefined, untilLoaded);
await openRowMenu(row);
},
};
@@ -100,15 +75,6 @@ export const RowMenu: Story = {
export const SaveChangesMenu: Story = {
args: { openView: 'unsaved-changes' },
play: async ({ canvasElement }): Promise<void> => {
const trigger = await waitFor(async () => {
const button = within(canvasElement).getByTestId('saved-views-save-changes');
await expect(button).not.toHaveAttribute('aria-busy');
return button;
}, untilLoaded);
await userEvent.click(trigger);
await screen.findByRole('menu', undefined, untilLoaded);
await openSaveChangesMenu(canvasElement);
},
};

View File

@@ -0,0 +1,220 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { unreadableViewId } from '../__story_mockdata__/savedViews';
import {
byTestId,
expectDisabled,
expectPending,
findRows,
openRowMenu,
openSaveChangesMenu,
rowMenuItem,
rowName,
saveChangesItem,
SavedViewsSidebar,
savedViewsMocks,
sidebarCanvas,
UNTIL_LOADED,
} from '../SavedViews.stories.mocks';
import SavedViewsPanel from '../../SavedViewsPanel';
type SavedViewsArgs = PageStoryArgs<typeof savedViewsMocks>;
const componentStory = storyMocks(savedViewsMocks);
/**
* The saved views sidebar with one permission missing at a time. Every story
* here is an admin denied exactly what its name says. `list` and `create` are
* workspace-wide; `read`, `update` and `delete` are checked per view, so a row
* menu only checks its view once it is opened.
*
* Route: the explorer of the Source control, with the Open view control's
* `viewKey`.
*/
const meta = {
title: 'Components/Saved Views/Authz',
tags: ['authz', 'play'],
component: SavedViewsPanel,
decorators: [sidebarCanvas],
render: ({ source }): JSX.Element => <SavedViewsSidebar source={source} />,
...componentStory,
parameters: { ...componentStory.parameters },
} satisfies Meta<SavedViewsArgs>;
export default meta;
type Story = StoryObj<SavedViewsArgs>;
const LIST = 'list:saved-view';
const CREATE = 'create:saved-view';
const UPDATE = 'update:saved-view';
const DELETE = 'delete:saved-view';
/**
* The panel shows the permission callout in place of the list, and All views,
* in an explorer whose quick filters hide the list, is disabled.
*/
export const NoList: Story = {
args: { revoked: [LIST] },
render: ({ source }): JSX.Element => (
<SavedViewsSidebar source={source} onOpenViews={(): void => {}} />
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectDisabled(byTestId('saved-views-open'));
await waitFor(
() =>
expect(canvas.getByTestId('saved-views-panel')).toHaveTextContent(
/is not authorized to perform list/,
),
UNTIL_LOADED,
);
await expect(canvas.queryByTestId('saved-views-search')).toBeNull();
},
};
/** An empty workspace with both ways to create a view disabled: + and Create view. */
export const NoCreate: Story = {
args: { revoked: [CREATE], createdByMe: 0, createdByOthers: 0 },
play: async () => {
await expectDisabled(byTestId('saved-views-create'));
await expectDisabled(byTestId('saved-views-list-create'));
},
};
/** The ✓ menu on unsaved changes: Save as new view disabled, Update selected view open. */
export const NoCreateSaveMenu: Story = {
args: { revoked: [CREATE], openView: 'unsaved-changes' },
play: async ({ canvasElement }) => {
await openSaveChangesMenu(canvasElement);
await expectDisabled(saveChangesItem('save-as-new'));
await expectDisabled(saveChangesItem('update'), false);
},
};
/**
* `read` denied on one view made by someone else: its name is disabled and its
* hover card never opens, while every other view still opens.
*/
export const NoRead: Story = {
args: { unreadableView: true },
play: async ({ canvasElement, args }) => {
const rows = await findRows(canvasElement);
const deniedId = unreadableViewId(args.source);
await Promise.all(
rows.map((row) =>
expectDisabled(rowName(row), row.getAttribute('data-view-id') === deniedId),
),
);
},
};
/** The unreadable view open from its link: the header names it No view access. */
export const NoReadOpenView: Story = {
args: { unreadableView: true, openView: 'created-by-others' },
play: async ({ canvasElement }) => {
await expect(
await within(canvasElement).findByText(
'No view access',
undefined,
UNTIL_LOADED,
),
).toBeVisible();
},
};
/** A row's menu: Edit details disabled; Copy link, Copy ID and Delete open. */
export const NoUpdate: Story = {
args: { revoked: [UPDATE] },
play: async ({ canvasElement }) => {
const [row] = await findRows(canvasElement);
await openRowMenu(row);
await expectDisabled(rowMenuItem('editDetails'));
await expectDisabled(rowMenuItem('copyLink'), false);
await expectDisabled(rowMenuItem('copyId'), false);
await expectDisabled(rowMenuItem('delete'), false);
},
};
/** The ✓ menu on unsaved changes: Update selected view disabled, Save as new view open. */
export const NoUpdateSaveMenu: Story = {
args: { revoked: [UPDATE], openView: 'unsaved-changes' },
play: async ({ canvasElement }) => {
await openSaveChangesMenu(canvasElement);
await expectDisabled(saveChangesItem('update'));
await expectDisabled(saveChangesItem('save-as-new'), false);
},
};
/** A row's menu: Delete disabled, Edit details still open. */
export const NoDelete: Story = {
args: { revoked: [DELETE] },
play: async ({ canvasElement }) => {
const [row] = await findRows(canvasElement);
await openRowMenu(row);
await expectDisabled(rowMenuItem('delete'));
await expectDisabled(rowMenuItem('editDetails'), false);
},
};
/**
* Everything but listing and reading: + is disabled, and a row's menu has only
* Copy link and Copy ID left.
*/
export const ReadOnly: Story = {
args: { revoked: [CREATE, UPDATE, DELETE] },
play: async ({ canvasElement }) => {
await expectDisabled(byTestId('saved-views-create'));
const [row] = await findRows(canvasElement);
await expectDisabled(rowName(row), false);
await openRowMenu(row);
await expectDisabled(rowMenuItem('editDetails'));
await expectDisabled(rowMenuItem('delete'));
await expectDisabled(rowMenuItem('copyLink'), false);
await expectDisabled(rowMenuItem('copyId'), false);
},
};
/**
* The permission check never answering: + spins, and the list stays blank
* behind its `list` check.
*/
export const CheckLoading: Story = {
args: { authzState: 'loading' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectPending(byTestId('saved-views-create'));
await expect(canvas.queryByTestId('saved-views-search')).toBeNull();
},
};
/**
* The permission check failing rather than denying: the sidebar fails open,
* so the list renders and every action stays enabled for the API to refuse.
*/
export const CheckFailed: Story = {
args: { authzState: 'error' },
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }) => {
await expectDisabled(byTestId('saved-views-create'), false);
const [row] = await findRows(canvasElement);
await expectDisabled(rowName(row), false);
await openRowMenu(row);
await expectDisabled(rowMenuItem('editDetails'), false);
await expectDisabled(rowMenuItem('delete'), false);
},
};

View File

@@ -0,0 +1,61 @@
import { renderHook } from '@testing-library/react';
import { initialQueriesMap } from 'constants/queryBuilder';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useActiveLog } from '../useActiveLog';
const mockRedirect = jest.fn();
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({
currentQuery: initialQueriesMap.logs,
redirectWithQueryBuilderData: mockRedirect,
}),
}));
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: (): URLSearchParams => new URLSearchParams(),
}));
describe('useActiveLog', () => {
beforeEach(() => {
mockRedirect.mockClear();
});
it('adds the chip field as a filter with its data type and redirects', () => {
const { result } = renderHook(() => useActiveLog());
result.current.onAddToQuery(
'service.name',
'frontend',
'=',
DataTypes.String,
);
expect(mockRedirect).toHaveBeenCalledTimes(1);
const query = mockRedirect.mock.calls[0][0] as Query;
const item = query.builder.queryData[0].filters?.items.at(-1);
expect(item?.key).toMatchObject({
key: 'service.name',
dataType: DataTypes.String,
type: '',
});
expect(item?.op).toBe('=');
expect(item?.value).toBe('frontend');
});
it('keeps the existing filter items ahead of the new one', () => {
const { result } = renderHook(() => useActiveLog());
result.current.onAddToQuery('k8s.pod.name', 'pod-1', '=');
const query = mockRedirect.mock.calls[0][0] as Query;
const items = query.builder.queryData[0].filters?.items ?? [];
expect(items).toHaveLength(
(initialQueriesMap.logs.builder.queryData[0].filters?.items.length ?? 0) + 1,
);
expect(items.at(-1)?.key?.key).toBe('k8s.pod.name');
});
});

View File

@@ -11,7 +11,6 @@ jest.mock('hooks/useIsTextSelected');
const mockOnSetActiveLog = jest.fn();
const mockOnClearActiveLog = jest.fn();
const mockOnAddToQuery = jest.fn();
const mockOnGroupByAttribute = jest.fn();
const mockIsTextSelected = jest.fn();
const mockLog: ILog = {
@@ -44,7 +43,6 @@ beforeEach(() => {
onSetActiveLog: mockOnSetActiveLog,
onClearActiveLog: mockOnClearActiveLog,
onAddToQuery: mockOnAddToQuery,
onGroupByAttribute: mockOnGroupByAttribute,
});
});
@@ -80,7 +78,6 @@ it('should toggle off when clicking the same active log', () => {
onSetActiveLog: mockOnSetActiveLog,
onClearActiveLog: mockOnClearActiveLog,
onAddToQuery: mockOnAddToQuery,
onGroupByAttribute: mockOnGroupByAttribute,
});
const { result } = renderHook(() => useLogDetailHandlers());

View File

@@ -25,5 +25,4 @@ export type UseActiveLog = {
operator: string,
dataType?: DataTypes,
) => void;
onGroupByAttribute: (fieldKey: string, dataType?: DataTypes) => Promise<void>;
};

View File

@@ -1,40 +1,16 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { QueryParams } from 'constants/query';
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
import { MetricsType } from 'container/MetricsApplication/constant';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { getFilterQueryData } from 'container/LogDetailedView/logAttributeActions.utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import { ILog } from 'types/api/logs/log';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
import { UseActiveLog } from './types';
export function getOldLogsOperatorFromNew(operator: string): string {
switch (operator) {
case OPERATORS['=']:
return OPERATORS.IN;
case OPERATORS['!=']:
return OPERATORS.NIN;
default:
return operator;
}
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export const useActiveLog = (): UseActiveLog => {
const queryClient = useQueryClient();
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const { notifications } = useNotifications();
const [activeLog, setActiveLog] = useState<ILog | null>(null);
@@ -58,121 +34,32 @@ export const useActiveLog = (): UseActiveLog => {
const onClearActiveLog = useCallback((): void => setActiveLog(null), []);
const onAddToQueryExplorer = useCallback(
async (
const onAddToQuery = useCallback(
(
fieldKey: string,
fieldValue: string,
operator: string,
dataType?: DataTypes,
fieldType?: MetricsType | undefined,
): Promise<void> => {
try {
const keysAutocompleteResponse = await queryClient.fetchQuery(
[QueryBuilderKeys.GET_AGGREGATE_KEYS, fieldKey],
async () =>
getAggregateKeys({
searchText: fieldKey,
aggregateOperator:
currentQuery.builder.queryData[0].aggregateOperator || '',
dataSource: currentQuery.builder.queryData[0].dataSource,
aggregateAttribute:
currentQuery.builder.queryData[0].aggregateAttribute?.key || '',
}),
);
): void => {
const nextQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item) =>
getFilterQueryData(item, { fieldKey, dataType }, fieldValue, operator),
),
},
};
const keysAutocomplete: BaseAutocompleteData[] =
keysAutocompleteResponse.payload?.attributeKeys || [];
const existAutocompleteKey = chooseAutocompleteFromCustomValue(
keysAutocomplete,
fieldKey,
dataType,
fieldType,
);
const currentOperator = getOperatorValue(operator);
const nextQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item) => ({
...item,
filters: {
...item.filters,
items: [
...(item.filters?.items || []),
{
id: uuid(),
key: existAutocompleteKey,
op: currentOperator,
value: fieldValue,
},
],
op: item.filters?.op || 'AND',
},
})),
},
};
redirectWithQueryBuilderData(nextQuery);
} catch {
notifications.error({ message: SOMETHING_WENT_WRONG });
}
redirectWithQueryBuilderData(nextQuery);
},
[currentQuery, notifications, queryClient, redirectWithQueryBuilderData],
);
const onGroupByAttribute = useCallback(
async (fieldKey: string, dataType?: DataTypes): Promise<void> => {
try {
const keysAutocompleteResponse = await queryClient.fetchQuery(
[QueryBuilderKeys.GET_AGGREGATE_KEYS, fieldKey],
// eslint-disable-next-line sonarjs/no-identical-functions
async () =>
getAggregateKeys({
searchText: fieldKey,
aggregateOperator:
currentQuery.builder.queryData[0].aggregateOperator || '',
dataSource: currentQuery.builder.queryData[0].dataSource,
aggregateAttribute:
currentQuery.builder.queryData[0].aggregateAttribute?.key || '',
}),
);
const keysAutocomplete: BaseAutocompleteData[] =
keysAutocompleteResponse.payload?.attributeKeys || [];
const existAutocompleteKey = chooseAutocompleteFromCustomValue(
keysAutocomplete,
fieldKey,
dataType,
);
const nextQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item) => ({
...item,
groupBy: [...item.groupBy, existAutocompleteKey],
})),
},
};
redirectWithQueryBuilderData(nextQuery);
} catch {
notifications.error({ message: SOMETHING_WENT_WRONG });
}
},
[currentQuery, notifications, queryClient, redirectWithQueryBuilderData],
[currentQuery, redirectWithQueryBuilderData],
);
return {
activeLog,
onSetActiveLog,
onClearActiveLog,
onAddToQuery: onAddToQueryExplorer,
onGroupByAttribute,
onAddToQuery,
};
};

View File

@@ -1,24 +0,0 @@
import { QueryOperatorsMultiVal } from 'lib/logql/tokens';
type Keys = keyof typeof QueryOperatorsMultiVal;
type Values = (typeof QueryOperatorsMultiVal)[Keys];
interface GenerateFilterQueryParams {
fieldKey: string;
fieldValue: string;
type: Values;
}
export const generateFilterQuery = ({
fieldKey,
fieldValue,
type,
}: GenerateFilterQueryParams): string => {
let generatedQueryString = `${fieldKey} ${type} `;
if (typeof fieldValue === 'number') {
generatedQueryString += `(${fieldValue})`;
} else {
generatedQueryString += `('${fieldValue}')`;
}
return generatedQueryString;
};

View File

@@ -217,8 +217,6 @@ function ListPanelRenderer({
log={logDetail.activeLog}
selectedTab={logDetail.selectedTab}
onClose={logDetail.onClose}
onAddToQuery={logDetail.onAddToQuery}
onClickActionItem={logDetail.onAddToQuery}
onNavigateLog={logDetail.onNavigateLog}
logs={logDetail.logs}
isListViewPanel

View File

@@ -18,7 +18,6 @@ export interface ListLogDetail {
selectedTab: VIEWS;
logs: ILog[];
onClose: () => void;
onAddToQuery: ReturnType<typeof useLogDetailHandlers>['onAddToQuery'];
onNavigateLog: (log: ILog) => void;
}
@@ -49,13 +48,8 @@ export function useListRowInteraction({
const isLogs = signal === TelemetrytypesSignalDTO.logs;
const isTraces = signal === TelemetrytypesSignalDTO.traces;
const {
activeLog,
onAddToQuery,
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
} = useLogDetailHandlers();
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
useLogDetailHandlers();
const logs = useMemo(
() => (isLogs ? rows.map(rawRowToILog) : []),
@@ -90,7 +84,6 @@ export function useListRowInteraction({
selectedTab: selectedTab ?? VIEW_TYPES.OVERVIEW,
logs,
onClose: handleCloseLogDetail,
onAddToQuery,
onNavigateLog: handleSetActiveLog,
}
: null,
@@ -100,7 +93,6 @@ export function useListRowInteraction({
selectedTab,
logs,
handleCloseLogDetail,
onAddToQuery,
handleSetActiveLog,
],
);

View File

@@ -1,11 +1,11 @@
import { ReactNode } from 'react';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Tooltip, TooltipProps } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import styles from './EntityMetadataItem.module.scss';
interface EntityMetadataItemProps {
tooltip: string;
tooltip: TooltipProps['title'];
icon?: ReactNode;
children: ReactNode;
}

View File

@@ -8,3 +8,12 @@
// Keep Typography.Text at the row's size (it reads this var, not inherited font-size).
--typography-font-size: var(--periscope-font-size-base);
}
.tokenTooltip {
composes: tooltipBody from '../shared.module.scss';
min-width: 180px;
}
.tokenTooltipTitle {
padding-bottom: var(--spacing-4);
}

View File

@@ -1,10 +1,18 @@
import { CalendarClock, Server, Timer } from '@signozhq/icons';
import { CalendarClock, Coins, Landmark, Server, Timer } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import cx from 'classnames';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import {
AiTokenCounts,
formatCost,
formatTokens,
getTotalInputTokens,
hasValue,
} from '../utils/aiTraceUtils';
import EntityMetadataItem from './EntityMetadataItem';
import TokenUsageTooltip from './TokenUsageTooltip';
import styles from './EntityMetadataRow.module.scss';
@@ -16,6 +24,8 @@ interface EntityMetadataRowProps {
execTimePercent?: number;
timestamp?: string;
statusCode?: string | number;
tokens?: AiTokenCounts;
cost?: number | null;
}
const ICON_SIZE = 14;
@@ -32,8 +42,11 @@ function EntityMetadataRow({
execTimePercent,
timestamp,
statusCode,
tokens,
cost,
}: EntityMetadataRowProps): JSX.Element {
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
const totalInput = tokens && getTotalInputTokens(tokens);
const durationTooltip =
entity === 'trace' ? 'Trace Duration' : 'Span Duration';
// Single source of duration formatting so both rows label units identically.
@@ -91,6 +104,24 @@ function EntityMetadataRow({
<HttpStatusBadge statusCode={statusCode} />
</EntityMetadataItem>
)}
{tokens && (
<EntityMetadataItem
tooltip={<TokenUsageTooltip tokens={tokens} />}
icon={<Coins size={ICON_SIZE} />}
>
Tokens: {formatTokens(totalInput)} → {formatTokens(tokens.output)}
</EntityMetadataItem>
)}
{hasValue(cost) && (
<EntityMetadataItem
tooltip="Total cost"
icon={<Landmark size={ICON_SIZE} />}
>
Cost — {formatCost(cost)}
</EntityMetadataItem>
)}
</div>
);
}
@@ -102,6 +133,8 @@ EntityMetadataRow.defaultProps = {
execTimePercent: undefined,
timestamp: undefined,
statusCode: undefined,
tokens: undefined,
cost: undefined,
};
export default EntityMetadataRow;

View File

@@ -0,0 +1,35 @@
import { Typography } from '@signozhq/ui/typography';
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
import { AiTokenCounts } from '../utils/aiTraceUtils';
import styles from './EntityMetadataRow.module.scss';
interface TokenUsageTooltipProps {
tokens: AiTokenCounts;
}
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
const { input, output, cacheRead, cacheWrite, reasoning } = tokens;
return (
<div className={styles.tokenTooltip}>
<Typography.Text
size="small"
weight="normal"
className={styles.tokenTooltipTitle}
>
Usage Breakdown
</Typography.Text>
<TokenBreakdown
input={input}
output={output}
cacheRead={cacheRead}
cacheWrite={cacheWrite}
reasoning={reasoning}
/>
</div>
);
}
export default TokenUsageTooltip;

View File

@@ -1,9 +1,11 @@
import { useMemo } from 'react';
import { Link2 } from '@signozhq/icons';
import dayjs from 'dayjs';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
import { getAIUsageDetails, convertToAiTokens } from '../utils/aiTraceUtils';
import { HIGHLIGHTED_OPTIONS } from './config';
import {
LinkedSpansPanel,
@@ -31,6 +33,10 @@ function SpanSummary({
}: SpanSummaryProps): JSX.Element {
const percentile = useSpanPercentile(selectedSpan);
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
const aiUsage = useMemo(
() => getAIUsageDetails(selectedSpan)?.usage,
[selectedSpan],
);
return (
<>
@@ -63,6 +69,8 @@ function SpanSummary({
: undefined
}
timestamp={dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
tokens={aiUsage && convertToAiTokens(aiUsage)}
cost={aiUsage?.cost}
/>
<div className={styles.spanInfoItem}>

View File

@@ -95,4 +95,26 @@ describe('SpanSummary', () => {
expect(screen.queryByText(/of total exec time/)).not.toBeInTheDocument();
});
it('renders tokens and cost from the gen_ai usage attributes', () => {
const span = {
...createMockSpan(),
attributes: {
'gen_ai.usage.input_tokens': 1000,
'gen_ai.usage.output_tokens': 9,
'signoz.gen_ai.usage.tokens.cost': 0.000009,
},
};
render(<SpanSummary selectedSpan={span} />);
expect(screen.getByText(/Tokens: 1,000 → 9/)).toBeInTheDocument();
expect(screen.getByText(/Cost — \$ 0.000009/)).toBeInTheDocument();
});
it('omits tokens and cost for non-AI spans', () => {
render(<SpanSummary selectedSpan={createMockSpan()} />);
expect(screen.queryByText(/Tokens:/)).not.toBeInTheDocument();
expect(screen.queryByText(/Cost —/)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,37 @@
import { Badge } from '@signozhq/ui/badge';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { getAiSpanKind } from '../utils/aiTraceUtils';
import { AI_SPAN_BADGES } from './constants';
interface SpanHintBadgeProps {
span: SpanV3;
}
function SpanHintBadge({ span }: SpanHintBadgeProps): JSX.Element | null {
const aiSpanKind = getAiSpanKind(span);
if (aiSpanKind) {
const { label, color } = AI_SPAN_BADGES[aiSpanKind];
return (
<span style={{ display: 'inline-flex' }}>
<Badge
variant="outlined"
color={color}
testId={`span-hint-badge-${aiSpanKind}`}
>
{label}
</Badge>
</span>
);
}
if (!span.response_status_code) {
return null;
}
return <HttpStatusBadge statusCode={span.response_status_code} />;
}
export default SpanHintBadge;

View File

@@ -0,0 +1,12 @@
import type { BadgeColorType } from '@signozhq/ui/badge';
import { AiSpanKind } from '../utils/aiTraceUtils';
export const AI_SPAN_BADGES: Record<
AiSpanKind,
{ label: string; color: BadgeColorType }
> = {
[AiSpanKind.Llm]: { label: 'LLM', color: 'success' },
[AiSpanKind.Tool]: { label: 'TOOL', color: 'warning' },
[AiSpanKind.Agent]: { label: 'AGENT', color: 'highlight-danger' },
};

View File

@@ -8,16 +8,12 @@
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--l2-foreground);
margin-bottom: 6px;
}
.name {

View File

@@ -9,28 +9,17 @@
}
.content {
font-size: 12px;
color: var(--l1-foreground);
composes: tooltipBody from '../shared.module.scss';
min-width: 223px;
max-width: 360px;
}
.name {
font-weight: 600;
margin-bottom: 4px;
.title {
composes: tooltipTitle from '../shared.module.scss';
}
.row {
line-height: 1.5;
color: var(--l2-foreground);
}
.preview {
// container for additional preview rows
}
.previewKey {
color: var(--l2-foreground);
}
.previewValue {
color: var(--l1-foreground);
.header {
display: flex;
flex-direction: column;
padding-bottom: var(--spacing-2);
}

View File

@@ -1,4 +1,5 @@
import { Tooltip } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
@@ -7,6 +8,11 @@ import { useMemo } from 'react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { toFixed } from 'utils/toFixed';
import { getAIUsageDetails, SpanAiDetails } from '../utils/aiTraceUtils';
import SpanUsageBreakdown from './SpanUsageBreakdown';
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
import styles from './SpanHoverCard.module.scss';
/**
@@ -33,6 +39,7 @@ export interface SpanTooltipContentProps {
relativeStartMs: number;
durationMs: number;
previewRows?: SpanPreviewRow[];
ai?: SpanAiDetails;
}
export function SpanTooltipContent({
@@ -42,33 +49,60 @@ export function SpanTooltipContent({
relativeStartMs,
durationMs,
previewRows,
ai,
}: SpanTooltipContentProps): JSX.Element {
const { time: formattedDuration, timeUnitName } =
convertTimeToRelevantUnit(durationMs);
return (
<div className={styles.content}>
<div className={styles.name} style={{ color }}>
{spanName}
</div>
<div className={styles.row}>status: {hasError ? 'error' : 'ok'}</div>
<div className={styles.row}>start: {toFixed(relativeStartMs, 2)} ms</div>
<div className={styles.row}>
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
<div className={styles.header}>
<Typography.Text
className={styles.title}
size="small"
weight="normal"
style={{ color }}
>
{spanName}
</Typography.Text>
{ai?.usage && (
<Typography.Text size="small" weight="normal">
Usage Breakdown
</Typography.Text>
)}
</div>
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
<TraceTooltipSection>
{ai?.model && <TraceTooltipRow label="model" value={ai.model} />}
{ai?.toolName && <TraceTooltipRow label="tool" value={ai.toolName} />}
{ai?.agentName && <TraceTooltipRow label="agent" value={ai.agentName} />}
<TraceTooltipRow
label="status"
value={hasError ? 'error' : 'ok'}
testId="span-hover-card-status"
/>
<TraceTooltipRow
label="start"
value={`${toFixed(relativeStartMs, 2)} ms`}
testId="span-hover-card-start"
/>
<TraceTooltipRow
label="duration"
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
testId="span-hover-card-duration"
/>
</TraceTooltipSection>
{previewRows && previewRows.length > 0 && (
<div className={styles.preview}>
<TraceTooltipSection>
{previewRows.map((row) => (
<div
<TraceTooltipRow
key={row.key}
className={styles.row}
data-testid={`span-hover-card-preview-${row.key}`}
>
<span className={styles.previewKey}>{row.key}:</span>{' '}
<span className={styles.previewValue}>{row.value}</span>
</div>
label={row.key}
value={row.value}
testId={`span-hover-card-preview-${row.key}`}
/>
))}
</div>
</TraceTooltipSection>
)}
</div>
);
@@ -130,6 +164,7 @@ export function SpanHoverCard({
relativeStartMs: span.timestamp - traceStartTime,
durationMs: span.duration_nano / 1e6,
previewRows,
ai: getAIUsageDetails(span),
},
};
}, [

View File

@@ -0,0 +1,38 @@
import { formatCost, hasValue, SpanAiUsage } from '../utils/aiTraceUtils';
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
interface SpanUsageBreakdownProps {
usage: SpanAiUsage;
}
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
const {
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
reasoningTokens,
cost,
} = usage;
return (
<>
<TokenBreakdown
input={inputTokens}
output={outputTokens}
cacheRead={cacheReadTokens}
cacheWrite={cacheCreationTokens}
reasoning={reasoningTokens}
/>
{hasValue(cost) && (
<TraceTooltipSection>
<TraceTooltipRow isHeading label="Cost" value={formatCost(cost)} />
</TraceTooltipSection>
)}
</>
);
}
export default SpanUsageBreakdown;

View File

@@ -0,0 +1,81 @@
import {
AiTokenCounts,
formatTokens,
getTokenLabel,
getTotalInputTokens,
} from '../utils/aiTraceUtils';
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
type TokenBreakdownProps = AiTokenCounts;
function TokenBreakdown({
input,
output,
cacheRead,
cacheWrite,
reasoning,
}: TokenBreakdownProps): JSX.Element {
const totalInput = getTotalInputTokens({ input, cacheRead, cacheWrite });
return (
<>
<TraceTooltipSection>
<TraceTooltipRow
label={getTokenLabel('Total Input', totalInput)}
value={formatTokens(totalInput)}
isHeading
/>
<TraceTooltipRow
label={getTokenLabel('Cache Read', cacheRead)}
value={formatTokens(cacheRead)}
isNested
/>
<TraceTooltipRow
label={getTokenLabel('Cache Write', cacheWrite)}
value={formatTokens(cacheWrite)}
isNested
/>
<TraceTooltipRow
label={getTokenLabel('Input', input)}
value={formatTokens(input)}
isNested
/>
</TraceTooltipSection>
<TraceTooltipSection>
<TraceTooltipRow
label={getTokenLabel('Reasoning', reasoning)}
value={formatTokens(reasoning)}
isHeading
/>
</TraceTooltipSection>
<TraceTooltipSection>
<TraceTooltipRow
label={getTokenLabel('Output', output)}
value={formatTokens(output)}
isHeading
/>
</TraceTooltipSection>
<TraceTooltipSection>
<TraceTooltipRow
label="Total Token Usage"
value={formatTokens(totalInput + (output ?? 0))}
isHeading
/>
</TraceTooltipSection>
</>
);
}
TokenBreakdown.defaultProps = {
input: undefined,
output: undefined,
cacheRead: undefined,
cacheWrite: undefined,
reasoning: undefined,
};
export default TokenBreakdown;

View File

@@ -186,6 +186,8 @@ function TraceDetailsHeader({
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
)}
statusCode={traceSummary.rootSpanStatusCode}
tokens={traceSummary.ai?.tokens}
cost={traceSummary.ai?.totalCost}
/>
) : (
<DetailsLoader />

View File

@@ -1,6 +1,7 @@
import { fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { render } from 'tests/test-utils';
@@ -176,9 +177,19 @@ describe('TraceDetailsHeader – trace metadata row', () => {
hasMissingSpans: false,
totalSpansCount: 42,
totalErrorSpansCount: 0,
ai: {
tokens: {
input: 12040,
output: 3110,
cacheRead: 0,
cacheWrite: 0,
reasoning: 0,
},
totalCost: 0.0421,
},
};
const mockSummary = (data?: typeof traceMetadata): void => {
const mockSummary = (data?: SpantypesGettableTraceSummaryDTO): void => {
jest.mocked(useTraceSummary).mockReturnValue({ data, isLoading: false });
};
@@ -201,6 +212,77 @@ describe('TraceDetailsHeader – trace metadata row', () => {
expect(screen.getByText(duration)).toBeInTheDocument();
});
it('renders AI tokens and cost when the summary has them', () => {
mockSummary(traceMetadata);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
});
it('omits AI tokens and cost when the summary has no ai field', () => {
mockSummary({ ...traceMetadata, ai: undefined });
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.queryByText(/^Tokens:/)).not.toBeInTheDocument();
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
});
it('shows AI tokens when input and output are both zero', () => {
mockSummary({
...traceMetadata,
ai: {
...traceMetadata.ai,
tokens: { ...traceMetadata.ai.tokens, input: 0, output: 0 },
},
} as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
});
it('shows zero for a missing input', () => {
mockSummary({
...traceMetadata,
ai: {
...traceMetadata.ai,
tokens: { ...traceMetadata.ai.tokens, input: undefined },
},
} as unknown as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 0 → 3,110')).toBeInTheDocument();
});
it('shows zero AI tokens when input and output are both missing', () => {
mockSummary({
...traceMetadata,
ai: {
...traceMetadata.ai,
tokens: {
...traceMetadata.ai.tokens,
input: undefined,
output: undefined,
},
},
} as unknown as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
});
it('omits the cost when the summary sends null', () => {
mockSummary({
...traceMetadata,
ai: { ...traceMetadata.ai, totalCost: null },
} as unknown as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
});
it('is shown by default and can be hidden / shown again via the Trace options menu', async () => {
const user = userEvent.setup({ delay: null });
mockSummary(traceMetadata);

View File

@@ -30,13 +30,9 @@
// lives here. Shares visual treatment with the waterfall hover popover
// but is positioned `fixed` instead of anchored.
.tooltip {
composes: tooltipSurface from '../shared.module.scss';
position: fixed;
z-index: 1000;
pointer-events: none;
background-color: var(--l1-background);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid var(--l2-border);
color: var(--l1-foreground);
}

View File

@@ -8,6 +8,7 @@ import { EventTooltipContent } from '../SpanHoverCard/EventTooltipContent';
import { SpanTooltipContent } from '../SpanHoverCard/SpanHoverCard';
import { DEFAULT_ROW_HEIGHT } from './constants';
import { useCanvasSetup } from './hooks/useCanvasSetup';
import { useCursorTooltipPosition } from './hooks/useCursorTooltipPosition';
import { useFlamegraphCrosshair } from './hooks/useFlamegraphCrosshair';
import { useFlamegraphDrag } from './hooks/useFlamegraphDrag';
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
@@ -200,15 +201,11 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
handleHoverMouseLeave,
]);
const tooltipRef = useCursorTooltipPosition<HTMLDivElement>(tooltipContent);
const tooltipElement = tooltipContent
? createPortal(
<div
className={styles.tooltip}
style={{
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
}}
>
<div ref={tooltipRef} className={styles.tooltip}>
{tooltipContent.event ? (
<EventTooltipContent
eventName={tooltipContent.event.name}

View File

@@ -0,0 +1,36 @@
import { RefObject, useLayoutEffect, useRef } from 'react';
const CURSOR_OFFSET = 15;
interface CursorAnchor {
clientX: number;
clientY: number;
}
function resolveAxis(cursor: number, size: number, viewport: number): number {
const after = cursor + CURSOR_OFFSET;
if (after + size <= viewport) {
return after;
}
return Math.max(0, cursor - CURSOR_OFFSET - size);
}
/** Places a fixed element beside the cursor, flipping sides when it would overflow the viewport. */
export function useCursorTooltipPosition<T extends HTMLElement>(
anchor: CursorAnchor | null,
): RefObject<T> {
const ref = useRef<T>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el || !anchor) {
return;
}
const left = resolveAxis(anchor.clientX, el.offsetWidth, window.innerWidth);
const top = resolveAxis(anchor.clientY, el.offsetHeight, window.innerHeight);
el.style.left = `${left}px`;
el.style.top = `${top}px`;
}, [anchor]);
return ref;
}

View File

@@ -0,0 +1,47 @@
.section {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-2) 0;
border-top: 1px solid;
border-image: linear-gradient(
90deg,
var(--l3-background-transparent),
var(--l3-border),
var(--l3-background-transparent)
)
1;
}
.section:first-child {
padding-top: 0;
border-top: none;
}
.section:last-child {
padding-bottom: 0;
}
.row {
display: grid;
grid-template-columns: auto auto;
justify-content: space-between;
column-gap: var(--spacing-2);
min-width: 0;
}
.nestedRow {
padding-left: var(--spacing-6);
}
.rowLabel {
min-width: 0;
overflow-wrap: anywhere;
}
.rowValue {
min-width: 0;
overflow-wrap: anywhere;
text-align: right;
font-variant-numeric: tabular-nums;
}

View File

@@ -0,0 +1,42 @@
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './TraceTooltip.module.scss';
interface TraceTooltipRowProps {
label: string;
value?: string;
isNested?: boolean;
isHeading?: boolean;
testId?: string;
}
function TraceTooltipRow({
label,
value,
isNested,
isHeading,
testId,
}: TraceTooltipRowProps): JSX.Element {
return (
<div
className={cx(styles.row, isNested && styles.nestedRow)}
data-testid={testId}
>
<Typography.Text
size="small"
color={isHeading ? undefined : 'muted'}
className={styles.rowLabel}
>
{label}
</Typography.Text>
{value !== undefined && (
<Typography.Text size="small" className={styles.rowValue}>
{value}
</Typography.Text>
)}
</div>
);
}
export default TraceTooltipRow;

View File

@@ -0,0 +1,15 @@
import { ReactNode } from 'react';
import styles from './TraceTooltip.module.scss';
interface TraceTooltipSectionProps {
children: ReactNode;
}
function TraceTooltipSection({
children,
}: TraceTooltipSectionProps): JSX.Element {
return <div className={styles.section}>{children}</div>;
}
export default TraceTooltipSection;

View File

@@ -203,8 +203,9 @@
.statusCell {
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
cursor: pointer;
padding-left: var(--spacing-2);
--badge-border-radius: 2px;
--badge-padding: 3px 6px;
@@ -394,6 +395,11 @@
}
}
.aiSpanIcon {
flex-shrink: 0;
margin: 0 var(--spacing-2);
}
.treeLabel {
color: var(--l1-foreground);
font-family: 'Inter';

View File

@@ -20,7 +20,6 @@ import {
} from '@tanstack/react-table';
import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
import cx from 'classnames';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import TimelineV3 from 'components/TimelineV3/TimelineV3';
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
@@ -28,7 +27,13 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
import { ChevronDown, ChevronRight, Link, ListPlus } from '@signozhq/icons';
import {
ChevronDown,
ChevronRight,
Link,
ListPlus,
Sparkle,
} from '@signozhq/icons';
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
import { resolveSpanColor } from 'pages/TraceDetailsV3/utils';
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
@@ -39,6 +44,8 @@ import { toFixed } from 'utils/toFixed';
import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent';
import { SpanHoverCard } from '../../../SpanHoverCard/SpanHoverCard';
import SpanHintBadge from '../../../SpanHintBadge/SpanHintBadge';
import { isAiSpan } from '../../../utils/aiTraceUtils';
import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal';
import { IInterestedSpan } from '../../types';
@@ -318,14 +325,23 @@ const SpanOverview = memo(function SpanOverview({
</span>
{/* Colored service dot */}
<span
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
style={
{
'--service-dot-color': effectiveColor,
} as React.CSSProperties
}
/>
{isAiSpan(span) ? (
<Sparkle
size={10}
color={effectiveColor}
fill={effectiveColor}
className={styles.aiSpanIcon}
/>
) : (
<span
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
style={
{
'--service-dot-color': effectiveColor,
} as React.CSSProperties
}
/>
)}
{/* Span name + service name */}
<span className={styles.treeLabel}>
@@ -944,9 +960,7 @@ function Success(props: ISuccessProps): JSX.Element {
onMouseLeave={(): void => applyHoverClass(null)}
onClick={(): void => handleSpanClick(span)}
>
{span.response_status_code && (
<HttpStatusBadge statusCode={span.response_status_code} />
)}
<SpanHintBadge span={span} />
</div>
);
})}

View File

@@ -0,0 +1,20 @@
.tooltipBody {
display: flex;
flex-direction: column;
letter-spacing: var(--paragraph-small-400-letter-spacing);
color: var(--l1-foreground-hover);
}
.tooltipTitle {
font-family: 'SF Mono', 'Geist Mono', monospace;
word-break: break-all;
}
.tooltipSurface {
background-color: color-mix(in srgb, var(--l3-background) 60%, transparent);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
padding: var(--spacing-4);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}

View File

@@ -46,6 +46,8 @@ import {
traceDetailFieldValues,
traceFlamegraphResponse,
traceSummaryResponse,
TRACE_SCENARIOS,
type TraceScenario,
traceWaterfallResponse,
} from './__story_mockdata__/traceDetails';
@@ -103,6 +105,13 @@ const spanLogScopeOf = (body: QueryRangeRequestV5): SpanLogScope => {
export const traceDetailsMocks = defineStoryMocks({
controls: {
scenario: choiceControl<TraceScenario>('Scenario', {
group: TRACE,
description:
'The spans the trace is built from: a checkout, one span per HTTP status badge, or an agent run with LLM, tool and agent spans and the AI totals in the header.',
options: TRACE_SCENARIOS,
value: 'checkout',
}),
spans: countControl('Spans', {
group: TRACE,
description:
@@ -151,6 +160,7 @@ export const traceDetailsMocks = defineStoryMocks({
},
handlers: (values, response) => {
const trace = {
scenario: values.scenario,
spans: values.spans,
errors: values.errors,
traceStart: traceStartOf(),
@@ -288,7 +298,7 @@ export const traceDetailsMocks = defineStoryMocks({
},
config: (values) => ({
route: values.spanPanel
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.spans))}`
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.scenario, values.spans))}`
: `/trace/${STORY_TRACE_ID}`,
}),
effect: (values) => {

View File

@@ -13,7 +13,12 @@ import {
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { STORY_TRACE_ID } from './__story_mockdata__/traceDetails';
import {
focusSpanIndex,
scenarioSpanCount,
spanIdAt,
STORY_TRACE_ID,
} from './__story_mockdata__/traceDetails';
import {
openRowMenu,
startDownload,
@@ -96,6 +101,40 @@ export const BottomStripLoading: Story = {
args: { bottomStrip: true, dataState: 'loading' },
};
/**
* One span per HTTP status badge the waterfall draws: informational, success,
* redirect, client and server errors, and a span with no status code and so no
* badge.
*/
export const StatusCodes: Story = {
args: {
scenario: 'status-codes',
spans: scenarioSpanCount('status-codes'),
spanPanel: false,
},
};
const AI_SPANS = scenarioSpanCount('ai');
/**
* An agent run: LLM, tool and agent badges with sparkle icons in the waterfall,
* token and cost totals in the header, the span panel on an LLM call with its
* tokens and cost, and that span's hover card open on its usage breakdown.
*/
export const AiTrace: Story = {
args: { scenario: 'ai', spans: AI_SPANS },
play: async ({ canvasElement }): Promise<void> => {
const row = await within(canvasElement).findByTestId(
`cell-0-${spanIdAt(focusSpanIndex('ai', AI_SPANS))}`,
undefined,
untilLoaded,
);
await fireEvent.mouseOver(row);
await screen.findByText('Usage Breakdown', undefined, untilLoaded);
},
};
/**
* Turns on Highlight errors, which writes `has_error = true` into the filter and
* expands the bar to show it.

View File

@@ -23,6 +23,10 @@ interface SpanTemplate {
name: string;
kind: string;
failing?: boolean;
/** The span the panel opens on when nothing fails. */
focus?: boolean;
/** `response_status_code`; failing spans default to 503, the rest to 200. */
statusCode?: string;
attributes: Record<string, string | number | boolean>;
}
@@ -123,6 +127,145 @@ const SPAN_TEMPLATES: SpanTemplate[] = [
},
];
/** One span per badge colour, plus a span with no status code and so no badge. */
const STATUS_CODE_TEMPLATES: SpanTemplate[] = [
['200', 'GET /status/ok'],
['101', 'GET /ws/upgrade'],
['201', 'POST /status/created'],
['204', 'DELETE /status/no-content'],
['301', 'GET /status/moved'],
['304', 'GET /status/not-modified'],
['400', 'POST /status/bad-request'],
['401', 'GET /status/unauthorized'],
['404', 'GET /status/not-found'],
['429', 'GET /status/rate-limited'],
['500', 'GET /status/internal-error'],
['502', 'GET /status/bad-gateway'],
['503', 'GET /status/unavailable'],
['504', 'GET /status/timeout'],
['', 'internal.compute'],
].map(
([statusCode, name]): SpanTemplate => ({
service: 'status-api',
name,
kind: statusCode ? 'Server' : 'Internal',
failing: Number(statusCode) >= 500,
statusCode,
attributes: statusCode
? {
'http.method': name.split(' ')[0],
'http.status_code': Number(statusCode),
}
: {},
}),
);
/**
* An agent run: agent, LLM and tool spans, each badged by kind. The LLM spans
* cover cache counted inside input (OpenAI), cache counted beside it
* (Anthropic), reasoning, and an embedding with input only.
*/
const AI_TEMPLATES: SpanTemplate[] = [
{
service: 'support-agent',
name: 'invoke_agent support-agent',
kind: 'Internal',
attributes: { 'gen_ai.agent.name': 'support-agent' },
},
{
service: 'support-agent',
name: 'chat gpt-4o',
kind: 'Client',
attributes: {
'gen_ai.request.model': 'gpt-4o-2024-08-06',
'gen_ai.usage.input_tokens': 1840,
'gen_ai.usage.output_tokens': 212,
'gen_ai.usage.cache_read.input_tokens': 1024,
'gen_ai.usage.cache_creation.input_tokens': 0,
'signoz.gen_ai.usage.tokens.cost': 0.00566,
},
},
{
service: 'support-agent',
name: 'execute_tool search_docs',
kind: 'Internal',
attributes: { 'gen_ai.tool.name': 'search_docs' },
},
{
service: 'support-agent',
name: 'chat claude-sonnet-4',
kind: 'Client',
focus: true,
attributes: {
'gen_ai.request.model': 'claude-sonnet-4-20250514',
'gen_ai.usage.input_tokens': 86,
'gen_ai.usage.output_tokens': 640,
'gen_ai.usage.cache_read.input_tokens': 4200,
'gen_ai.usage.cache_creation.input_tokens': 1300,
'signoz.gen_ai.usage.tokens.cost': 0.01853,
},
},
{
service: 'docs-api',
name: 'GET /docs/search',
kind: 'Server',
attributes: {
'http.method': 'GET',
'http.route': '/docs/search',
'http.status_code': 200,
},
},
{
service: 'support-agent',
name: 'execute_tool fetch_ticket',
kind: 'Internal',
failing: true,
attributes: { 'gen_ai.tool.name': 'fetch_ticket' },
},
{
service: 'support-agent',
name: 'invoke_agent triage-agent',
kind: 'Internal',
attributes: { 'gen_ai.agent.name': 'triage-agent' },
},
{
service: 'support-agent',
name: 'embeddings text-embedding-3-small',
kind: 'Client',
attributes: {
'gen_ai.request.model': 'text-embedding-3-small',
'gen_ai.usage.input_tokens': 312,
'signoz.gen_ai.usage.tokens.cost': 0.00000624,
},
},
{
service: 'support-agent',
name: 'chat o3-mini',
kind: 'Client',
attributes: {
'gen_ai.request.model': 'o3-mini-2025-01-31',
'gen_ai.usage.input_tokens': 920,
'gen_ai.usage.output_tokens': 1480,
'gen_ai.usage.reasoning.output_tokens': 1152,
'signoz.gen_ai.usage.tokens.cost': 0.007524,
},
},
];
export const TRACE_SCENARIOS = ['checkout', 'status-codes', 'ai'] as const;
export type TraceScenario = (typeof TRACE_SCENARIOS)[number];
const SCENARIO_TEMPLATES: Record<TraceScenario, SpanTemplate[]> = {
checkout: SPAN_TEMPLATES,
'status-codes': STATUS_CODE_TEMPLATES,
ai: AI_TEMPLATES,
};
/** Spans it takes for the scenario to show every template once. */
export const scenarioSpanCount = (scenario: TraceScenario): number =>
SCENARIO_TEMPLATES[scenario].length;
const TRACE_SERVICES = [
...new Set(SPAN_TEMPLATES.map(({ service }) => service)),
];
@@ -146,6 +289,7 @@ interface BuiltSpan {
}
interface TraceOptions {
scenario: TraceScenario;
spans: number;
errors: boolean;
/** Epoch milliseconds the root span starts at. */
@@ -156,24 +300,33 @@ export const spanIdAt = (index: number): string =>
`b17e${index.toString(16).padStart(12, '0')}`;
/**
* The span the story opens the panel on: the first failing one, so its events
* and error styling are on screen, and the root when nothing fails.
* The span the story opens the panel on: the scenario's focus span, else the
* first failing one, so its events and error styling are on screen, and the
* root when neither is in the trace.
*/
export const focusSpanIndex = (spans: number): number => {
const failing = SPAN_TEMPLATES.findIndex(({ failing }) => failing);
export const focusSpanIndex = (
scenario: TraceScenario,
spans: number,
): number => {
const templates = SCENARIO_TEMPLATES[scenario];
const focus = templates.findIndex((template) => template.focus);
const failing = templates.findIndex((template) => template.failing);
const index = focus >= 0 ? focus : failing;
return failing >= 0 && failing < spans ? failing : 0;
return index >= 0 && index < spans ? index : 0;
};
const buildSpans = ({
scenario,
spans,
errors,
traceStart,
}: TraceOptions): BuiltSpan[] => {
const built: BuiltSpan[] = [];
const templates = SCENARIO_TEMPLATES[scenario];
for (let index = 0; index < spans; index += 1) {
const template = SPAN_TEMPLATES[index % SPAN_TEMPLATES.length];
const template = templates[index % templates.length];
const parent = index === 0 ? undefined : built[parentIndexOf(index)];
// Children fit inside their parent's window, second child after the first,
// so no span outlives its parent and no two siblings overlap.
@@ -257,7 +410,8 @@ const waterfallSpan = (span: BuiltSpan): SpantypesWaterfallSpanDTO => ({
status_code: span.hasError ? 2 : 1,
status_code_string: span.hasError ? 'Error' : 'Ok',
status_message: span.hasError ? 'upstream unavailable' : '',
response_status_code: span.hasError ? '503' : '200',
response_status_code:
span.template.statusCode ?? (span.hasError ? '503' : '200'),
has_children: span.hasChildren,
sub_tree_node_count: span.subTreeNodeCount,
level: span.level,
@@ -304,6 +458,36 @@ export const traceWaterfallResponse = (
};
};
const numberAttribute = (span: BuiltSpan, key: string): number =>
Number(span.template.attributes[key] ?? 0);
/** The trace's AI totals, summed over its spans the way the backend does. */
const traceAiSummary = (
spans: BuiltSpan[],
): GetTraceSummary200['data']['ai'] => {
const aiSpans = spans.filter(({ template }) =>
Object.keys(template.attributes).some((key) => key.includes('gen_ai.')),
);
if (aiSpans.length === 0) {
return undefined;
}
const sum = (key: string): number =>
aiSpans.reduce((total, span) => total + numberAttribute(span, key), 0);
return {
tokens: {
input: sum('gen_ai.usage.input_tokens'),
output: sum('gen_ai.usage.output_tokens'),
cacheRead: sum('gen_ai.usage.cache_read.input_tokens'),
cacheWrite: sum('gen_ai.usage.cache_creation.input_tokens'),
reasoning: sum('gen_ai.usage.reasoning.output_tokens'),
},
totalCost: sum('signoz.gen_ai.usage.tokens.cost'),
};
};
export const traceSummaryResponse = (
options: TraceOptions & { missingSpans: boolean },
): GetTraceSummary200 => {
@@ -321,6 +505,7 @@ export const traceSummaryResponse = (
totalSpansCount: spans.length,
totalErrorSpansCount: spans.filter(({ hasError }) => hasError).length,
hasMissingSpans: options.missingSpans,
ai: traceAiSummary(spans),
},
};
};
@@ -472,7 +657,7 @@ export const spanLogRows = (
): RawRow[] => {
const step = ROOT_DURATION_MS / Math.max(count, 1);
const spans = buildSpans(options);
const focus = spans[focusSpanIndex(options.spans)];
const focus = spans[focusSpanIndex(options.scenario, options.spans)];
return Array.from({ length: count }, (_, index) => {
const at = options.traceStart + SCOPE_OFFSET_MS[scope] + index * step;

View File

@@ -9,9 +9,14 @@ import {
RESERVED_ERROR,
} from './utils/generateColorPair';
/** Like `||`, but keeps `0`. */
const valueOrUndefined = <T>(value: T): T | undefined =>
value || value === 0 ? value : undefined;
/**
* Look up an attribute from both `resource` and `attributes` on a span.
* Resources are checked first (service.name, k8s.* etc. live there).
* Falsy values count as missing, except `0`.
*
* Accepts both `SpanV3` (waterfall) and `FlamegraphSpan` (flamegraph) by typing
* structurally — the only fields touched are `resource` and `attributes`.
@@ -22,11 +27,14 @@ export function getSpanAttribute(
span: {
resource?: Record<string, string>;
attributes?: Record<string, any>;
tagMap?: Record<string, any>;
},
key: string,
): string | undefined {
return (
span.resource?.[key] || span.attributes?.[key] || (span as any).tagMap?.[key]
valueOrUndefined(span.resource?.[key]) ??
valueOrUndefined(span.attributes?.[key]) ??
valueOrUndefined(span.tagMap?.[key])
);
}

View File

@@ -0,0 +1,51 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import {
AiSpanKind,
getAiSpanKind,
getTotalInputTokens,
isAiSpan,
} from '../aiTraceUtils';
const spanWith = (attributes: Record<string, string | number>): SpanV3 =>
({ attributes, resource: {} }) as unknown as SpanV3;
describe('getAiSpanKind', () => {
it.each<[Record<string, string | number>, AiSpanKind]>([
[{ 'gen_ai.request.model': 'gpt-4o' }, AiSpanKind.Llm],
[{ 'gen_ai.usage.input_tokens': 0 }, AiSpanKind.Llm],
[{ 'gen_ai.usage.reasoning.output_tokens': 12 }, AiSpanKind.Llm],
[{ 'gen_ai.tool.name': 'search' }, AiSpanKind.Tool],
[{ 'gen_ai.agent.name': 'planner' }, AiSpanKind.Agent],
[
{ 'gen_ai.tool.name': 'search', 'gen_ai.request.model': 'gpt-4o' },
AiSpanKind.Tool,
],
[
{ 'gen_ai.agent.name': 'planner', 'gen_ai.usage.input_tokens': 10 },
AiSpanKind.Agent,
],
])('classifies %p as %s', (attributes, kind) => {
expect(getAiSpanKind(spanWith(attributes))).toBe(kind);
});
it('returns undefined for non-AI spans and empty values', () => {
expect(getAiSpanKind(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
expect(getAiSpanKind(spanWith({ 'gen_ai.tool.name': '' }))).toBeUndefined();
expect(isAiSpan(spanWith({}))).toBe(false);
});
});
describe('getTotalInputTokens', () => {
it.each([
// Cache reported inside input (OpenAI): input already includes it.
[{ input: 1000, cacheRead: 600, cacheWrite: 100 }, 1000],
// Cache reported separately (Anthropic): add it.
[{ input: 50, cacheRead: 600, cacheWrite: 100 }, 750],
[{ input: 71, cacheRead: 0, cacheWrite: 0 }, 71],
[{ input: 10 }, 10],
[{ cacheRead: 600 }, 600],
])('%p → %p', (tokens, expected) => {
expect(getTotalInputTokens(tokens)).toBe(expected);
});
});

View File

@@ -0,0 +1,168 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
export const GEN_AI_KEYS = {
requestModel: 'gen_ai.request.model',
toolName: 'gen_ai.tool.name',
agentName: 'gen_ai.agent.name',
inputTokens: 'gen_ai.usage.input_tokens',
outputTokens: 'gen_ai.usage.output_tokens',
cacheReadTokens: 'gen_ai.usage.cache_read.input_tokens',
cacheCreationTokens: 'gen_ai.usage.cache_creation.input_tokens',
reasoningTokens: 'gen_ai.usage.reasoning.output_tokens',
cost: 'signoz.gen_ai.usage.tokens.cost',
} as const;
export enum AiSpanKind {
Llm = 'llm',
Tool = 'tool',
Agent = 'agent',
}
const LLM_KEYS = [
GEN_AI_KEYS.requestModel,
GEN_AI_KEYS.inputTokens,
GEN_AI_KEYS.outputTokens,
GEN_AI_KEYS.cacheReadTokens,
GEN_AI_KEYS.cacheCreationTokens,
GEN_AI_KEYS.reasoningTokens,
];
function hasAttribute(span: SpanV3, key: string): boolean {
return getSpanAttribute(span, key) !== undefined;
}
/** Tool and agent spans may also carry model or usage keys, so they win over LLM. */
export function getAiSpanKind(span: SpanV3): AiSpanKind | undefined {
if (hasAttribute(span, GEN_AI_KEYS.toolName)) {
return AiSpanKind.Tool;
}
if (hasAttribute(span, GEN_AI_KEYS.agentName)) {
return AiSpanKind.Agent;
}
if (LLM_KEYS.some((key) => hasAttribute(span, key))) {
return AiSpanKind.Llm;
}
return undefined;
}
export function isAiSpan(span: SpanV3): boolean {
return getAiSpanKind(span) !== undefined;
}
/** Token counts; `null` or missing means the backend did not send the value. */
export interface AiTokenCounts {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
reasoning?: number;
}
export function hasValue(value: number | null | undefined): value is number {
return value !== undefined && value !== null;
}
/**
* Input including cache read/write, counted once. Providers that report cache
* inside `input` (OpenAI) have `input >= cache`; those that report it
* separately (Anthropic) usually have `input < cache`.
*/
export function getTotalInputTokens({
input,
cacheRead,
cacheWrite,
}: AiTokenCounts): number {
const inputValue = input ?? 0;
const cache = (cacheRead ?? 0) + (cacheWrite ?? 0);
return inputValue < cache ? inputValue + cache : inputValue;
}
/** `Input Token` for a count of 1, `Input Tokens` otherwise. */
export function getTokenLabel(
prefix: string,
count: number | null | undefined,
): string {
return `${prefix} ${count === 1 ? 'Token' : 'Tokens'}`;
}
/** Missing values render as `0`. */
export function formatTokens(value: number | null | undefined): string {
return hasValue(value) ? value.toLocaleString('en-US') : '0';
}
export function formatCost(value: number): string {
return `$ ${value.toLocaleString('en-US', { maximumFractionDigits: 10 })}`;
}
export interface SpanAiUsage {
inputTokens?: number;
outputTokens?: number;
cacheReadTokens?: number;
cacheCreationTokens?: number;
reasoningTokens?: number;
cost?: number;
}
export interface SpanAiDetails {
model?: string;
toolName?: string;
agentName?: string;
usage?: SpanAiUsage;
}
function getGenAiAttributeAsString(
span: SpanV3,
key: string,
): string | undefined {
const value = getSpanAttribute(span, key);
return value === undefined ? undefined : String(value);
}
function getGenAiAttributeAsNumber(
span: SpanV3,
key: string,
): number | undefined {
const value = getSpanAttribute(span, key);
if (value === undefined) {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
export function getAIUsageDetails(span: SpanV3): SpanAiDetails | undefined {
const usage: SpanAiUsage = {
inputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.inputTokens),
outputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.outputTokens),
cacheReadTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cacheReadTokens),
cacheCreationTokens: getGenAiAttributeAsNumber(
span,
GEN_AI_KEYS.cacheCreationTokens,
),
reasoningTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.reasoningTokens),
cost: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cost),
};
const hasUsage = Object.values(usage).some((value) => value !== undefined);
const details: SpanAiDetails = {
model: getGenAiAttributeAsString(span, GEN_AI_KEYS.requestModel),
toolName: getGenAiAttributeAsString(span, GEN_AI_KEYS.toolName),
agentName: getGenAiAttributeAsString(span, GEN_AI_KEYS.agentName),
usage: hasUsage ? usage : undefined,
};
return details.model || details.toolName || details.agentName || hasUsage
? details
: undefined;
}
export function convertToAiTokens(usage: SpanAiUsage): AiTokenCounts {
return {
input: usage.inputTokens,
output: usage.outputTokens,
cacheRead: usage.cacheReadTokens,
cacheWrite: usage.cacheCreationTokens,
reasoning: usage.reasoningTokens,
};
}

View File

@@ -1,3 +1,4 @@
import * as Sentry from '@sentry/react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
@@ -7,6 +8,10 @@ import { DataSource } from 'types/common/queryBuilder';
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
import { sanitizeOrderByForExplorer } from 'utils/sanitizeOrderBy';
jest.mock('@sentry/react', () => ({
captureEvent: jest.fn(),
}));
jest.mock('utils/aggregationConverter', () => ({
getParsedAggregationOptionsForOrderBy: jest.fn(),
}));
@@ -117,6 +122,7 @@ describe('sanitizeOrderByForExplorer', () => {
const result = sanitizeOrderByForExplorer(query);
expect(result).toStrictEqual([]);
expect(Sentry.captureEvent).not.toHaveBeenCalled();
});
it('handles missing orderBy by returning an empty array', () => {

View File

@@ -1,4 +1,3 @@
import * as Sentry from '@sentry/react';
import {
IBuilderQuery,
OrderByPayload,
@@ -20,15 +19,5 @@ export function sanitizeOrderByForExplorer(
const current = query.orderBy || [];
const hasInvalidOrderBy = current.some((o) => !allowed.has(o.columnName));
if (hasInvalidOrderBy) {
Sentry.captureEvent({
message: `Invalid orderBy: current: ${JSON.stringify(
current,
)} - allowed: ${JSON.stringify(Array.from(allowed))}`,
level: 'warning',
});
}
return current.filter((o) => allowed.has(o.columnName));
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/pagerduty"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/slack"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/telegram"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/webhook"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/alertmanager/config/receiver"
@@ -32,6 +33,7 @@ var customNotifierIntegrations = []string{
jira.Integration,
jsmops.Integration,
incidentio.Integration,
telegram.Integration,
}
func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
@@ -102,6 +104,11 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp
return incidentio.New(c, tmpl, l, templater)
})
}
for i, c := range nc.TelegramConfigs {
add(telegram.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
return telegram.New(c, tmpl, l, templater)
})
}
if errs.Len() > 0 {
return nil, &errs

View File

@@ -0,0 +1,279 @@
// Copyright (c) 2026 SigNoz, Inc.
// Copyright 2022 Prometheus Team
// SPDX-License-Identifier: Apache-2.0
// Package telegram delivers alerts to a Telegram chat. It follows upstream's
// notifier for everything about the transport and adds the channel title and
// the per-rule title and body templates carried on the _title_template and
// _body_template annotations.
package telegram
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
commoncfg "github.com/prometheus/common/config"
)
const (
Integration = "telegram"
// Telegram supports 4096 chars max - from https://limits.tginfo.me/en.
maxMessageLenRunes = 4096
parseModeHTML = "HTML"
)
type Notifier struct {
conf *alertmanagertypes.TelegramReceiverConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
}
func New(conf *alertmanagertypes.TelegramReceiverConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
if conf.HTTPConfig == nil {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "telegram http_config is nil")
}
if conf.APIUrl == nil {
withAPIURL := *conf
withAPIURL.APIUrl = config.DefaultGlobalConfig().TelegramAPIUrl
conf = &withAPIURL
}
// Rendering targets the HTML subset only. A receiver stored through the v1
// API can still carry another mode; failing here would fail the whole org's
// config, so the send is forced to HTML instead.
if conf.ParseMode != parseModeHTML {
l.WarnContext(context.Background(), "telegram parse_mode is not supported, sending as HTML", slog.String("parse_mode", conf.ParseMode))
htmlConf := *conf
htmlConf.ParseMode = parseModeHTML
conf = &htmlConf
}
httpclient, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
}
return &Notifier{
conf: conf,
tmpl: t,
logger: l,
client: httpclient,
retrier: &notify.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
}
logger := n.logger.With(slog.String("group_key", key.String()))
messageText, err := n.buildMessage(ctx, logger, as)
if err != nil {
return false, err
}
messageText, truncated := notify.TruncateInRunes(messageText, maxMessageLenRunes)
if truncated {
logger.WarnContext(ctx, "truncated message", slog.Int("max_runes", maxMessageLenRunes))
}
botToken, err := n.getBotToken()
if err != nil {
return true, err
}
chatID, err := n.getChatID()
if err != nil {
return true, err
}
buf, err := n.encodeSendMessage(chatID, messageText)
if err != nil {
return false, err
}
resp, err := notify.PostJSON(ctx, n.client, n.conf.APIUrl.String()+"/bot"+botToken+"/sendMessage", buf) //nolint:bodyclose
if err != nil {
return true, notify.RedactURL(err)
}
defer notify.Drain(resp)
shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body)
if err != nil {
return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
}
return shouldRetry, err
}
// encodeSendMessage builds the sendMessage payload with every value as a
// string, the encoding upstream's telebot client uses.
func (n *Notifier) encodeSendMessage(chatID int64, text string) (*bytes.Buffer, error) {
params := map[string]string{
"chat_id": strconv.FormatInt(chatID, 10),
"text": text,
"parse_mode": n.conf.ParseMode,
"disable_web_page_preview": "true",
}
if n.conf.DisableNotifications {
params["disable_notification"] = "true"
}
if n.conf.MessageThreadID != 0 {
params["message_thread_id"] = strconv.Itoa(n.conf.MessageThreadID)
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(params); err != nil {
return nil, err
}
return &buf, nil
}
// buildMessage sends the title in bold above the message. Each half comes from
// the rule's template when it has one and from the channel's otherwise; an
// empty title sends the message alone.
func (n *Notifier) buildMessage(ctx context.Context, logger *slog.Logger, as []*types.Alert) (string, error) {
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(as)
var result *alertmanagertypes.ExpandResult
if customTitle != "" || customBody != "" {
var err error
result, err = n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
}, as)
if err != nil {
return "", err
}
if len(result.MissingVars) > 0 {
logger.WarnContext(ctx, "unresolved variables in rule template", slog.Any("template_missing_vars", result.MissingVars))
}
}
var (
title, body string
err error
)
if customTitle != "" {
title, err = n.renderRuleTemplate(result.Title)
} else {
title, err = n.renderChannelTemplate(ctx, as, n.conf.Title)
}
if err != nil {
return "", err
}
if customBody != "" {
body, err = n.renderRuleTemplate(strings.Join(nonEmpty(result.Body), "\n\n"))
} else {
body, err = n.renderChannelTemplate(ctx, as, n.conf.Message)
}
if err != nil {
return "", err
}
if title == "" {
return body, nil
}
return strings.TrimSpace("<b>" + title + "</b>\n\n" + body), nil
}
// renderChannelTemplate reproduces upstream's rendering, including its choice
// of engine under parse_mode HTML: html/template escapes every interpolated
// value, so a channel template that does not pipe through html still sends.
func (n *Notifier) renderChannelTemplate(ctx context.Context, as []*types.Alert, text string) (string, error) {
if text == "" {
return "", nil
}
var tmplErr error
data := notify.GetTemplateData(ctx, n.tmpl, as, n.logger)
rendered := notify.TmplHTML(n.tmpl, data, &tmplErr)(text)
if tmplErr != nil {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to execute telegram template: %s", tmplErr.Error())
}
return strings.TrimSpace(rendered), nil
}
// renderRuleTemplate converts the templater's output from markdown, which is
// the format rule templates are authored in for every channel, into Telegram's
// HTML subset. The renderer escapes as it goes, so a stray < or & in an alert
// value cannot fail the send with "can't parse entities".
func (n *Notifier) renderRuleTemplate(markdown string) (string, error) {
rendered, err := markdownrenderer.RenderTelegramHTML(markdown)
if err != nil {
return "", err
}
return strings.TrimSpace(rendered), nil
}
func (n *Notifier) getBotToken() (string, error) {
if n.conf.BotTokenFile == "" {
return string(n.conf.BotToken), nil
}
content, err := os.ReadFile(n.conf.BotTokenFile)
if err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "could not read %s", n.conf.BotTokenFile)
}
return strings.TrimSpace(string(content)), nil
}
func (n *Notifier) getChatID() (int64, error) {
if n.conf.ChatIDFile == "" {
return n.conf.ChatID, nil
}
content, err := os.ReadFile(n.conf.ChatIDFile)
if err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "could not read %s", n.conf.ChatIDFile)
}
chatID, err := strconv.ParseInt(strings.TrimSpace(string(content)), 10, 64)
if err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "could not parse chat_id from %s", n.conf.ChatIDFile)
}
return chatID, nil
}
func nonEmpty(parts []string) []string {
kept := make([]string, 0, len(parts))
for _, part := range parts {
if part != "" {
kept = append(kept, part)
}
}
return kept
}

View File

@@ -0,0 +1,390 @@
package telegram
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"sync"
"testing"
"time"
test "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/alertmanagernotifytest"
"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/config"
"github.com/prometheus/alertmanager/notify"
"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"
)
// mockBotAPI answers sendMessage the way api.telegram.org does. Every
// parameter is posted as a string.
type mockBotAPI struct {
srv *httptest.Server
mu sync.Mutex
sent []map[string]string
}
func newMockBotAPI(t *testing.T) *mockBotAPI {
t.Helper()
return newMockBotAPIWithStatus(t, http.StatusOK)
}
func newMockBotAPIWithStatus(t *testing.T, status int) *mockBotAPI {
t.Helper()
mock := &mockBotAPI{}
mock.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var params map[string]string
_ = json.NewDecoder(r.Body).Decode(&params)
mock.mu.Lock()
mock.sent = append(mock.sent, params)
mock.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if status != http.StatusOK {
_, _ = w.Write([]byte(`{"ok":false,"error_code":` + strconv.Itoa(status) + `,"description":"failed"}`))
return
}
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":1,"date":0,"chat":{"id":` + params["chat_id"] + `,"type":"private"}}}`))
}))
t.Cleanup(mock.srv.Close)
return mock
}
func (m *mockBotAPI) only(t *testing.T) map[string]string {
t.Helper()
m.mu.Lock()
defer m.mu.Unlock()
require.Len(t, m.sent, 1)
return m.sent[0]
}
func newNotifier(t *testing.T, mock *mockBotAPI) *Notifier {
t.Helper()
return newNotifierWithTitle(t, mock, alertmanagertypes.DefaultTelegramTitleTemplate)
}
func newNotifierWithTitle(t *testing.T, mock *mockBotAPI, title string) *Notifier {
t.Helper()
tmpl := test.CreateTmpl(t)
apiURL, err := url.Parse(mock.srv.URL)
require.NoError(t, err)
logger := slog.New(slog.DiscardHandler)
notifier, err := New(&alertmanagertypes.TelegramReceiverConfig{
HTTPConfig: &commoncfg.HTTPClientConfig{},
APIUrl: &config.URL{URL: apiURL},
BotToken: "123456:tok",
ChatID: -1001234567890,
ParseMode: "HTML",
Title: title,
Message: alertmanagertypes.DefaultTelegramMessageTemplate,
}, tmpl, logger, alertmanagertemplate.New(tmpl, logger))
require.NoError(t, err)
return notifier
}
func newAlert(host string, annotations model.LabelSet) *types.Alert {
merged := model.LabelSet{"summary": "p99 is 612ms"}
for name, value := range annotations {
merged[name] = value
}
return &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "checkout-latency-high", "severity": "warning", "host": model.LabelValue(host)},
Annotations: merged,
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
GeneratorURL: "http://am/alerts/overview?ruleId=r1",
},
}
}
func TestNotifyBuildsTheMessage(t *testing.T) {
testCases := []struct {
name string
untitledChannel bool
alerts []*types.Alert
wantExact string
wantContains []string
wantNotContains []string
}{
{
name: "NoRuleTemplates_UsesChannelTemplate",
alerts: []*types.Alert{newAlert("web-3", nil)},
wantContains: []string{
"<b>[FIRING:1] checkout-latency-high</b>\n\n<b>Alert:</b>",
"<b>Summary:</b> p99 is 612ms",
`<a href="http://am/alerts/overview?ruleId=r1">View in SigNoz</a>`,
},
},
{
// Receivers stored before the channel title existed have none.
name: "UntitledChannel_SendsTheMessageAlone",
untitledChannel: true,
alerts: []*types.Alert{newAlert("web-3", nil)},
wantContains: []string{"<b>Summary:</b> p99 is 612ms"},
wantNotContains: []string{"[FIRING:1]"},
},
{
name: "TitleAndBody_ReplaceTheChannelTemplate",
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
ruletypes.AnnotationTitleTemplate: "Incident on $labels.host",
ruletypes.AnnotationBodyTemplate: "$alert.status for $rule.name",
})},
wantExact: "<b>Incident on web-3</b>\n\nfiring for checkout-latency-high",
wantNotContains: []string{"View in SigNoz"},
},
{
// $-refs are template variables, so the braced form is the real
// syntax and the only one that reaches conditionals. A bare ref in
// text is rewritten into an action before expansion.
name: "BracedAndBareRefs_ExpandAlike",
untitledChannel: true,
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
ruletypes.AnnotationBodyTemplate: "{{ $rule.name }}|$rule.name|{{ if $alert.is_firing }}up{{ end }}",
})},
wantExact: "checkout-latency-high|checkout-latency-high|up",
},
{
name: "BodyOnly_KeepsTheChannelTitle",
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
ruletypes.AnnotationBodyTemplate: "$alert.status for $rule.name",
})},
wantExact: "<b>[FIRING:1] checkout-latency-high</b>\n\nfiring for checkout-latency-high",
},
{
name: "BodyOnly_UntitledChannel_OmitsTheTitleLine",
untitledChannel: true,
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
ruletypes.AnnotationBodyTemplate: "$alert.status for $rule.name",
})},
wantExact: "firing for checkout-latency-high",
},
{
name: "TitleOnly_ReplacesTheChannelTitle",
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
ruletypes.AnnotationTitleTemplate: "Incident on $labels.host",
})},
wantContains: []string{"<b>Incident on web-3</b>\n\n<b>Alert:</b>", "<b>Summary:</b> p99 is 612ms"},
wantNotContains: []string{"[FIRING:1]"},
},
{
// Rule templates are authored in markdown for every channel, so
// Telegram converts rather than escaping the markers away.
name: "RuleTemplateMarkdown_RenderedToTelegramHTML",
untitledChannel: true,
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
ruletypes.AnnotationBodyTemplate: "**{{ $rule.name }}** on `{{ $labels.host }}` — [open](https://signoz.io/d?a=1&b=2)",
})},
wantExact: `<b>checkout-latency-high</b> on <code>web-3</code> — <a href="https://signoz.io/d?a=1&amp;b=2">open</a>`,
},
{
// The templater renders plain text, so anything interpolated from
// an alert has to be escaped before a parse_mode HTML send.
name: "RuleTemplateOutput_EscapedForHTML",
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
"summary": "p99 < 2s & rising",
ruletypes.AnnotationTitleTemplate: "$annotations.summary",
ruletypes.AnnotationBodyTemplate: "$annotations.summary",
})},
wantExact: "<b>p99 &lt; 2s &amp; rising</b>\n\np99 &lt; 2s &amp; rising",
},
{
name: "GroupedAlerts_BodyRenderedPerAlert",
untitledChannel: true,
alerts: []*types.Alert{
newAlert("web-3", model.LabelSet{ruletypes.AnnotationBodyTemplate: "host $labels.host"}),
newAlert("web-4", model.LabelSet{ruletypes.AnnotationBodyTemplate: "host $labels.host"}),
},
wantExact: "host web-3\n\nhost web-4",
},
{
// Picking one rule's template for a mixed group would be arbitrary,
// so the channel template is used instead.
name: "GroupedAlerts_DisagreeingTemplates_FallBackToChannel",
alerts: []*types.Alert{
newAlert("web-3", model.LabelSet{ruletypes.AnnotationBodyTemplate: "host $labels.host"}),
newAlert("web-4", model.LabelSet{ruletypes.AnnotationBodyTemplate: "something else"}),
},
wantContains: []string{"<b>Summary:</b> p99 is 612ms"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
mock := newMockBotAPI(t)
title := alertmanagertypes.DefaultTelegramTitleTemplate
if testCase.untitledChannel {
title = ""
}
retry, err := newNotifierWithTitle(t, mock, title).Notify(notify.WithGroupKey(context.Background(), "1"), testCase.alerts...)
require.NoError(t, err)
assert.False(t, retry)
text := mock.only(t)["text"]
if testCase.wantExact != "" {
assert.Equal(t, testCase.wantExact, text)
}
for _, want := range testCase.wantContains {
assert.Contains(t, text, want)
}
for _, notWant := range testCase.wantNotContains {
assert.NotContains(t, text, notWant)
}
})
}
}
// A v1-written receiver can carry a mode the v2 spec no longer offers. Nothing
// renders to those, so the send goes out as HTML without touching the receiver.
func TestNewForcesParseModeHTML(t *testing.T) {
testCases := []struct {
name string
parseMode string
}{
{"HTML", "HTML"},
{"MarkdownV2", "MarkdownV2"},
{"Markdown", "Markdown"},
{"Empty", ""},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
mock := newMockBotAPI(t)
apiURL, err := url.Parse(mock.srv.URL)
require.NoError(t, err)
tmpl := test.CreateTmpl(t)
logger := slog.New(slog.DiscardHandler)
conf := &alertmanagertypes.TelegramReceiverConfig{
HTTPConfig: &commoncfg.HTTPClientConfig{},
APIUrl: &config.URL{URL: apiURL},
BotToken: "123456:tok",
ChatID: -1001234567890,
ParseMode: testCase.parseMode,
Message: alertmanagertypes.DefaultTelegramMessageTemplate,
}
notifier, err := New(conf, tmpl, logger, alertmanagertemplate.New(tmpl, logger))
require.NoError(t, err)
_, err = notifier.Notify(notify.WithGroupKey(context.Background(), "1"), newAlert("web-3", nil))
require.NoError(t, err)
assert.Equal(t, "HTML", mock.only(t)["parse_mode"])
assert.Equal(t, testCase.parseMode, conf.ParseMode)
})
}
}
func TestNewDefaultsAPIURL(t *testing.T) {
testCases := []struct {
name string
apiURL string
expected string
}{
{"Unset_DefaultsToTelegram", "", "https://api.telegram.org"},
{"Set_KeepsChannelURL", "http://127.0.0.1:9099", "http://127.0.0.1:9099"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
conf := &alertmanagertypes.TelegramReceiverConfig{
HTTPConfig: &commoncfg.HTTPClientConfig{},
BotToken: "123456:tok",
ChatID: -1001234567890,
ParseMode: "HTML",
}
if testCase.apiURL != "" {
apiURL, err := url.Parse(testCase.apiURL)
require.NoError(t, err)
conf.APIUrl = &config.URL{URL: apiURL}
}
tmpl := test.CreateTmpl(t)
logger := slog.New(slog.DiscardHandler)
notifier, err := New(conf, tmpl, logger, alertmanagertemplate.New(tmpl, logger))
require.NoError(t, err)
require.NotNil(t, notifier.conf.APIUrl)
assert.Equal(t, testCase.expected, notifier.conf.APIUrl.String())
if testCase.apiURL == "" {
assert.Nil(t, conf.APIUrl, "the receiver is not modified")
}
})
}
}
func TestNotifySendsTheConfiguredSendOptions(t *testing.T) {
mock := newMockBotAPI(t)
_, err := newNotifier(t, mock).Notify(notify.WithGroupKey(context.Background(), "1"), newAlert("web-3", nil))
require.NoError(t, err)
sent := mock.only(t)
assert.Equal(t, "-1001234567890", sent["chat_id"])
assert.Equal(t, "HTML", sent["parse_mode"])
}
func TestNotifyRetriesOnlyRateLimitAndServerErrors(t *testing.T) {
testCases := []struct {
name string
status int
wantErr bool
wantRetry bool
}{
{"OK_NoRetry", http.StatusOK, false, false},
{"TooManyRequests_Retries", http.StatusTooManyRequests, true, true},
{"InternalServerError_Retries", http.StatusInternalServerError, true, true},
{"BadGateway_Retries", http.StatusBadGateway, true, true},
{"BadRequest_DoesNotRetry", http.StatusBadRequest, true, false},
{"Unauthorized_DoesNotRetry", http.StatusUnauthorized, true, false},
{"Forbidden_DoesNotRetry", http.StatusForbidden, true, false},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
mock := newMockBotAPIWithStatus(t, testCase.status)
retry, err := newNotifier(t, mock).Notify(notify.WithGroupKey(context.Background(), "1"), newAlert("web-3", nil))
if testCase.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
assert.Equal(t, testCase.wantRetry, retry)
})
}
}
func TestNotifyPostsToTheBotSendMessageEndpoint(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_, _ = w.Write([]byte(`{"ok":true}`))
}))
t.Cleanup(srv.Close)
_, err := newNotifier(t, &mockBotAPI{srv: srv}).Notify(notify.WithGroupKey(context.Background(), "1"), newAlert("web-3", nil))
require.NoError(t, err)
assert.Equal(t, "/bot123456:tok/sendMessage", gotPath)
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
@@ -253,6 +254,24 @@ func (r *BaseRule) GeneratorURL() string {
return r.ExternalURL("alerts/overview", params)
}
// ExpandAnnotations runs expand over the rule's annotations, leaving private
// ones as they are. Those carry templates the notifier expands against its own
// data model, and the ruler's expander rewrites every $-ref into a label
// lookup, which blanks the ones that are not labels.
func (r *BaseRule) ExpandAnnotations(expand func(string) string) ruletypes.Labels {
annotations := make(ruletypes.Labels, 0, len(r.annotations.Map()))
for name, value := range r.annotations.Map() {
if alertmanagertypes.IsPrivateAnnotation(name) {
annotations = append(annotations, ruletypes.Label{Name: name, Value: value})
continue
}
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
}
return annotations
}
func (r *BaseRule) ExternalURL(path string, params url.Values) string {
u := r.externalURL.JoinPath(path)
if len(params) > 0 {

View File

@@ -833,3 +833,55 @@ func labelsKey(lbls []*qbtypes.Label) string {
}
return temp.Labels().String()
}
func TestExpandAnnotations(t *testing.T) {
testCases := []struct {
name string
annotations map[string]string
want map[string]string
}{
{
name: "PublicAnnotation_Expanded",
annotations: map[string]string{"summary": "observed {{$value}}"},
want: map[string]string{"summary": "observed 0.35"},
},
{
// The ruler's expander rewrites every $-ref into a label lookup, so
// expanding these would blank the paths the notifier resolves.
name: "PrivateAnnotation_LeftAlone",
annotations: map[string]string{ruletypes.AnnotationTitleTemplate: "{{ $rule.name }} on {{ $labels.host }}"},
want: map[string]string{ruletypes.AnnotationTitleTemplate: "{{ $rule.name }} on {{ $labels.host }}"},
},
{
name: "MixedAnnotations_OnlyPublicExpanded",
annotations: map[string]string{
"summary": "observed {{$value}}",
ruletypes.AnnotationBodyTemplate: "$alert.status for $rule.name",
},
want: map[string]string{
"summary": "observed 0.35",
ruletypes.AnnotationBodyTemplate: "$alert.status for $rule.name",
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
rule := &BaseRule{annotations: ruletypes.FromMap(testCase.annotations)}
expand := func(text string) string {
tmpl := ruletypes.NewTemplateExpander(
context.Background(),
"{{$labels := .Labels}}{{$value := .Value}}{{$threshold := .Threshold}}"+text,
"__alert_test",
ruletypes.AlertTemplateData(map[string]string{}, "0.35", "0.32"),
nil,
)
out, err := tmpl.Expand()
require.NoError(t, err)
return out
}
assert.Equal(t, testCase.want, rule.ExpandAnnotations(expand).Map())
})
}
}

View File

@@ -211,10 +211,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
annotations := make(ruletypes.Labels, 0, len(r.annotations.Map()))
for name, value := range r.annotations.Map() {
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
}
annotations := r.ExpandAnnotations(expand)
if result.IsMissing {
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")

View File

@@ -295,10 +295,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
annotations := make(ruletypes.Labels, 0, len(r.annotations.Map()))
for name, value := range r.annotations.Map() {
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
}
annotations := r.ExpandAnnotations(expand)
if smpl.IsMissing {
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")

View File

@@ -262,6 +262,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddRuleViewFactory(sqlstore, sqlschema),
sqlmigration.NewAddFieldTuplesFactory(sqlstore),
sqlmigration.NewKeepRelevantLLMPricingRulesFactory(),
sqlmigration.NewBackfillTelegramChannelSpecFactory(),
)
}

View File

@@ -0,0 +1,188 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type backfillTelegramChannelSpec struct {
logger *slog.Logger
}
func NewBackfillTelegramChannelSpecFactory() factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("backfill_telegram_channel_spec"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &backfillTelegramChannelSpec{logger: ps.Logger}, nil
})
}
func (migration *backfillTelegramChannelSpec) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// Up fills the spec of telegram channels add_channel_spec left NULL because v2
// did not model telegram then. parse_mode is not carried: the spec has no field
// for it and every send goes out as HTML. Rows carrying any other notifier are
// left to the repair endpoint.
func (migration *backfillTelegramChannelSpec) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
rows := make([]*channelSpecBackfillRow, 0)
if err := tx.NewSelect().Model(&rows).Where("spec IS NULL").OrderExpr("org_id, id").Scan(ctx); err != nil {
return err
}
for _, row := range rows {
kind, spec, err := telegramChannelSpecFromReceiverJSON(row.Data)
if kind != "telegram" {
continue
}
if err != nil {
migration.logger.WarnContext(ctx, "leaving telegram notification channel without a v2 spec", slog.String("org_id", row.OrgID), slog.String("channel_id", row.ID), errors.Attr(err))
continue
}
encoded, err := marshalUnescaped(spec)
if err != nil {
return err
}
if _, err := tx.NewUpdate().
Model((*channelSpecBackfillRow)(nil)).
Set("spec = ?", string(encoded)).
Set("type = ?", kind).
Where("id = ?", row.ID).
Exec(ctx); err != nil {
return err
}
migration.logger.InfoContext(ctx, "filled v2 spec on telegram notification channel", slog.String("org_id", row.OrgID), slog.String("channel_id", row.ID))
}
return tx.Commit()
}
func (migration *backfillTelegramChannelSpec) Down(context.Context, *bun.DB) error {
return nil
}
// telegramChannelSpecFromReceiverJSON reads the kind from the receiver rather
// than the stored type, which v1 could leave empty.
func telegramChannelSpecFromReceiverJSON(data string) (string, map[string]any, error) {
receiver := map[string]json.RawMessage{}
if err := json.Unmarshal([]byte(data), &receiver); err != nil {
return "", nil, nil
}
total := 0
var telegrams []notifierJSON
for key, raw := range receiver {
if !strings.HasSuffix(key, "_configs") {
continue
}
var list []notifierJSON
if err := json.Unmarshal(raw, &list); err != nil {
if key != "telegram_configs" {
return "", nil, nil
}
return "telegram", nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
}
total += len(list)
if key == "telegram_configs" {
telegrams = list
}
}
if len(telegrams) == 0 {
return "", nil, nil
}
if total > 1 {
return "telegram", nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "carries %d notifier configurations; only one per channel is supported", total)
}
spec, err := convertTelegramNotifierJSON(telegrams[0])
return "telegram", spec, err
}
func convertTelegramNotifierJSON(notifier notifierJSON) (map[string]any, error) {
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
return nil, err
}
for _, key := range []string{"token_file", "chat_file"} {
value, err := notifier.stringValue(key)
if err != nil {
return nil, err
}
if value != "" {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "telegram: %s is not supported", key)
}
}
spec := map[string]any{}
if err := notifier.copyStrings(spec, map[string]string{"token": "botToken"}); err != nil {
return nil, err
}
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"api_url": "apiUrl", "message": "message"}); err != nil {
return nil, err
}
if spec["botToken"] == "" {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "telegram: token is required")
}
chatID, err := notifier.int64Value("chat")
if err != nil {
return nil, err
}
if chatID == 0 {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "telegram: chat is required")
}
spec["chatId"] = chatID
threadID, err := notifier.int64Value("message_thread_id")
if err != nil {
return nil, err
}
if threadID != 0 {
spec["messageThreadId"] = threadID
}
disableNotifications, err := notifier.boolValue("disable_notifications")
if err != nil {
return nil, err
}
if disableNotifications {
spec["disableNotifications"] = true
}
sendResolved, err := notifier.boolValue("send_resolved")
if err != nil {
return nil, err
}
spec["sendResolved"] = sendResolved
return spec, nil
}
func (n notifierJSON) int64Value(key string) (int64, error) {
if !n.has(key) {
return 0, nil
}
var value int64
if err := json.Unmarshal(n[key], &value); err != nil {
return 0, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
}
return value, nil
}

View File

@@ -8,6 +8,7 @@ import (
"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/SigNoz/signoz/pkg/templating/markdownrenderer/telegram"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
)
@@ -38,6 +39,11 @@ var (
return goldmark.New(goldmark.WithExtensions(plaintext.Extender))
},
}
telegramPool = sync.Pool{
New: func() any {
return goldmark.New(goldmark.WithExtensions(telegram.Extender))
},
}
)
// RenderHTML converts markdown to HTML.
@@ -67,6 +73,14 @@ func RenderPlainText(markdown string) (string, error) {
return render(md, markdown, "plain text")
}
// RenderTelegramHTML converts markdown to the HTML subset Telegram's
// parse_mode HTML accepts, flattening constructs it has no tag for.
func RenderTelegramHTML(markdown string) (string, error) {
md := telegramPool.Get().(goldmark.Markdown)
defer telegramPool.Put(md)
return render(md, markdown, "Telegram HTML")
}
func render(md goldmark.Markdown, markdown string, format string) (string, error) {
var buf bytes.Buffer
if err := md.Convert([]byte(markdown), &buf); err != nil {

View File

@@ -0,0 +1,360 @@
// Package telegram provides a goldmark node renderer that emits the HTML
// subset Telegram's parse_mode HTML accepts. Telegram rejects the whole
// message when it meets a tag outside that set, so constructs with no
// equivalent (headings, lists, tables, images) are flattened to text rather
// than rendered with their usual tags.
// https://core.telegram.org/bots/api#html-style
package telegram
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"
)
// textEscaper covers the three characters Telegram requires as entities in
// message text. attrEscaper additionally covers the quote that would end an
// href value early.
var (
textEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
attrEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
)
// Extender registers the Telegram 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.renderHeading)
reg.Register(ast.KindBlockquote, r.renderBlockquote)
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
reg.Register(ast.KindFencedCodeBlock, r.renderCodeBlock)
reg.Register(ast.KindHTMLBlock, r.renderRawHTML)
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.renderEmphasis)
reg.Register(ast.KindImage, r.renderImage)
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.renderStrikethrough)
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)
}
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)
}
}
// wrap emits an opening tag on the way in and its closing tag on the way out.
func wrap(w util.BufWriter, tag string, entering bool) (ast.WalkStatus, error) {
if entering {
_, _ = w.WriteString("<" + tag + ">")
} else {
_, _ = w.WriteString("</" + tag + ">")
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.prefixes = r.prefixes[:0]
}
return ast.WalkContinue, nil
}
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
}
// renderHeading emits bold text: Telegram has no heading tag.
func (r *nodeRenderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.separateFromPrevious(w, node)
}
return wrap(w, "b", entering)
}
func (r *nodeRenderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.separateFromPrevious(w, node)
}
return wrap(w, "blockquote", entering)
}
func (r *nodeRenderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
tag := "i"
if node.(*ast.Emphasis).Level >= 2 {
tag = "b"
}
return wrap(w, tag, entering)
}
func (r *nodeRenderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
return wrap(w, "s", entering)
}
func (r *nodeRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
r.separateFromPrevious(w, n)
var body bytes.Buffer
for i := 0; i < n.Lines().Len(); i++ {
line := n.Lines().At(i)
body.Write(line.Value(source))
}
_, _ = w.WriteString("<pre>")
_, _ = w.WriteString(textEscaper.Replace(strings.TrimRight(body.String(), "\n")))
_, _ = w.WriteString("</pre>")
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
var body bytes.Buffer
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
value := c.(*ast.Text).Segment.Value(source)
if bytes.HasSuffix(value, []byte("\n")) {
body.Write(value[:len(value)-1])
body.WriteByte(' ')
} else {
body.Write(value)
}
}
_, _ = w.WriteString("<code>" + textEscaper.Replace(body.String()) + "</code>")
return ast.WalkSkipChildren, nil
}
// renderList and renderListItem mirror the plain-text renderer: Telegram has no
// list tag, so items become prefixed lines.
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 {
r.prefixes = r.prefixes[:len(r.prefixes)-1]
return ast.WalkContinue, nil
}
if n.PreviousSibling() != nil {
r.writeLineSeparator(w)
}
parent := n.Parent().(*ast.List)
if parent.IsOrdered() {
index := parent.Start
for c := parent.FirstChild(); c != nil && c != n; c = c.NextSibling() {
index++
}
_, _ = fmt.Fprintf(w, "%d. ", index)
} else {
_, _ = w.WriteString("• ")
}
r.prefixes = append(r.prefixes, " ")
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(`<a href="` + attrEscaper.Replace(url) + `">` + textEscaper.Replace(url) + "</a>")
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Link)
if entering {
_, _ = w.WriteString(`<a href="` + attrEscaper.Replace(string(n.Destination)) + `">`)
} else {
_, _ = w.WriteString("</a>")
}
return ast.WalkContinue, nil
}
// renderImage flattens to "alt (url)": a Telegram text message cannot carry an
// inline image, and <img> is not on the allowlist.
func (r *nodeRenderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
n := node.(*ast.Image)
if len(n.Destination) > 0 {
_, _ = fmt.Fprintf(w, " (%s)", textEscaper.Replace(string(n.Destination)))
}
}
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.WriteString(textEscaper.Replace(string(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.WriteString(textEscaper.Replace(string(node.(*ast.String).Value)))
}
return ast.WalkContinue, nil
}
// renderRawHTML drops markup the author embedded by hand. Passing it through
// would hand Telegram tags outside the allowlist and fail the whole send.
func (r *nodeRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
return ast.WalkSkipChildren, nil
}
// renderTable flattens to "cell | cell" lines: Telegram has no table tag.
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(textEscaper.Replace(extractPlainText(cc, source)))
}
}
return ast.WalkSkipChildren, nil
}
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())
}

View File

@@ -0,0 +1,75 @@
package telegram
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yuin/goldmark"
)
func render(t *testing.T, markdown string) string {
t.Helper()
var buf bytes.Buffer
require.NoError(t, goldmark.New(goldmark.WithExtensions(Extender)).Convert([]byte(markdown), &buf))
return buf.String()
}
func TestRenderEmitsTelegramHTML(t *testing.T) {
testCases := []struct {
name string
markdown string
want string
}{
{"Bold", "**critical**", "<b>critical</b>"},
{"Italic", "*degraded*", "<i>degraded</i>"},
{"Strikethrough", "~~resolved~~", "<s>resolved</s>"},
{"CodeSpan", "use `kubectl get pods`", "use <code>kubectl get pods</code>"},
{"Link", "[dashboard](https://signoz.io/d?a=1&b=2)", `<a href="https://signoz.io/d?a=1&amp;b=2">dashboard</a>`},
{"AutoLink", "<https://signoz.io>", `<a href="https://signoz.io">https://signoz.io</a>`},
{"Blockquote", "> escalate now", "<blockquote>escalate now</blockquote>"},
// Telegram has no heading, list, table or image tag, so these flatten.
{"Heading_BecomesBold", "## Incident", "<b>Incident</b>"},
{"BulletList_BecomesLines", "- one\n- two", "• one\n• two"},
{"OrderedList_BecomesLines", "1. one\n2. two", "1. one\n2. two"},
{"Table_BecomesPipedLines", "| a | b |\n|---|---|\n| 1 | 2 |", "a | b\n1 | 2"},
{"Image_BecomesTextAndURL", "![graph](https://signoz.io/g.png)", "graph (https://signoz.io/g.png)"},
{"ThematicBreak", "a\n\n---\n\nb", "a\n\n———\n\nb"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, render(t, testCase.markdown))
})
}
}
// Telegram drops the whole message on an unparseable entity, so anything that
// could be read as markup has to leave as an entity.
func TestRenderEscapesEverythingElse(t *testing.T) {
testCases := []struct {
name string
markdown string
want string
}{
{"BareSpecialChars", "p99 < 2s & rising", "p99 &lt; 2s &amp; rising"},
{"InsideBold", "**a < b**", "<b>a &lt; b</b>"},
{"InsideCodeSpan", "`if a < b && c`", "<code>if a &lt; b &amp;&amp; c</code>"},
{"InsideCodeBlock", "```\na < b\n```", "<pre>a &lt; b</pre>"},
{"InsideLinkText", "[a < b](https://signoz.io)", `<a href="https://signoz.io">a &lt; b</a>`},
{"QuoteInHref", `[x](https://signoz.io/?q=")`, `<a href="https://signoz.io/?q=&quot;">x</a>`},
// Hand-written markup would hand Telegram tags outside the allowlist.
// The tags go; the text between them stays.
{"RawHTMLTagsDropped_TextKept", "before <marquee>x</marquee> after", "before x after"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
rendered := render(t, testCase.markdown)
assert.Equal(t, testCase.want, rendered)
assert.NotContains(t, rendered, "&amp;lt;", "double-escaped")
})
}
}

View File

@@ -0,0 +1,193 @@
package alertmanagertypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
commoncfg "github.com/prometheus/common/config"
)
// Telegram renders the message with parse_mode HTML, which only accepts a small
// tag set and requires every stray <, > and & to be escaped. Each interpolated
// value is piped through html so the template renders identically under both
// template engines; an unescaped alert annotation would otherwise fail the
// whole send with "can't parse entities".
// https://core.telegram.org/bots/api#html-style
const (
// DefaultTelegramTitleTemplate is sent in bold above the message.
DefaultTelegramTitleTemplate = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname | html }}`
DefaultTelegramMessageTemplate = `{{ range .Alerts -}}
<b>Alert:</b> {{ .Labels.alertname | html }}{{ if .Labels.severity }} ({{ .Labels.severity | html }}){{ end }}
{{ if .Annotations.summary }}<b>Summary:</b> {{ .Annotations.summary | html }}
{{ end }}{{ if .Annotations.description }}<b>Description:</b> {{ .Annotations.description | html }}
{{ end }}{{ if .GeneratorURL }}<a href="{{ .GeneratorURL | html }}">View in SigNoz</a>
{{ end }}{{ if .Annotations.related_logs }}<a href="{{ .Annotations.related_logs | html }}">View related logs</a>
{{ end }}{{ if .Annotations.related_traces }}<a href="{{ .Annotations.related_traces | html }}">View related traces</a>
{{ end }}
{{ end }}`
)
// telegramParseMode is the only mode SigNoz renders for. Upstream also accepts
// Markdown, MarkdownV2 and the empty string, but those carry per-mode escaping
// rules that nothing renders to, so the spec does not offer them.
const telegramParseMode = "HTML"
// ChannelTelegramConfig configures delivery to a Telegram chat through a bot.
// ChatID is the numeric chat the bot posts to, negative for groups and channels;
// Telegram's @username form is not accepted upstream.
type ChannelTelegramConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
BotToken string `json:"botToken" required:"true" format:"password"`
ChatID int64 `json:"chatId" required:"true"`
// APIURL points at a self-hosted Bot API server. Left empty, upstream's
// default (https://api.telegram.org) applies.
APIURL valuer.UnsetOrNonEmptyString `json:"apiUrl,omitzero"`
// MessageThreadID targets a topic inside a forum group.
MessageThreadID int `json:"messageThreadId,omitempty"`
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
Message valuer.UnsetOrNonEmptyString `json:"message,omitzero"`
DisableNotifications bool `json:"disableNotifications,omitempty"`
}
func (c *ChannelTelegramConfig) UnmarshalJSON(data []byte) error {
type alias ChannelTelegramConfig
if err := decodeStrict(data, (*alias)(c)); err != nil {
return err
}
fillSendResolved(&c.SendResolved, DefaultTelegramReceiverConfig.VSendResolved)
c.Title.SetIfUnset(DefaultTelegramTitleTemplate)
c.Message.SetIfUnset(DefaultTelegramMessageTemplate)
return c.Validate()
}
// Validate repeats the checks TelegramReceiverConfig.UnmarshalYAML makes, so
// a bad channel fails on the request with a field name rather than as a yaml
// error surfacing out of the config resolve.
func (c ChannelTelegramConfig) Validate() error {
if c.BotToken == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.botToken is required for a telegram channel")
}
if c.ChatID == 0 {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.chatId is required for a telegram channel")
}
return nil
}
func (c ChannelTelegramConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
var apiURL *config.URL
if !c.APIURL.IsZero() {
parsed, err := parseUpstreamURL(c.APIURL.StringValue())
if err != nil {
return nil, err
}
apiURL = parsed
}
return &Receiver{
Receiver: &config.Receiver{Name: displayName},
TelegramConfigs: []*TelegramReceiverConfig{{
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, DefaultTelegramReceiverConfig.VSendResolved)},
APIUrl: apiURL,
BotToken: config.Secret(c.BotToken),
ChatID: c.ChatID,
MessageThreadID: c.MessageThreadID,
Title: c.Title.StringValue(),
Message: c.Message.StringValue(),
ParseMode: telegramParseMode,
DisableNotifications: c.DisableNotifications,
}},
}, nil
}
func newChannelTelegramConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
telegram := receiver.TelegramConfigs[0]
sendResolved := telegram.VSendResolved
if err := rejectAnyHTTPAuth(name, telegram.HTTPConfig); err != nil {
return nil, err
}
// Both read a path on the server's filesystem, so they are refused rather
// than silently dropped on the way back out.
if telegram.BotTokenFile != "" {
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets bot_token_file, which is not supported", name)
}
if telegram.ChatIDFile != "" {
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets chat_id_file, which is not supported", name)
}
// Dropping a non-HTML parse_mode on the way out would silently rewrite the
// channel on the next write, so it is refused instead.
if telegram.ParseMode != telegramParseMode {
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets parse_mode %q, only %s is supported", name, telegram.ParseMode, telegramParseMode)
}
return &ChannelTelegramConfig{
SendResolved: &sendResolved,
BotToken: string(telegram.BotToken),
ChatID: telegram.ChatID,
APIURL: valuer.UnsetIfEmpty(formatUpstreamURL(telegram.APIUrl)),
MessageThreadID: telegram.MessageThreadID,
Title: valuer.UnsetIfEmpty(telegram.Title),
Message: valuer.UnsetIfEmpty(telegram.Message),
DisableNotifications: telegram.DisableNotifications,
}, nil
}
// TelegramReceiverConfig is upstream's TelegramConfig with a Title, under the
// same keys so receivers stored as upstream's still decode. Title has no
// default: a receiver without one sends Message alone.
type TelegramReceiverConfig struct {
config.NotifierConfig `yaml:",inline" json:",inline"`
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`
APIUrl *config.URL `yaml:"api_url" json:"api_url,omitempty"`
BotToken config.Secret `yaml:"bot_token,omitempty" json:"token,omitempty"`
BotTokenFile string `yaml:"bot_token_file,omitempty" json:"token_file,omitempty"`
ChatID int64 `yaml:"chat_id,omitempty" json:"chat,omitempty"`
ChatIDFile string `yaml:"chat_id_file,omitempty" json:"chat_file,omitempty"`
MessageThreadID int `yaml:"message_thread_id,omitempty" json:"message_thread_id,omitempty"`
Title string `yaml:"title,omitempty" json:"title,omitempty"`
Message string `yaml:"message,omitempty" json:"message,omitempty"`
DisableNotifications bool `yaml:"disable_notifications,omitempty" json:"disable_notifications,omitempty"`
ParseMode string `yaml:"parse_mode,omitempty" json:"parse_mode,omitempty"`
}
var DefaultTelegramReceiverConfig = TelegramReceiverConfig{
NotifierConfig: config.DefaultTelegramConfig.NotifierConfig,
Message: config.DefaultTelegramConfig.Message,
DisableNotifications: config.DefaultTelegramConfig.DisableNotifications,
ParseMode: config.DefaultTelegramConfig.ParseMode,
}
func (c *TelegramReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error {
*c = DefaultTelegramReceiverConfig
type plain TelegramReceiverConfig
if err := unmarshal((*plain)(c)); err != nil {
return err
}
if c.BotToken != "" && c.BotTokenFile != "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "at most one of bot_token & bot_token_file must be configured")
}
if c.ChatID == 0 && c.ChatIDFile == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "missing chat_id or chat_id_file on telegram_config")
}
if c.ChatID != 0 && c.ChatIDFile != "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "at most one of chat_id & chat_id_file must be configured")
}
switch c.ParseMode {
case "", "Markdown", "MarkdownV2", "HTML":
default:
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "unknown parse_mode on telegram_config, must be Markdown, MarkdownV2, HTML or empty string")
}
return nil
}

View File

@@ -0,0 +1,150 @@
package alertmanagertypes
import (
"encoding/json"
"net/url"
"testing"
"time"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestChannelTelegramConfigDefaults(t *testing.T) {
var spec ChannelTelegramConfig
require.NoError(t, json.Unmarshal([]byte(`{"botToken":"123456:tok","chatId":-1001234567890}`), &spec))
require.NotNil(t, spec.SendResolved)
assert.True(t, *spec.SendResolved, "upstream defaults telegram send_resolved on")
assert.Equal(t, DefaultTelegramTitleTemplate, spec.Title.StringValue())
assert.Equal(t, DefaultTelegramMessageTemplate, spec.Message.StringValue())
assert.Empty(t, spec.APIURL, "the notifier defaults it when none is given")
}
func TestChannelTelegramConfigValidation(t *testing.T) {
testCases := []struct {
name string
spec string
}{
{"BotToken_Missing", `{"chatId":1}`},
{"ChatID_Missing", `{"botToken":"123456:tok"}`},
{"ChatID_Username_Rejected", `{"botToken":"123456:tok","chatId":"@signoz"}`},
{"ParseMode_NotAField", `{"botToken":"123456:tok","chatId":1,"parseMode":"HTML"}`},
{"BotTokenFile_NotAField", `{"botToken":"123456:tok","chatId":1,"botTokenFile":"/etc/token"}`},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
var spec ChannelTelegramConfig
assert.Error(t, json.Unmarshal([]byte(testCase.spec), &spec))
})
}
}
// Settings the spec cannot represent would be dropped on read and silently
// rewrite the channel on the next write, so the read fails instead.
func TestDeriveChannelConfigRejectsUnsupportedTelegramSettings(t *testing.T) {
testCases := []struct {
name string
data string
}{
{"BotTokenFile", `{"name":"tg","telegram_configs":[{"chat_id":1,"token_file":"/etc/token"}]}`},
{"ChatIDFile", `{"name":"tg","telegram_configs":[{"token":"123456:tok","chat_file":"/etc/chat"}]}`},
{"ParseMode_NotHTML", `{"name":"tg","telegram_configs":[{"token":"123456:tok","chat":1,"parse_mode":"MarkdownV2"}]}`},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
channel := Channel{DisplayName: "tg", Data: testCase.data}
_, err := channel.deriveChannelConfig()
assert.Error(t, err)
})
}
}
func TestDefaultTelegramTemplatesEscapeAlertContent(t *testing.T) {
tmpl, err := FromGlobs([]string{})
require.NoError(t, err)
tmpl.ExternalURL = &url.URL{Scheme: "http", Host: "localhost:8080"}
alert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "p99 < 2s & rising", "severity": "critical"},
Annotations: model.LabelSet{"summary": "latency > 2s", "related_logs": "http://localhost:8080/logs?a=1&b=2"},
GeneratorURL: "http://localhost:8080/alerts?a=1&b=2",
},
UpdatedAt: time.Now(),
}
data := tmpl.Data("__receiver", model.LabelSet{}, alert)
// The channel template is rendered with html/template under parse_mode HTML
// and with text/template otherwise, so it has to escape identically under
// both. html/template recognises the html pipeline and adds no second pass.
renderers := map[string]func(string, any) (string, error){
"Text": tmpl.ExecuteTextString,
"HTML": tmpl.ExecuteHTMLString,
}
for name, render := range renderers {
t.Run(name, func(t *testing.T) {
title, err := render(DefaultTelegramTitleTemplate, data)
require.NoError(t, err)
assert.Equal(t, "[FIRING:1] p99 &lt; 2s &amp; rising", title)
rendered, err := render(DefaultTelegramMessageTemplate, data)
require.NoError(t, err)
assert.NotContains(t, rendered, "[FIRING:1]", "the title is sent separately")
assert.Contains(t, rendered, "<b>Summary:</b> latency &gt; 2s")
assert.Contains(t, rendered, `<a href="http://localhost:8080/alerts?a=1&amp;b=2">View in SigNoz</a>`)
assert.Contains(t, rendered, `<a href="http://localhost:8080/logs?a=1&amp;b=2">View related logs</a>`)
assert.NotContains(t, rendered, "&amp;lt;", "double-escaped")
assert.NotContains(t, rendered, "View related traces")
})
}
}
// HTTPConfig is filled from the global rather than the spec, and the notifier
// refuses to start without it. APIUrl stays unset; the notifier defaults it.
func TestTelegramResolvesTheGlobalHTTPConfigAndLeavesAPIURLUnset(t *testing.T) {
cfg, err := NewDefaultConfig(GlobalConfig{}, RouteConfig{GroupInterval: 1 * time.Minute, GroupWait: 1 * time.Minute, RepeatInterval: 1 * time.Minute}, "org-1")
require.NoError(t, err)
require.NoError(t, cfg.SetGlobalConfig(GlobalConfig{}))
spec := ChannelTelegramConfig{BotToken: "123456:tok", ChatID: -1001234567890}
receiver, err := spec.toUndefaultedReceiver("tg")
require.NoError(t, err)
require.NoError(t, cfg.CreateReceiver(receiver))
resolved, err := cfg.Resolved()
require.NoError(t, err)
resolvedReceiver, err := resolved.GetReceiver("tg")
require.NoError(t, err)
require.Len(t, resolvedReceiver.TelegramConfigs, 1)
telegram := resolvedReceiver.TelegramConfigs[0]
assert.Nil(t, telegram.APIUrl)
assert.NotNil(t, telegram.HTTPConfig)
stored, err := cfg.GetReceiver("tg")
require.NoError(t, err)
assert.Nil(t, stored.TelegramConfigs[0].APIUrl, "no default is stored in the receiver")
}
// Receivers stored before the title existed decode without one and keep
// sending their message alone; only the spec fills the default title.
func TestTelegramReceiverWithoutTitleStaysUntitled(t *testing.T) {
receiver, err := NewReceiver(`{"name":"tg","telegram_configs":[{"token":"123456:tok","chat":1,"message":"hello"}]}`)
require.NoError(t, err)
require.Len(t, receiver.TelegramConfigs, 1)
assert.Empty(t, receiver.TelegramConfigs[0].Title)
assert.Equal(t, "hello", receiver.TelegramConfigs[0].Message)
spec, err := newChannelTelegramConfigFromReceiver("tg", receiver)
require.NoError(t, err)
assert.True(t, spec.(*ChannelTelegramConfig).Title.IsZero())
}

View File

@@ -96,6 +96,7 @@ func (ChannelConfig) JSONSchemaOneOf() []any {
ChannelConfigVariant[ChannelJiraConfig]{Kind: ChannelKindJira.StringValue()},
ChannelConfigVariant[ChannelJSMOpsConfig]{Kind: ChannelKindJSMOps.StringValue()},
ChannelConfigVariant[ChannelIncidentIOConfig]{Kind: ChannelKindIncidentIO.StringValue()},
ChannelConfigVariant[ChannelTelegramConfig]{Kind: ChannelKindTelegram.StringValue()},
}
}
@@ -113,6 +114,7 @@ func (ChannelConfig) PrepareJSONSchema(s *jsonschema.Schema) error {
ChannelKindJira.StringValue(): channelVariantRef("ChannelJiraConfig"),
ChannelKindJSMOps.StringValue(): channelVariantRef("ChannelJSMOpsConfig"),
ChannelKindIncidentIO.StringValue(): channelVariantRef("ChannelIncidentIOConfig"),
ChannelKindTelegram.StringValue(): channelVariantRef("ChannelTelegramConfig"),
})
}

View File

@@ -258,6 +258,28 @@ func TestDeriveChannelConfigRoundTripsEveryFieldOfEveryKind(t *testing.T) {
Metadata: map[string]string{"team": "platform"},
},
},
{
description: "telegram",
kind: ChannelKindTelegram,
spec: &ChannelTelegramConfig{
SendResolved: &sendResolved,
BotToken: "123456:bot-token",
ChatID: -1001234567890,
APIURL: valuer.MustNewUnsetOrNonEmptyString("https://bot-api.example.com"),
MessageThreadID: 42,
Message: valuer.MustNewUnsetOrNonEmptyString("telegram message"),
DisableNotifications: true,
},
expectedRoundTrip: &ChannelTelegramConfig{
SendResolved: &sendResolved,
BotToken: "123456:bot-token",
ChatID: -1001234567890,
APIURL: valuer.MustNewUnsetOrNonEmptyString("https://bot-api.example.com"),
MessageThreadID: 42,
Message: valuer.MustNewUnsetOrNonEmptyString("telegram message"),
DisableNotifications: true,
},
},
}
for _, testCase := range testCases {
@@ -396,8 +418,8 @@ func TestDeriveChannelConfigRejectsUnrepresentableChannels(t *testing.T) {
{
description: "notifier kind outside the supported set",
channel: Channel{
DisplayName: "tg",
Data: `{"name":"tg","telegram_configs":[{"chat_id":1}]}`,
DisplayName: "dc",
Data: `{"name":"dc","discord_configs":[{"webhook_url":"https://discord.com/api/webhooks/1/x"}]}`,
},
},
{

View File

@@ -29,6 +29,7 @@ var (
ChannelKindJira = ChannelKind{valuer.NewString("jira")}
ChannelKindJSMOps = ChannelKind{valuer.NewString("jsmops")}
ChannelKindIncidentIO = ChannelKind{valuer.NewString("incidentio")}
ChannelKindTelegram = ChannelKind{valuer.NewString("telegram")}
)
func (ChannelKind) Enum() []any {

View File

@@ -74,6 +74,12 @@ var channelKinds = []channelKindEntry{
countConfigs: func(receiver *Receiver) int { return len(receiver.IncidentIOConfigs) },
extractSpec: newChannelIncidentIOConfigFromReceiver,
},
{
kind: ChannelKindTelegram,
newEmptySpec: func() ChannelSpec { return new(ChannelTelegramConfig) },
countConfigs: func(receiver *Receiver) int { return len(receiver.TelegramConfigs) },
extractSpec: newChannelTelegramConfigFromReceiver,
},
}
func buildEmptyChannelSpecForKind(kind ChannelKind) (ChannelSpec, bool) {

View File

@@ -43,13 +43,13 @@ func TestListChannelsParamsValidateRejectsALongQuery(t *testing.T) {
// possible until they are migrated. One of them must not fail the whole list.
func TestChannelToListedChannelLeavesUnmodelledKindsEmpty(t *testing.T) {
channel := Channel{
DisplayName: "tg",
Name: "tg",
Type: "telegram",
Data: `{"name":"tg","telegram_configs":[{"chat_id":1}]}`,
DisplayName: "dc",
Name: "dc",
Type: "discord",
Data: `{"name":"dc","discord_configs":[{"webhook_url":"https://discord.com/api/webhooks/1/x"}]}`,
}
listed := channel.ToListedNotificationChannel()
assert.Equal(t, "tg", listed.Name)
assert.Equal(t, "dc", listed.Name)
assert.True(t, listed.Kind.IsZero())
}

View File

@@ -73,10 +73,11 @@ type customReceiverConfigs struct {
Jira []*JiraReceiverConfig
JSMOps []*JSMOpsReceiverConfig
IncidentIO []*IncidentIOReceiverConfig
Telegram []*TelegramReceiverConfig
}
func (c customReceiverConfigs) isEmpty() bool {
return len(c.GoogleChat) == 0 && len(c.Jira) == 0 && len(c.JSMOps) == 0 && len(c.IncidentIO) == 0
return len(c.GoogleChat) == 0 && len(c.Jira) == 0 && len(c.JSMOps) == 0 && len(c.IncidentIO) == 0 && len(c.Telegram) == 0
}
func customConfigsOf(receiver *Receiver) customReceiverConfigs {
@@ -85,6 +86,7 @@ func customConfigsOf(receiver *Receiver) customReceiverConfigs {
Jira: receiver.JiraConfigs,
JSMOps: receiver.JSMOpsConfigs,
IncidentIO: receiver.IncidentIOConfigs,
Telegram: receiver.TelegramConfigs,
}
}
@@ -196,6 +198,7 @@ func extendedReceivers(c *config.Config, customConfigs map[string]customReceiver
JiraConfigs: custom.Jira,
JSMOpsConfigs: custom.JSMOps,
IncidentIOConfigs: custom.IncidentIO,
TelegramConfigs: custom.Telegram,
}
}
@@ -394,6 +397,7 @@ func (c *Config) GetReceiver(name string) (*Receiver, error) {
JiraConfigs: custom.Jira,
JSMOpsConfigs: custom.JSMOps,
IncidentIOConfigs: custom.IncidentIO,
TelegramConfigs: custom.Telegram,
}, nil
}
}
@@ -488,6 +492,11 @@ func (c *Config) applyNativeDefaults() {
ic.HTTPConfig = httpDefault
}
}
for _, tc := range custom.Telegram {
if tc.HTTPConfig == nil {
tc.HTTPConfig = httpDefault
}
}
}
}

View File

@@ -31,6 +31,8 @@ type Receiver struct {
// Shadows upstream's incidentio_configs so our custom notifier (templater,
// group-key dedup, label metadata) handles it instead of upstream's.
IncidentIOConfigs []*IncidentIOReceiverConfig `json:"incidentio_configs,omitempty" yaml:"incidentio_configs,omitempty"`
// Shadows upstream's telegram_configs to carry a title alongside the message.
TelegramConfigs []*TelegramReceiverConfig `json:"telegram_configs,omitempty" yaml:"telegram_configs,omitempty"`
}
// NewReceiver builds a Receiver from its JSON input, applying each notifier
@@ -87,6 +89,14 @@ func newDefaultedReceiver(receiver *Receiver) (*Receiver, error) {
receiver.IncidentIOConfigs[i] = defaulted
}
for i, tc := range receiver.TelegramConfigs {
defaulted, err := defaultedNotifierConfig(tc)
if err != nil {
return nil, err
}
receiver.TelegramConfigs[i] = defaulted
}
return receiver, nil
}

View File

@@ -56,12 +56,18 @@ test.describe('Trace details — flamegraph', () => {
}) => {
await hoverFlamegraphSpan(page, trace.landmarks.errors[0]);
// "status: error" only renders in the hover card (not in waterfall rows),
// The status row only renders in the hover card (not in waterfall rows),
// so it proves both that the card opened and that we hovered the right
// (error) span — the bar was targeted by id via the span→rect map.
await expect(page.getByText('status: error')).toBeVisible();
await expect(page.getByText(/start: [\d.]+ ms/)).toBeVisible();
await expect(page.getByText(/duration: [\d.]+/)).toBeVisible();
await expect(page.getByTestId('span-hover-card-status')).toHaveText(
/status\s*error/,
);
await expect(page.getByTestId('span-hover-card-start')).toHaveText(
/start\s*[\d.]+ ms/,
);
await expect(page.getByTestId('span-hover-card-duration')).toHaveText(
/duration\s*[\d.]+/,
);
});
test('TC-02 clicking a bar selects the span, opens the drawer, and syncs the waterfall row', async ({

View File

@@ -306,6 +306,82 @@ def incidentio_event_subset(alertname: str, links: list[tuple[str, str]]) -> dic
}
TELEGRAM_CHAT_ID = -1001234567890
def telegram_bot_token(bot: str) -> str:
return f"123456:{bot}"
def telegram_path(bot: str) -> str:
return f"/bot{telegram_bot_token(bot)}/sendMessage"
def telegram_channel(name: str, bot: str, notification_channel: types.TestContainerDocker) -> dict:
"""The bot token keys the WireMock path. v2 only: v1 keeps upstream's
default message template instead of SigNoz's."""
return {
"name": name,
"config": {
"kind": "telegram",
"spec": {
"botToken": telegram_bot_token(bot),
"chatId": TELEGRAM_CHAT_ID,
"apiUrl": notification_channel.container_configs["8080"].base(),
},
},
}
# telebot parses result.chat.id as int64, so it must be a number here; a string
# fails the unmarshal and the send is retried forever.
TELEGRAM_OK_BODY = {"ok": True, "result": {"message_id": 1, "date": 0, "chat": {"id": TELEGRAM_CHAT_ID, "type": "supergroup"}}}
def telegram_ok_mappings(path: str) -> list[Mapping]:
return [
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=200, json_body=TELEGRAM_OK_BODY),
)
]
def telegram_retry_mappings(path: str) -> list[Mapping]:
"""429 on the first call then 200, via a wiremock scenario transition."""
scenario = f"telegram-retry-{path}"
return [
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=429, json_body={"ok": False, "error_code": 429, "description": "Too Many Requests: retry after 1", "parameters": {"retry_after": 1}}),
scenario_name=scenario,
required_scenario_state="Started",
new_scenario_state="ok",
),
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=200, json_body=TELEGRAM_OK_BODY),
scenario_name=scenario,
required_scenario_state="ok",
),
]
def telegram_message_subset(alertname: str, links: list[tuple[str, str]]) -> dict:
"""links: (text, href_regex) pairs in default-template order (View in
SigNoz -> related logs -> related traces). telebot sends every param as a
string."""
text = "(?s)" + re.escape(f"<b>[FIRING:1] {alertname}</b>") + ".*" + re.escape(f"<b>Alert:</b> {alertname}")
for label, href in links:
text += rf".*<a href=\"[^\"]*{href}[^\"]*\">{re.escape(label)}</a>"
return {
"chat_id": str(TELEGRAM_CHAT_ID),
"parse_mode": "HTML",
"disable_web_page_preview": "true",
"text": re.compile(text),
}
JIRA_TEST_EMAIL = "user@acme.io"
JIRA_SA_EMAIL = "svc@serviceaccount.atlassian.com"
JIRA_TEST_TOKEN = "jira-test-token" # noqa: S105

View File

@@ -57,7 +57,7 @@ def test_repair_deletes_a_legacy_channel_of_an_unmodelled_kind(
cleanup_notification_channels: list[str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
name = f"legacy-telegram-{uuid.uuid4().hex[:8]}"
name = f"legacy-pushover-{uuid.uuid4().hex[:8]}"
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
@@ -68,7 +68,7 @@ def test_repair_deletes_a_legacy_channel_of_an_unmodelled_kind(
assert response.status_code == HTTPStatus.CREATED, response.text
channel_id = response.json()["data"]["id"]
cleanup_notification_channels.append(channel_id)
rewrite_channel_as_legacy_receiver(signoz, channel_id, {"name": name, "telegram_configs": [{"chat": 12345, "token": "telegram-bot-token"}]})
rewrite_channel_as_legacy_receiver(signoz, channel_id, {"name": name, "pushover_configs": [{"user_key": "pushover-user-key", "token": "pushover-token"}]})
# v2 lists the row with an empty kind and refuses to read it.
response = requests.get(
@@ -241,7 +241,7 @@ def test_repair_splits_off_an_unmodelled_notifier_for_its_own_delete(
{
"name": name,
"slack_configs": [{"api_url": "https://hooks.slack.test/services/T/B/X", "channel": "#alerts"}],
"telegram_configs": [{"chat": 12345, "token": "telegram-bot-token"}],
"pushover_configs": [{"user_key": "pushover-user-key", "token": "pushover-token"}],
},
)
@@ -257,8 +257,8 @@ def test_repair_splits_off_an_unmodelled_notifier_for_its_own_delete(
assert repair["applied"] is True
assert [channel["displayName"] for channel in repair["channels"]] == [name, f"{name} (2)"]
assert [channel["kind"] for channel in repair["channels"]] == ["slack", ""]
telegram_id = repair["channels"][1]["id"]
cleanup_notification_channels.append(telegram_id)
pushover_id = repair["channels"][1]["id"]
cleanup_notification_channels.append(pushover_id)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
@@ -269,7 +269,7 @@ def test_repair_splits_off_an_unmodelled_notifier_for_its_own_delete(
assert response.json()["data"]["config"]["kind"] == "slack"
response = requests.post(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{telegram_id}/repair"),
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{pushover_id}/repair"),
headers={"Authorization": f"Bearer {token}"},
timeout=TIMEOUT,
)

View File

@@ -427,7 +427,7 @@ def test_create_rejects_invalid_bodies(
[
pytest.param({"sort": "data"}, id="sort_outside_the_enum"),
pytest.param({"order": "sideways"}, id="order_outside_the_enum"),
pytest.param({"kind": "telegram"}, id="kind_outside_the_enum"),
pytest.param({"kind": "pushover"}, id="kind_outside_the_enum"),
pytest.param({"limit": -1}, id="negative_limit"),
pytest.param({"offset": -1}, id="negative_offset"),
],

View File

@@ -75,7 +75,7 @@ def test_get_reflects_a_v2_update_after_a_v1_create(
"receiver",
[
pytest.param(
{"telegram_configs": [{"chat": 12345, "token": "telegram-bot-token"}]},
{"pushover_configs": [{"user_key": "pushover-user-key", "token": "pushover-token"}]},
id="kind_v2_does_not_model",
),
pytest.param(
@@ -115,7 +115,7 @@ def test_update_retypes_a_legacy_channel_of_an_unmodelled_kind(
cleanup_notification_channels: list[str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
name = f"legacy-telegram-{uuid.uuid4().hex[:8]}"
name = f"legacy-pushover-{uuid.uuid4().hex[:8]}"
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
@@ -126,7 +126,7 @@ def test_update_retypes_a_legacy_channel_of_an_unmodelled_kind(
assert response.status_code == HTTPStatus.CREATED, response.text
channel_id = response.json()["data"]["id"]
cleanup_notification_channels.append(channel_id)
rewrite_channel_as_legacy_receiver(signoz, channel_id, {"name": name, "telegram_configs": [{"chat": 12345, "token": "telegram-bot-token"}]})
rewrite_channel_as_legacy_receiver(signoz, channel_id, {"name": name, "pushover_configs": [{"user_key": "pushover-user-key", "token": "pushover-token"}]})
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),

View File

@@ -0,0 +1,204 @@
import json
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
import requests
from wiremock.resources.mappings import Mapping
from fixtures import types
from fixtures.alerts import (
get_testdata_file_path,
update_rule_channel_name,
verify_notification_expectation,
)
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.notification_channel import (
telegram_channel,
telegram_message_subset,
telegram_ok_mappings,
telegram_path,
telegram_retry_mappings,
wait_for_alertmanager_sync,
)
V2_BASE_URL = "/api/v2/notification_channels"
METRICS_DATA = "ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
METRICS_RULE = "ruler/test_scenarios/threshold_above_at_least_once/rule.json"
LOGS_DATA = "ruler/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"
LOGS_RULE = "ruler/test_scenarios/threshold_below_at_least_once/rule.json"
TRACES_DATA = "ruler/test_scenarios/threshold_above_average/alert_data.jsonl"
TRACES_RULE = "ruler/test_scenarios/threshold_above_average/rule.json"
# channel_config holds the bot name; the v2 body is built at runtime since it
# needs the WireMock host.
TELEGRAM_CASES = [
types.AlertManagerNotificationTestCase(
name="telegram_default_metrics_firing",
rule_path=METRICS_RULE,
alert_data=[types.AlertData(type="metrics", data_path=METRICS_DATA)],
channel_config={"bot": "tg-metrics"},
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": telegram_path("tg-metrics"),
"count": 1,
"json_body": telegram_message_subset("threshold_above_at_least_once", [("View in SigNoz", r"/alerts/overview\?ruleId=")]),
},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="telegram_rich_message_logs",
rule_path=LOGS_RULE,
alert_data=[types.AlertData(type="logs", data_path=LOGS_DATA)],
channel_config={"bot": "tg-logs"},
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": telegram_path("tg-logs"),
"count": 1,
"json_body": telegram_message_subset(
"threshold_below_at_least_once",
[("View in SigNoz", r"/alerts/overview\?ruleId="), ("View related logs", r"/logs/logs-explorer\?")],
),
},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="telegram_rich_message_traces",
rule_path=TRACES_RULE,
alert_data=[types.AlertData(type="traces", data_path=TRACES_DATA)],
channel_config={"bot": "tg-traces"},
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": telegram_path("tg-traces"),
"count": 1,
"json_body": telegram_message_subset(
"threshold_above_average",
[("View in SigNoz", r"/alerts/overview\?ruleId="), ("View related traces", r"traces-explorer\?")],
),
},
),
],
),
),
]
@pytest.mark.parametrize(
"telegram_test_case",
TELEGRAM_CASES,
ids=lambda c: c.name,
)
def test_telegram_notifier( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_user_admin: None, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
cleanup_notification_channels: list[str],
create_alert_rule: Callable[[dict], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
maildev: types.TestContainerDocker,
telegram_test_case: types.AlertManagerNotificationTestCase,
) -> None:
channel_name = str(uuid.uuid4())
path = telegram_test_case.notification_expectation.notification_validations[0].validation_data["path"]
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
make_http_mocks(notification_channel, telegram_ok_mappings(path))
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
json=telegram_channel(channel_name, telegram_test_case.channel_config["bot"], notification_channel),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert response.status_code == HTTPStatus.CREATED, response.text
cleanup_notification_channels.append(response.json()["data"]["id"])
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
insert_alert_data(telegram_test_case.alert_data, base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
with open(get_testdata_file_path(telegram_test_case.rule_path), encoding="utf-8") as f:
rule_data = json.loads(f.read())
update_rule_channel_name(rule_data, channel_name)
create_alert_rule(rule_data)
verify_notification_expectation(notification_channel, maildev, telegram_test_case.notification_expectation)
def test_telegram_retry_429_then_200( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_user_admin: None, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
cleanup_notification_channels: list[str],
create_alert_rule: Callable[[dict], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
maildev: types.TestContainerDocker,
) -> None:
channel_name = str(uuid.uuid4())
path = telegram_path("tg-retry")
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
make_http_mocks(notification_channel, telegram_retry_mappings(path))
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
json=telegram_channel(channel_name, "tg-retry", notification_channel),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert response.status_code == HTTPStatus.CREATED, response.text
cleanup_notification_channels.append(response.json()["data"]["id"])
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
insert_alert_data([types.AlertData(type="metrics", data_path=METRICS_DATA)], base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
with open(get_testdata_file_path(METRICS_RULE), encoding="utf-8") as f:
rule_data = json.loads(f.read())
update_rule_channel_name(rule_data, channel_name)
create_alert_rule(rule_data)
verify_notification_expectation(
notification_channel,
maildev,
types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
# a retryable 429 is followed by a successful re-POST => >=2 hits
"path": path,
"min_count": 2,
"json_body": {"parse_mode": "HTML"},
},
),
],
),
)

View File

@@ -0,0 +1,101 @@
import base64
import json
import re
import uuid
from collections.abc import Callable
from http import HTTPStatus
from typing import NamedTuple
import pytest
import requests
from wiremock.resources.mappings import HttpMethods, Mapping, MappingRequest, MappingResponse
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.notification_channel import TELEGRAM_CHAT_ID, TELEGRAM_OK_BODY, telegram_channel, telegram_path, wait_for_alertmanager_sync
# channel test (POST /api/v2/notification_channels/test) drives the notifier once,
# synchronously, with a hardcoded test alert and no retry — the deterministic place
# to assert permanent-failure behaviour. Rich messages + retry are covered in
# alertmanager/17_telegram.py. Error bodies are the Bot API's {ok, error_code, description}.
class TestChannelCase(NamedTuple):
__test__ = False
name: str
bot: str
status: int # stub status
body: dict # stub body
expected_error: str | None # the notifier's error text, None when delivered
TEST_CHANNEL_CASES = [
TestChannelCase("success", "tg-tc-ok", 200, TELEGRAM_OK_BODY, None),
TestChannelCase("chat_not_found_400", "tg-tc-400", 400, {"ok": False, "error_code": 400, "description": "Bad Request: chat not found"}, "unexpected status code 400"),
TestChannelCase("unauthorized_401", "tg-tc-401", 401, {"ok": False, "error_code": 401, "description": "Unauthorized"}, "unexpected status code 401"),
]
@pytest.mark.parametrize(
"case",
TEST_CHANNEL_CASES,
ids=lambda c: c.name,
)
def test_telegram_test_channel( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_user_admin: None, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
case: TestChannelCase,
) -> None:
path = telegram_path(case.bot)
make_http_mocks(
notification_channel,
[
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=case.status, json_body=case.body),
)
],
)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/notification_channels/test"),
json={"config": telegram_channel(str(uuid.uuid4()), case.bot, notification_channel)["config"]},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=30,
)
if case.expected_error is None:
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
else:
# a Bot API error surfaces as a 500 (untyped notify error) whose body
# carries the status code and the Bot API's description
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, f"expected 500, got {response.status_code}: {response.text}"
assert case.expected_error in response.text, f"expected {case.expected_error!r} in error body: {response.text}"
assert case.body["description"] in response.text, f"expected {case.body['description']!r} in error body: {response.text}"
# exactly one delivery attempt either way (testChannel never retries)
count = requests.post(
notification_channel.host_configs["8080"].get("/__admin/requests/count"),
json={"method": "POST", "urlPath": path},
timeout=10,
)
assert count.json()["count"] == 1, f"expected exactly 1 request (no retry), got {count.text}"
find = requests.post(
notification_channel.host_configs["8080"].get("/__admin/requests/find"),
json={"method": "POST", "urlPath": path},
timeout=10,
)
req = find.json()["requests"][0]
# the bot token travels in the path, appended to the configured api url
assert req["url"] == path, f"expected {path}, got {req['url']}"
message = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
assert message["chat_id"] == str(TELEGRAM_CHAT_ID)
assert message["parse_mode"] == "HTML"
assert re.search(r"<b>\[FIRING:1\] Test Alert \(", message["text"]), f"unexpected text: {message['text']}"