mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-13 05:30:41 +01:00
Compare commits
10 Commits
feat/teleg
...
feat/scatt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8a7db2ea3 | ||
|
|
3991224508 | ||
|
|
a64aadcd81 | ||
|
|
3de4d6eaea | ||
|
|
e18afc0178 | ||
|
|
2bac73ee5c | ||
|
|
552f35e5bb | ||
|
|
9eba4a794f | ||
|
|
8a0c6c9911 | ||
|
|
d3662e66c7 |
@@ -37,7 +37,6 @@ components:
|
||||
opsgenie: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig'
|
||||
pagerduty: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig'
|
||||
slack: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig'
|
||||
telegram: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfig'
|
||||
webhook: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
@@ -51,7 +50,6 @@ components:
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfig'
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig:
|
||||
properties:
|
||||
@@ -161,18 +159,6 @@ 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:
|
||||
@@ -325,7 +311,6 @@ components:
|
||||
- jira
|
||||
- jsmops
|
||||
- incidentio
|
||||
- telegram
|
||||
type: string
|
||||
AlertmanagertypesChannelListOrder:
|
||||
enum:
|
||||
@@ -530,29 +515,6 @@ 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
|
||||
required:
|
||||
- botToken
|
||||
- chatId
|
||||
type: object
|
||||
AlertmanagertypesChannelWebhookConfig:
|
||||
properties:
|
||||
bearerToken:
|
||||
@@ -3463,6 +3425,13 @@ components:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesAxisScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
type: string
|
||||
DashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
@@ -4205,6 +4174,7 @@ components:
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
|
||||
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
@@ -4220,6 +4190,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -4233,6 +4204,7 @@ components:
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -4318,6 +4290,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -4397,6 +4381,16 @@ components:
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesPointDiameter:
|
||||
maximum: 40
|
||||
minimum: 2
|
||||
nullable: true
|
||||
type: integer
|
||||
DashboardtypesPointOpacity:
|
||||
maximum: 1
|
||||
minimum: 0.1
|
||||
nullable: true
|
||||
type: number
|
||||
DashboardtypesPostableDashboardV2:
|
||||
properties:
|
||||
generateName:
|
||||
@@ -4570,6 +4564,104 @@ components:
|
||||
required:
|
||||
- queryValue
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxes:
|
||||
properties:
|
||||
x:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
"y":
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxis:
|
||||
properties:
|
||||
label:
|
||||
description: Axis title. Empty draws none.
|
||||
type: string
|
||||
scale:
|
||||
$ref: '#/components/schemas/DashboardtypesAxisScale'
|
||||
softMax:
|
||||
nullable: true
|
||||
type: number
|
||||
softMin:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesScatterPlotChartAppearance:
|
||||
properties:
|
||||
points:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
|
||||
type: object
|
||||
DashboardtypesScatterPlotDimensions:
|
||||
properties:
|
||||
colorBy:
|
||||
description: Group-by label names (e.g. k8s.namespace.name) whose combined
|
||||
values colour dots and drive the legend. Empty colours by every group-by
|
||||
label.
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
sizeBy:
|
||||
description: Value column key that scales dot size. Empty draws every dot
|
||||
at the default size.
|
||||
type: string
|
||||
x:
|
||||
description: Value column key (queryName, or queryName.expression for a
|
||||
multi-aggregation query) plotted on the x axis. Empty uses the first value
|
||||
column.
|
||||
type: string
|
||||
"y":
|
||||
description: Value column key plotted on the y axis. Empty uses the second
|
||||
value column.
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
|
||||
dimensions:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesTableFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
thresholds:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotThreshold'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesScatterPlotPoints:
|
||||
properties:
|
||||
maxSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
minSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
opacity:
|
||||
$ref: '#/components/schemas/DashboardtypesPointOpacity'
|
||||
size:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
type: object
|
||||
DashboardtypesScatterPlotThreshold:
|
||||
properties:
|
||||
axis:
|
||||
$ref: '#/components/schemas/DashboardtypesThresholdAxis'
|
||||
color:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
- color
|
||||
type: object
|
||||
DashboardtypesSource:
|
||||
enum:
|
||||
- user
|
||||
@@ -4683,6 +4775,11 @@ components:
|
||||
- value
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesThresholdAxis:
|
||||
enum:
|
||||
- x
|
||||
- "y"
|
||||
type: string
|
||||
DashboardtypesThresholdFormat:
|
||||
enum:
|
||||
- text
|
||||
|
||||
@@ -277,7 +277,10 @@ 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 := r.ExpandAnnotations(expand)
|
||||
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)})
|
||||
}
|
||||
if smpl.IsMissing {
|
||||
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
|
||||
lb.Set(ruletypes.NoDataLabel, "true")
|
||||
|
||||
@@ -614,51 +614,6 @@ 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;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfigDTO {
|
||||
/**
|
||||
* @enum telegram
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelTelegramConfigDTO;
|
||||
}
|
||||
|
||||
export type AlertmanagertypesChannelConfigDTO =
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTO
|
||||
@@ -669,8 +624,7 @@ export type AlertmanagertypesChannelConfigDTO =
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfigDTO;
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO;
|
||||
|
||||
export enum AlertmanagertypesChannelDefectDTO {
|
||||
none = 'none',
|
||||
@@ -690,7 +644,6 @@ export enum AlertmanagertypesChannelKindDTO {
|
||||
jira = 'jira',
|
||||
jsmops = 'jsmops',
|
||||
incidentio = 'incidentio',
|
||||
telegram = 'telegram',
|
||||
}
|
||||
export enum AlertmanagertypesChannelListOrderDTO {
|
||||
asc = 'asc',
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
1
frontend/src/assets/Logos/android-kotlin-monitoring.svg
Normal file
1
frontend/src/assets/Logos/android-kotlin-monitoring.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 60 60"><linearGradient id="a" x1="15.959" x2="44.307" y1="-13.014" y2="15.333" gradientTransform="matrix(1 0 0 -1 0 61)" gradientUnits="userSpaceOnUse"><stop offset=".097" style="stop-color:#0095d5"/><stop offset=".301" style="stop-color:#238ad9"/><stop offset=".621" style="stop-color:#557bde"/><stop offset=".864" style="stop-color:#7472e2"/><stop offset="1" style="stop-color:#806ee3"/></linearGradient><path d="m0 60 30.1-30.1L60 60z" style="fill:url(#a)"/><linearGradient id="b" x1="4.209" x2="20.673" y1="48.941" y2="65.405" gradientTransform="matrix(1 0 0 -1 0 61)" gradientUnits="userSpaceOnUse"><stop offset=".118" style="stop-color:#0095d5"/><stop offset=".418" style="stop-color:#3c83dc"/><stop offset=".696" style="stop-color:#6d74e1"/><stop offset=".833" style="stop-color:#806ee3"/></linearGradient><path d="M0 0h30.1L0 32.5z" style="fill:url(#b)"/><linearGradient id="c" x1="-10.102" x2="45.731" y1="5.836" y2="61.669" gradientTransform="matrix(1 0 0 -1 0 61)" gradientUnits="userSpaceOnUse"><stop offset=".107" style="stop-color:#c757bc"/><stop offset=".214" style="stop-color:#d0609a"/><stop offset=".425" style="stop-color:#e1725c"/><stop offset=".605" style="stop-color:#ee7e2f"/><stop offset=".743" style="stop-color:#f58613"/><stop offset=".823" style="stop-color:#f88909"/></linearGradient><path d="M30.1 0 0 31.7V60l30.1-30.1L60 0z" style="fill:url(#c)"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -1 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#9CA3AF" d="M12 22.596c6.628 0 12-4.338 12-9.688 0-3.318-2.057-6.248-5.219-7.986-1.286-.715-2.297-1.357-3.139-1.89C14.058 2.025 13.08 1.404 12 1.404c-1.097 0-2.334.785-3.966 1.821a49.92 49.92 0 0 1-2.816 1.697C2.057 6.66 0 9.59 0 12.908c0 5.35 5.372 9.687 12 9.687v.001ZM10.599 4.715c.334-.759.503-1.58.498-2.409 0-.145.202-.187.23-.029.658 2.783-.902 4.162-2.057 4.624-.124.048-.199-.121-.103-.209a5.763 5.763 0 0 0 1.432-1.977Zm2.058-.102a5.82 5.82 0 0 0-.782-2.306v-.016c-.069-.123.086-.263.185-.172 1.962 2.111 1.307 4.067.556 5.051-.082.103-.23-.003-.189-.126a5.85 5.85 0 0 0 .23-2.431Zm1.776-.561a5.727 5.727 0 0 0-1.612-1.806v-.014c-.112-.085-.024-.274.114-.218 2.595 1.087 2.774 3.18 2.459 4.407a.116.116 0 0 1-.049.071.11.11 0 0 1-.153-.026.122.122 0 0 1-.022-.083 5.891 5.891 0 0 0-.737-2.331Zm-5.087.561c-.617.546-1.282.76-2.063 1-.117 0-.195-.078-.156-.181 1.752-.909 2.376-1.649 2.999-2.778 0 0 .155-.118.188.085 0 .304-.349 1.329-.968 1.874Zm4.945 11.237a2.957 2.957 0 0 1-.937 1.553c-.346.346-.8.565-1.286.62a2.178 2.178 0 0 1-1.327-.62 2.955 2.955 0 0 1-.925-1.553.244.244 0 0 1 .064-.198.234.234 0 0 1 .193-.069h3.965a.226.226 0 0 1 .19.07c.05.053.073.125.063.197Zm-5.458-2.176a1.862 1.862 0 0 1-2.384-.245 1.98 1.98 0 0 1-.233-2.447c.207-.319.503-.566.848-.713a1.84 1.84 0 0 1 1.092-.11c.366.075.703.261.967.531a1.98 1.98 0 0 1 .408 2.114 1.931 1.931 0 0 1-.698.869v.001Zm8.495.005a1.86 1.86 0 0 1-2.381-.253 1.964 1.964 0 0 1-.547-1.366c0-.384.11-.76.32-1.079.207-.319.503-.567.849-.713a1.844 1.844 0 0 1 1.093-.108c.367.076.704.262.968.534a1.98 1.98 0 0 1 .4 2.117 1.932 1.932 0 0 1-.702.868Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#F25022" d="M1 1h10.5v10.5H1z"/><path fill="#7FBA00" d="M12.5 1H23v10.5H12.5z"/><path fill="#00A4EF" d="M1 12.5h10.5V23H1z"/><path fill="#FFB900" d="M12.5 12.5H23V23H12.5z"/></svg>
|
||||
|
Before Width: | Height: | Size: 264 B |
@@ -1,7 +1,8 @@
|
||||
import agnoUrl from '@/assets/Logos/agno.svg';
|
||||
import amazonBedrockUrl from '@/assets/Logos/amazon-bedrock.svg';
|
||||
import amazonMskUrl from '@/assets/Logos/amazon-msk.svg';
|
||||
import androidUrl from '@/assets/Logos/android.svg';
|
||||
import androidJavaMonitoringUrl from '@/assets/Logos/android-java-monitoring.svg';
|
||||
import androidKotlinMonitoringUrl from '@/assets/Logos/android-kotlin-monitoring.svg';
|
||||
import anthropicApiMonitoringUrl from '@/assets/Logos/anthropic-api-monitoring.svg';
|
||||
import antigravityUrl from '@/assets/Logos/antigravity.svg';
|
||||
import apacheDruidUrl from '@/assets/Logos/apache-druid.svg';
|
||||
@@ -24,7 +25,6 @@ import azureSqlDatabaseMetricsUrl from '@/assets/Logos/azure-sql-database-metric
|
||||
import azureVmUrl from '@/assets/Logos/azure-vm.svg';
|
||||
import azureUrl from '@/assets/Logos/azure.svg';
|
||||
import basetenUrl from '@/assets/Logos/baseten.svg';
|
||||
import bunUrl from '@/assets/Logos/bun.svg';
|
||||
import cassandraUrl from '@/assets/Logos/cassandra.svg';
|
||||
import celeryUrl from '@/assets/Logos/celery.svg';
|
||||
import certManagerUrl from '@/assets/Logos/cert-manager.svg';
|
||||
@@ -126,7 +126,6 @@ import logstashUrl from '@/assets/Logos/logstash.svg';
|
||||
import mastraUrl from '@/assets/Logos/mastra.svg';
|
||||
import metaUrl from '@/assets/Logos/meta.svg';
|
||||
import metricsUrl from '@/assets/Logos/metrics.svg';
|
||||
import microsoftUrl from '@/assets/Logos/microsoft.svg';
|
||||
import microsoftSqlServerUrl from '@/assets/Logos/microsoft-sql-server.svg';
|
||||
import mistralUrl from '@/assets/Logos/mistral.svg';
|
||||
import modalUrl from '@/assets/Logos/modal.svg';
|
||||
@@ -4713,40 +4712,70 @@ const onboardingConfigWithLinks = [
|
||||
link: '/docs/messaging-queues/celery-setup/',
|
||||
},
|
||||
{
|
||||
dataSource: 'android',
|
||||
label: 'Android',
|
||||
imgUrl: androidUrl,
|
||||
dataSource: 'android-java',
|
||||
label: 'Android Java',
|
||||
imgUrl: androidJavaMonitoringUrl,
|
||||
tags: ['mobile app monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'android',
|
||||
'android app tracing',
|
||||
'android java',
|
||||
'android kotlin',
|
||||
'android java instrumentation',
|
||||
'android java monitoring',
|
||||
'android java to signoz',
|
||||
'android logs',
|
||||
'android metrics',
|
||||
'android observability',
|
||||
'android performance monitoring',
|
||||
'android to signoz',
|
||||
'android-java',
|
||||
'apm',
|
||||
'app',
|
||||
'application performance monitoring',
|
||||
'java',
|
||||
'kotlin',
|
||||
'logs',
|
||||
'metrics',
|
||||
'mobile',
|
||||
'mobile app monitoring',
|
||||
'monitor android apps',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'opentelemetry android',
|
||||
'otel android agent',
|
||||
'opentelemetry android java',
|
||||
'otel android sdk',
|
||||
'traces',
|
||||
'tracing',
|
||||
],
|
||||
id: 'android',
|
||||
link: '/docs/instrumentation/mobile-instrumentation/opentelemetry-android/',
|
||||
link: '/docs/instrumentation/mobile-instrumentation/opentelemetry-java/',
|
||||
},
|
||||
{
|
||||
dataSource: 'android-kotlin',
|
||||
label: 'Android Kotlin',
|
||||
imgUrl: androidKotlinMonitoringUrl,
|
||||
tags: ['mobile app monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'android',
|
||||
'android app performance kotlin',
|
||||
'android kotlin',
|
||||
'android kotlin monitoring',
|
||||
'android kotlin to signoz',
|
||||
'android observability kotlin',
|
||||
'android telemetry sdk',
|
||||
'android-kotlin',
|
||||
'apm',
|
||||
'app',
|
||||
'application performance monitoring',
|
||||
'kotlin',
|
||||
'kotlin app logs',
|
||||
'kotlin mobile tracing',
|
||||
'mobile',
|
||||
'mobile app monitoring',
|
||||
'monitor kotlin apps',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'opentelemetry android kotlin',
|
||||
'otel kotlin sdk',
|
||||
'traces',
|
||||
'tracing',
|
||||
],
|
||||
link: '/docs/instrumentation/mobile-instrumentation/opentelemetry-kotlin/',
|
||||
},
|
||||
{
|
||||
dataSource: 'flutter',
|
||||
@@ -7958,112 +7987,5 @@ const onboardingConfigWithLinks = [
|
||||
id: 'aws-lambda-microvms',
|
||||
link: '/docs/aws-monitoring/lambda-microvms/',
|
||||
},
|
||||
{
|
||||
dataSource: 'bun-elysia',
|
||||
label: 'Bun / Elysia',
|
||||
imgUrl: bunUrl,
|
||||
tags: ['apm/traces', 'metrics'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'apm',
|
||||
'application performance monitoring',
|
||||
'bun',
|
||||
'bun elysia',
|
||||
'bun metrics',
|
||||
'bun monitoring',
|
||||
'bun observability',
|
||||
'bun traces',
|
||||
'bunjs',
|
||||
'elysia',
|
||||
'elysiajs',
|
||||
'javascript',
|
||||
'metrics',
|
||||
'observability',
|
||||
'opentelemetry bun',
|
||||
'traces',
|
||||
'tracing',
|
||||
'typescript',
|
||||
],
|
||||
id: 'bun-elysia',
|
||||
link: '/docs/instrumentation/opentelemetry-bun/',
|
||||
},
|
||||
{
|
||||
dataSource: 'snmp',
|
||||
label: 'SNMP',
|
||||
imgUrl: metricsUrl,
|
||||
tags: ['metrics'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'firewall',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'network',
|
||||
'network devices',
|
||||
'network monitoring',
|
||||
'observability',
|
||||
'opentelemetry snmp',
|
||||
'router',
|
||||
'snmp',
|
||||
'snmp metrics',
|
||||
'snmp receiver',
|
||||
'switch',
|
||||
],
|
||||
id: 'snmp',
|
||||
link: '/docs/metrics-management/opentelemetry-snmp-metrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'vercel-eve',
|
||||
label: 'Vercel Eve',
|
||||
imgUrl: vercelUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'agent',
|
||||
'agents',
|
||||
'ai agent',
|
||||
'eve',
|
||||
'eve dev',
|
||||
'eve observability',
|
||||
'llm',
|
||||
'llm monitoring',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'token usage',
|
||||
'traces',
|
||||
'tracing',
|
||||
'vercel',
|
||||
'vercel agents',
|
||||
'vercel eve',
|
||||
],
|
||||
id: 'vercel-eve',
|
||||
link: '/docs/eve-observability/',
|
||||
},
|
||||
{
|
||||
dataSource: 'microsoft-agent-framework',
|
||||
label: 'Microsoft Agent Framework',
|
||||
imgUrl: microsoftUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'agent',
|
||||
'agent framework',
|
||||
'agents',
|
||||
'ai agent',
|
||||
'llm',
|
||||
'llm monitoring',
|
||||
'maf',
|
||||
'microsoft',
|
||||
'microsoft agent framework',
|
||||
'microsoft agent framework observability',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'token usage',
|
||||
'traces',
|
||||
'tracing',
|
||||
'workflows',
|
||||
],
|
||||
id: 'microsoft-agent-framework',
|
||||
link: '/docs/microsoft-agent-framework-observability/',
|
||||
},
|
||||
];
|
||||
export default onboardingConfigWithLinks;
|
||||
|
||||
2
go.mod
2
go.mod
@@ -381,7 +381,7 @@ require (
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d
|
||||
google.golang.org/grpc v1.83.2 // indirect
|
||||
gopkg.in/telebot.v3 v3.3.8
|
||||
gopkg.in/telebot.v3 v3.3.8 // indirect
|
||||
k8s.io/client-go v0.35.3 // indirect
|
||||
k8s.io/klog/v2 v2.140.0 // indirect
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||
|
||||
@@ -13,7 +13,6 @@ 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"
|
||||
@@ -33,7 +32,6 @@ 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) {
|
||||
@@ -104,11 +102,6 @@ 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
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
// 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 per-rule title and
|
||||
// body templates carried on the _title_template and _body_template
|
||||
// annotations.
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"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"
|
||||
telebot "gopkg.in/telebot.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
Integration = "telegram"
|
||||
|
||||
// Telegram supports 4096 chars max - from https://limits.tginfo.me/en.
|
||||
maxMessageLenRunes = 4096
|
||||
|
||||
parseModeHTML = "HTML"
|
||||
)
|
||||
|
||||
type Notifier struct {
|
||||
conf *config.TelegramConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *telebot.Bot
|
||||
retrier *notify.Retrier
|
||||
templater alertmanagertypes.Templater
|
||||
}
|
||||
|
||||
func New(conf *config.TelegramConfig, 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 {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "telegram api_url is nil")
|
||||
}
|
||||
|
||||
// Rendering targets the HTML subset only. A receiver written through the v1
|
||||
// API can still carry another mode, and sending HTML-escaped text under it
|
||||
// would mangle the message, so the integration refuses to build instead.
|
||||
if conf.ParseMode != parseModeHTML {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "telegram parse_mode %q is not supported, only %s is", conf.ParseMode, parseModeHTML)
|
||||
}
|
||||
|
||||
httpclient, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration, httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := telebot.NewBot(telebot.Settings{
|
||||
URL: conf.APIUrl.String(),
|
||||
ParseMode: conf.ParseMode,
|
||||
Client: httpclient,
|
||||
Offline: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Notifier{
|
||||
conf: conf,
|
||||
tmpl: t,
|
||||
logger: l,
|
||||
client: client,
|
||||
retrier: ¬ify.Retrier{},
|
||||
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
|
||||
}
|
||||
n.client.Token = botToken
|
||||
|
||||
chatID, err := n.getChatID()
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
|
||||
_, err = n.client.Send(telebot.ChatID(chatID), messageText, &telebot.SendOptions{
|
||||
DisableNotification: n.conf.DisableNotifications,
|
||||
DisableWebPagePreview: true,
|
||||
ThreadID: n.conf.MessageThreadID,
|
||||
ParseMode: n.conf.ParseMode,
|
||||
})
|
||||
if err != nil {
|
||||
return true, notify.NewErrorWithReason(notify.ClientErrorReason, err)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// buildMessage renders the channel's own message template unless the rule
|
||||
// carries templates of its own, in which case those replace it.
|
||||
func (n *Notifier) buildMessage(ctx context.Context, logger *slog.Logger, as []*types.Alert) (string, error) {
|
||||
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(as)
|
||||
if customTitle == "" && customBody == "" {
|
||||
return n.renderChannelTemplate(ctx, as)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// A rule may set only one of the two, so the other half still comes from
|
||||
// the channel.
|
||||
body := strings.Join(nonEmpty(result.Body), "\n\n")
|
||||
if customBody == "" {
|
||||
body, err = n.renderChannelTemplate(ctx, as)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
body, err = n.renderRuleTemplate(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if result.Title == "" {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
title, err := n.renderRuleTemplate(result.Title)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
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) (string, error) {
|
||||
var tmplErr error
|
||||
data := notify.GetTemplateData(ctx, n.tmpl, as, n.logger)
|
||||
|
||||
message := notify.TmplHTML(n.tmpl, data, &tmplErr)(n.conf.Message)
|
||||
if tmplErr != nil {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to execute telegram message template: %s", tmplErr.Error())
|
||||
}
|
||||
|
||||
return message, 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
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
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. telebot posts
|
||||
// every parameter as a string but parses chat.id back into an int64, so the
|
||||
// echoed id has to be a number or the send fails and the pipeline retries.
|
||||
type mockBotAPI struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
sent []map[string]string
|
||||
}
|
||||
|
||||
func newMockBotAPI(t *testing.T) *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(¶ms)
|
||||
|
||||
mock.mu.Lock()
|
||||
mock.sent = append(mock.sent, params)
|
||||
mock.mu.Unlock()
|
||||
|
||||
chatID, _ := strconv.ParseInt(params["chat_id"], 10, 64)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":1,"date":0,"chat":{"id":` + strconv.FormatInt(chatID, 10) + `,"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()
|
||||
tmpl := test.CreateTmpl(t)
|
||||
apiURL, err := url.Parse(mock.srv.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
notifier, err := New(&config.TelegramConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
APIUrl: &config.URL{URL: apiURL},
|
||||
BotToken: "123456:tok",
|
||||
ChatID: -1001234567890,
|
||||
ParseMode: "HTML",
|
||||
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
|
||||
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>",
|
||||
"<b>Summary:</b> p99 is 612ms",
|
||||
`<a href="http://am/alerts/overview?ruleId=r1">View in SigNoz</a>`,
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
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_OmitsTheTitleLine",
|
||||
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
|
||||
ruletypes.AnnotationBodyTemplate: "$alert.status for $rule.name",
|
||||
})},
|
||||
wantExact: "firing for checkout-latency-high",
|
||||
},
|
||||
{
|
||||
name: "TitleOnly_KeepsTheChannelBody",
|
||||
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
|
||||
ruletypes.AnnotationTitleTemplate: "Incident on $labels.host",
|
||||
})},
|
||||
wantContains: []string{"<b>Incident on web-3</b>", "<b>Summary:</b> p99 is 612ms"},
|
||||
},
|
||||
{
|
||||
// Rule templates are authored in markdown for every channel, so
|
||||
// Telegram converts rather than escaping the markers away.
|
||||
name: "RuleTemplateMarkdown_RenderedToTelegramHTML",
|
||||
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&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 < 2s & rising</b>\n\np99 < 2s & rising",
|
||||
},
|
||||
{
|
||||
name: "GroupedAlerts_BodyRenderedPerAlert",
|
||||
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)
|
||||
|
||||
retry, err := newNotifier(t, mock).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 integration refuses to build rather than sending
|
||||
// HTML-escaped text under a markdown parser.
|
||||
func TestNewRejectsAParseModeOtherThanHTML(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
parseMode string
|
||||
wantErr bool
|
||||
}{
|
||||
{"HTML", "HTML", false},
|
||||
{"MarkdownV2", "MarkdownV2", true},
|
||||
{"Markdown", "Markdown", true},
|
||||
{"Empty", "", true},
|
||||
}
|
||||
|
||||
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)
|
||||
_, err = New(&config.TelegramConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
APIUrl: &config.URL{URL: apiURL},
|
||||
BotToken: "123456:tok",
|
||||
ChatID: -1001234567890,
|
||||
ParseMode: testCase.parseMode,
|
||||
Message: alertmanagertypes.DefaultTelegramMessageTemplate,
|
||||
}, tmpl, logger, alertmanagertemplate.New(tmpl, logger))
|
||||
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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"])
|
||||
}
|
||||
@@ -12,7 +12,6 @@ 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"
|
||||
@@ -254,24 +253,6 @@ 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 {
|
||||
|
||||
@@ -833,55 +833,3 @@ 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +211,10 @@ 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 := r.ExpandAnnotations(expand)
|
||||
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)})
|
||||
}
|
||||
if result.IsMissing {
|
||||
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
|
||||
lb.Set(ruletypes.NoDataLabel, "true")
|
||||
|
||||
@@ -295,7 +295,10 @@ 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 := r.ExpandAnnotations(expand)
|
||||
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)})
|
||||
}
|
||||
if smpl.IsMissing {
|
||||
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
|
||||
lb.Set(ruletypes.NoDataLabel, "true")
|
||||
|
||||
@@ -8,7 +8,6 @@ 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"
|
||||
)
|
||||
@@ -39,11 +38,6 @@ 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.
|
||||
@@ -73,14 +67,6 @@ 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 {
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
// 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("&", "&", "<", "<", ">", ">")
|
||||
attrEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
||||
)
|
||||
|
||||
// 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())
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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&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)"},
|
||||
{"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 < 2s & rising"},
|
||||
{"InsideBold", "**a < b**", "<b>a < b</b>"},
|
||||
{"InsideCodeSpan", "`if a < b && c`", "<code>if a < b && c</code>"},
|
||||
{"InsideCodeBlock", "```\na < b\n```", "<pre>a < b</pre>"},
|
||||
{"InsideLinkText", "[a < b](https://signoz.io)", `<a href="https://signoz.io">a < b</a>`},
|
||||
{"QuoteInHref", `[x](https://signoz.io/?q=")`, `<a href="https://signoz.io/?q="">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, "&lt;", "double-escaped")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/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 DefaultTelegramMessageTemplate = `<b>[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname | html }}</b>
|
||||
|
||||
{{ 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, the global
|
||||
// telegram_api_url applies.
|
||||
APIURL string `json:"apiUrl"`
|
||||
// MessageThreadID targets a topic inside a forum group.
|
||||
MessageThreadID int `json:"messageThreadId,omitempty"`
|
||||
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, config.DefaultTelegramConfig.VSendResolved)
|
||||
c.Message.SetIfUnset(DefaultTelegramMessageTemplate)
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
// Validate repeats the checks upstream's TelegramConfig.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 != "" {
|
||||
parsed, err := parseUpstreamURL(c.APIURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiURL = parsed
|
||||
}
|
||||
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
TelegramConfigs: []*config.TelegramConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultTelegramConfig.VSendResolved)},
|
||||
APIUrl: apiURL,
|
||||
BotToken: config.Secret(c.BotToken),
|
||||
ChatID: c.ChatID,
|
||||
MessageThreadID: c.MessageThreadID,
|
||||
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: formatUpstreamURL(telegram.APIUrl),
|
||||
MessageThreadID: telegram.MessageThreadID,
|
||||
Message: valuer.UnsetIfEmpty(telegram.Message),
|
||||
DisableNotifications: telegram.DisableNotifications,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
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, DefaultTelegramMessageTemplate, spec.Message.StringValue())
|
||||
assert.Empty(t, spec.APIURL, "the global telegram_api_url applies 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 TestDefaultTelegramMessageTemplateEscapesAlertContent(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) {
|
||||
rendered, err := render(DefaultTelegramMessageTemplate, data)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, rendered, "<b>[FIRING:1] p99 < 2s & rising</b>")
|
||||
assert.Contains(t, rendered, "<b>Summary:</b> latency > 2s")
|
||||
assert.Contains(t, rendered, `<a href="http://localhost:8080/alerts?a=1&b=2">View in SigNoz</a>`)
|
||||
assert.Contains(t, rendered, `<a href="http://localhost:8080/logs?a=1&b=2">View related logs</a>`)
|
||||
assert.NotContains(t, rendered, "&lt;", "double-escaped")
|
||||
assert.NotContains(t, rendered, "View related traces")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// APIUrl and HTTPConfig are filled from the global rather than the spec, and
|
||||
// the notifier refuses to start without them. A resolve that leaves either nil
|
||||
// fails the channel at delivery.
|
||||
func TestTelegramResolvesTheGlobalAPIURLAndHTTPConfig(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]
|
||||
require.NotNil(t, telegram.APIUrl)
|
||||
assert.Equal(t, "https://api.telegram.org", telegram.APIUrl.String())
|
||||
assert.NotNil(t, telegram.HTTPConfig)
|
||||
}
|
||||
@@ -96,7 +96,6 @@ func (ChannelConfig) JSONSchemaOneOf() []any {
|
||||
ChannelConfigVariant[ChannelJiraConfig]{Kind: ChannelKindJira.StringValue()},
|
||||
ChannelConfigVariant[ChannelJSMOpsConfig]{Kind: ChannelKindJSMOps.StringValue()},
|
||||
ChannelConfigVariant[ChannelIncidentIOConfig]{Kind: ChannelKindIncidentIO.StringValue()},
|
||||
ChannelConfigVariant[ChannelTelegramConfig]{Kind: ChannelKindTelegram.StringValue()},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +113,6 @@ func (ChannelConfig) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
ChannelKindJira.StringValue(): channelVariantRef("ChannelJiraConfig"),
|
||||
ChannelKindJSMOps.StringValue(): channelVariantRef("ChannelJSMOpsConfig"),
|
||||
ChannelKindIncidentIO.StringValue(): channelVariantRef("ChannelIncidentIOConfig"),
|
||||
ChannelKindTelegram.StringValue(): channelVariantRef("ChannelTelegramConfig"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -258,28 +258,6 @@ 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: "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: "https://bot-api.example.com",
|
||||
MessageThreadID: 42,
|
||||
Message: valuer.MustNewUnsetOrNonEmptyString("telegram message"),
|
||||
DisableNotifications: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
@@ -418,8 +396,8 @@ func TestDeriveChannelConfigRejectsUnrepresentableChannels(t *testing.T) {
|
||||
{
|
||||
description: "notifier kind outside the supported set",
|
||||
channel: Channel{
|
||||
DisplayName: "dc",
|
||||
Data: `{"name":"dc","discord_configs":[{"webhook_url":"https://discord.com/api/webhooks/1/x"}]}`,
|
||||
DisplayName: "tg",
|
||||
Data: `{"name":"tg","telegram_configs":[{"chat_id":1}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -29,7 +29,6 @@ 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 {
|
||||
|
||||
@@ -74,12 +74,6 @@ 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) {
|
||||
|
||||
@@ -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: "dc",
|
||||
Name: "dc",
|
||||
Type: "discord",
|
||||
Data: `{"name":"dc","discord_configs":[{"webhook_url":"https://discord.com/api/webhooks/1/x"}]}`,
|
||||
DisplayName: "tg",
|
||||
Name: "tg",
|
||||
Type: "telegram",
|
||||
Data: `{"name":"tg","telegram_configs":[{"chat_id":1}]}`,
|
||||
}
|
||||
|
||||
listed := channel.ToListedNotificationChannel()
|
||||
assert.Equal(t, "dc", listed.Name)
|
||||
assert.Equal(t, "tg", listed.Name)
|
||||
assert.True(t, listed.Kind.IsZero())
|
||||
}
|
||||
|
||||
@@ -123,10 +123,22 @@ func (d *DashboardSpec) validatePanels() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// validatePanelQueryCount left exactly one query for a spec that reads it.
|
||||
if spec, ok := panel.Spec.Plugin.Spec.(queryBoundPanelSpec); ok {
|
||||
if err := spec.validateAgainstQuery(panel.Spec.Queries[0], path+".spec.plugin.spec"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryBoundPanelSpec is a panel spec with fields that depend on the panel's
|
||||
// query. It is checked after the query itself validates.
|
||||
type queryBoundPanelSpec interface {
|
||||
validateAgainstQuery(query Query, path string) error
|
||||
}
|
||||
|
||||
func validatePanelQueryCount(queries []Query, panelKind PanelPluginKind, path string) error {
|
||||
if queries == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: is required and must not be null; use [] for a panel that renders without a query", path)
|
||||
|
||||
@@ -1869,6 +1869,319 @@ func TestInvalidateAreaChartPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScatterPlotPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/ScatterPlotPanel",
|
||||
"spec": {}
|
||||
},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
d, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &ScatterPlotPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
|
||||
|
||||
assert.Empty(t, spec.Dimensions.X, "an omitted x stays empty so the renderer picks the first value column")
|
||||
assert.Empty(t, spec.Dimensions.Y, "an omitted y stays empty so the renderer picks the second value column")
|
||||
assert.Empty(t, spec.Dimensions.ColorBy, "an omitted colorBy stays empty so the renderer colours by every group-by label")
|
||||
assert.Equal(t, "auto", spec.Axes.X.Scale.ValueOrDefault(), "expected x scale default auto")
|
||||
assert.Equal(t, "auto", spec.Axes.Y.Scale.ValueOrDefault(), "expected y scale default auto")
|
||||
assert.Nil(t, spec.Axes.X.SoftMin, "expected x softMin unset")
|
||||
assert.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
|
||||
assert.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
|
||||
assert.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
|
||||
|
||||
output, err := json.Marshal(d)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
assert.Contains(t, string(output), `"axes":{"x":{"softMin":null,"softMax":null,"scale":"auto","label":""},"y":{"softMin":null,"softMax":null,"scale":"auto","label":""}}`, "expected default axes in stored/response JSON")
|
||||
assert.Contains(t, string(output), `"dimensions":{"x":"","y":"","sizeBy":"","colorBy":null}`, "expected empty dimensions in stored/response JSON")
|
||||
assert.Contains(t, string(output), `"chartAppearance":{"points":{"size":null,"minSize":null,"maxSize":null,"opacity":null}}`, "omitted point settings stay null so the renderer applies its defaults")
|
||||
}
|
||||
|
||||
func TestValidateScatterPlotDashboard(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/perses_scatter_plot_panel.json")
|
||||
require.NoError(t, err, "reading example file")
|
||||
|
||||
spec, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &ScatterPlotPanelSpec{}, spec.Panels["p1"].Spec.Plugin.Spec)
|
||||
panelSpec := spec.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
|
||||
softMin, softMax := 0.0, 1000.0
|
||||
size, minSize, maxSize, opacity := PointDiameter(8), PointDiameter(4), PointDiameter(24), PointOpacity(0.1)
|
||||
assert.Equal(t, &ScatterPlotPanelSpec{
|
||||
Visualization: BasicVisualization{TimePreference: TimePreferenceLast1Hr},
|
||||
Dimensions: ScatterPlotDimensions{X: "A.count()", Y: "A.p99(duration_nano)", SizeBy: "A.countIf(has_error = true)", ColorBy: []string{"k8s.namespace.name", "k8s.pod.name"}},
|
||||
Formatting: TableFormatting{ColumnUnits: map[string]string{"A.p99(duration_nano)": "ns"}, DecimalPrecision: PrecisionOption1},
|
||||
Axes: ScatterPlotAxes{
|
||||
X: ScatterPlotAxis{SoftMin: &softMin, Scale: AxisScaleLog, Label: "Throughput"},
|
||||
Y: ScatterPlotAxis{SoftMax: &softMax, Scale: AxisScaleSymlog, Label: "p99 latency"},
|
||||
},
|
||||
ChartAppearance: ScatterPlotChartAppearance{Points: ScatterPlotPoints{Size: &size, MinSize: &minSize, MaxSize: &maxSize, Opacity: &opacity}},
|
||||
Legend: Legend{Position: LegendPositionRight, Mode: LegendModeList, CustomColors: map[string]string{"checkout": "#1f77b4"}},
|
||||
Thresholds: []ScatterPlotThreshold{
|
||||
{ThresholdWithLabel: ThresholdWithLabel{Value: 300, Unit: "ms", Color: "#f00", Label: "p99 SLO"}, Axis: ThresholdAxisY},
|
||||
{ThresholdWithLabel: ThresholdWithLabel{Value: 50, Color: "#0f0"}, Axis: ThresholdAxisX},
|
||||
},
|
||||
}, panelSpec)
|
||||
|
||||
dashboard := &DashboardV2{Spec: *spec}
|
||||
request, err := dashboard.GetPanelQuery(1, 2, "p1")
|
||||
require.NoError(t, err, "building the panel's query failed")
|
||||
assert.Equal(t, qb.RequestTypeScalar, request.RequestType)
|
||||
require.NotNil(t, request.FormatOptions)
|
||||
assert.True(t, request.FormatOptions.FormatTableResultForUI, "scatter rows are joined the way a table's are")
|
||||
|
||||
require.Len(t, request.CompositeQuery.Queries, 1)
|
||||
query, ok := request.CompositeQuery.Queries[0].Spec.(qb.QueryBuilderQuery[qb.TraceAggregation])
|
||||
require.True(t, ok, "expected a traces builder query")
|
||||
assert.Len(t, query.Aggregations, 3)
|
||||
assert.Len(t, query.GroupBy, 2)
|
||||
|
||||
require.NoError(t, request.Validate(), "the request built from the panel is not a valid scalar request")
|
||||
|
||||
// the panel read back out of storage draws the same scatter plot
|
||||
stored, err := json.Marshal(spec)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
reread, err := unmarshalDashboard(stored)
|
||||
require.NoError(t, err, "the stored dashboard does not validate")
|
||||
assert.Equal(t, panelSpec, reread.Panels["p1"].Spec.Plugin.Spec)
|
||||
rereadRequest, err := (&DashboardV2{Spec: *reread}).GetPanelQuery(1, 2, "p1")
|
||||
require.NoError(t, err, "building the stored panel's query failed")
|
||||
assert.Equal(t, request, rereadRequest)
|
||||
}
|
||||
|
||||
func TestInvalidateScatterPlotPanelSpecValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
panelKind string
|
||||
panelSpec string
|
||||
expectedErrorSubstring string
|
||||
}{
|
||||
{
|
||||
scenario: "unknown axis scale",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"x": {"scale": "sqrt"}}}`,
|
||||
expectedErrorSubstring: "invalid axis scale \"sqrt\": must be `auto`, `linear`, `log`, or `symlog`",
|
||||
},
|
||||
{
|
||||
scenario: "non-string axis scale",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"y": {"scale": true}}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal bool",
|
||||
},
|
||||
{
|
||||
scenario: "isLogScale on a scatter plot axis",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"x": {"isLogScale": true}}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "single-axis axes shape on a scatter plot panel",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"softMin": 0}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "unknown threshold axis",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"thresholds": [{"value": 1, "color": "#f00", "axis": "z"}]}`,
|
||||
expectedErrorSubstring: "invalid threshold axis \"z\": must be `x` or `y`",
|
||||
},
|
||||
{
|
||||
scenario: "unknown dimension",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"dimensions": {"z": "A"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "point size below the minimum",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"size": 1}}}`,
|
||||
expectedErrorSubstring: "invalid point diameter 1: must be a whole number between 2 and 40",
|
||||
},
|
||||
{
|
||||
scenario: "max point size above the maximum",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"maxSize": 41}}}`,
|
||||
expectedErrorSubstring: "invalid point diameter 41",
|
||||
},
|
||||
{
|
||||
scenario: "fractional point size",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"minSize": 4.5}}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal number 4.5",
|
||||
},
|
||||
{
|
||||
scenario: "min point size above max",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"minSize": 20, "maxSize": 10}}}`,
|
||||
expectedErrorSubstring: "minSize 20 must not exceed maxSize 10",
|
||||
},
|
||||
{
|
||||
scenario: "point opacity below the floor",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"opacity": 0.05}}}`,
|
||||
expectedErrorSubstring: "invalid point opacity 0.05: must be between 0.1 and 1",
|
||||
},
|
||||
{
|
||||
scenario: "point opacity as a percentage",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"opacity": 70}}}`,
|
||||
expectedErrorSubstring: "invalid point opacity 70",
|
||||
},
|
||||
{
|
||||
scenario: "area fill fields on a scatter plot",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"fillOpacity": 0.5}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "a single colour key as a string",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"dimensions": {"colorBy": "service.name"}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal string",
|
||||
},
|
||||
{
|
||||
scenario: "panel-wide unit on a scatter plot panel",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"formatting": {"unit": "ms"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "threshold without a color",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"thresholds": [{"value": 300}]}`,
|
||||
expectedErrorSubstring: "Color' Error:Field validation for 'Color' failed on the 'required' tag",
|
||||
},
|
||||
{
|
||||
scenario: "scale on a time series panel",
|
||||
panelKind: "signoz/TimeSeriesPanel",
|
||||
panelSpec: `{"axes": {"scale": "log"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.scenario, func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "` + test.panelKind + `", "spec": ` + test.panelSpec + `},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected the spec to be rejected")
|
||||
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScatterPlotDimensionsBindToQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
queryPlugin string
|
||||
dimensions string
|
||||
expectedErrorSubstring string
|
||||
}{
|
||||
{
|
||||
scenario: "a single-aggregation query is one value column keyed by its name",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}}`,
|
||||
dimensions: `{"x": "A", "colorBy": ["service.name"]}`,
|
||||
},
|
||||
{
|
||||
scenario: "a multi-aggregation query keys each value column by name and expression",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}, {"expression": "p99(duration_nano)"}]}}`,
|
||||
dimensions: `{"x": "A"}`,
|
||||
expectedErrorSubstring: "dimensions.x: \"A\" is not a value column of the panel's query; value columns: `A.count()`, `A.p99(duration_nano)`",
|
||||
},
|
||||
{
|
||||
scenario: "a value column of a query the panel does not have",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}`,
|
||||
dimensions: `{"y": "B"}`,
|
||||
expectedErrorSubstring: "dimensions.y: \"B\" is not a value column of the panel's query; value columns: `A`",
|
||||
},
|
||||
{
|
||||
scenario: "a colour key the query does not group by",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}}`,
|
||||
dimensions: `{"colorBy": ["service.name", "k8s.pod.name"]}`,
|
||||
expectedErrorSubstring: "dimensions.colorBy: \"k8s.pod.name\" is not a group-by key of the panel's query; group-by keys: `service.name`",
|
||||
},
|
||||
{
|
||||
scenario: "a formula is a value column and carries the labels of the disabled queries it reads",
|
||||
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": true, "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "disabled": true, "aggregations": [{"expression": "countIf(has_error = true)"}], "groupBy": [{"name": "service.name"}]}},
|
||||
{"type": "builder_formula", "spec": {"name": "F1", "expression": "B / A"}}
|
||||
]}}`,
|
||||
dimensions: `{"x": "F1", "colorBy": ["service.name"]}`,
|
||||
},
|
||||
{
|
||||
scenario: "a disabled query yields no value column",
|
||||
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "disabled": true, "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_formula", "spec": {"name": "F1", "expression": "A * 2"}}
|
||||
]}}`,
|
||||
dimensions: `{"sizeBy": "A"}`,
|
||||
expectedErrorSubstring: "dimensions.sizeBy: \"A\" is not a value column of the panel's query; value columns: `F1`",
|
||||
},
|
||||
{
|
||||
scenario: "clickhouse columns are only known at run time, so the bindings go unchecked",
|
||||
queryPlugin: `{"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}`,
|
||||
dimensions: `{"x": "throughput", "colorBy": ["pod"]}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.scenario, func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/ScatterPlotPanel", "spec": {"dimensions": ` + test.dimensions + `}},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": ` + test.queryPlugin + `}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
if test.expectedErrorSubstring == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err, "expected the binding to be rejected")
|
||||
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumberPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
@@ -2190,7 +2503,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
switch panelKind {
|
||||
case "signoz/ListPanel":
|
||||
return "raw"
|
||||
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel":
|
||||
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel", "signoz/ScatterPlotPanel":
|
||||
return "scalar"
|
||||
default:
|
||||
return "time_series"
|
||||
@@ -2232,14 +2545,17 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
{"TimeSeries+PromQL", mkQuery("signoz/TimeSeriesPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), false},
|
||||
{"Table+ClickHouse", mkQuery("signoz/TablePanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
{"List+Builder", mkQuery("signoz/ListPanel", "signoz/BuilderQuery", `{"name":"A","signal":"logs"}`), false},
|
||||
{"ScatterPlot+ClickHouse", mkQuery("signoz/ScatterPlotPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
// Top-level: rejected
|
||||
{"Table+PromQL", mkQuery("signoz/TablePanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"ScatterPlot+PromQL", mkQuery("signoz/ScatterPlotPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"List+ClickHouse", mkQuery("signoz/ListPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), true},
|
||||
{"List+PromQL", mkQuery("signoz/ListPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"List+Composite", mkQuery("signoz/ListPanel", "signoz/CompositeQuery", `{"queries":[]}`), true},
|
||||
{"List+Formula", mkQuery("signoz/ListPanel", "signoz/Formula", `{"name":"F1","expression":"A+B"}`), true},
|
||||
// Composite sub-queries
|
||||
{"Table+Composite(promql)", mkComposite("signoz/TablePanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
{"ScatterPlot+Composite(promql)", mkComposite("signoz/ScatterPlotPanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
{"Table+Composite(clickhouse)", mkComposite("signoz/TablePanel", "clickhouse_sql", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
}
|
||||
|
||||
|
||||
@@ -28,16 +28,17 @@ type PanelPlugin struct {
|
||||
// (and strips the duplicate parent properties) after reflection.
|
||||
func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return markDiscriminator(s, "kind", map[string]string{
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -71,6 +72,7 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
|
||||
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
|
||||
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
|
||||
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
|
||||
PanelPluginVariant[ScatterPlotPanelSpec]{Kind: string(PanelKindScatterPlot)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,16 +229,17 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
|
||||
|
||||
var (
|
||||
panelPluginSpecs = map[PanelPluginKind]func() any{
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
|
||||
}
|
||||
queryPluginSpecs = map[QueryPluginKind]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
@@ -252,16 +255,17 @@ var (
|
||||
VariableKindCustom: func() any { return new(CustomVariableSpec) },
|
||||
}
|
||||
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
|
||||
CompositeQuery: composite,
|
||||
FormatOptions: &qb.FormatOptions{
|
||||
FillGaps: fillGaps,
|
||||
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable,
|
||||
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable || panel.Spec.Plugin.Kind == PanelKindScatterPlot,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -209,34 +209,38 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sets FormatTableResultForUI only for table panels", func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: PanelKindTable},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeScalar,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
t.Run("sets FormatTableResultForUI for panels that join scalar rows", func(t *testing.T) {
|
||||
for _, kind := range []PanelPluginKind{PanelKindTable, PanelKindScatterPlot} {
|
||||
t.Run(string(kind), func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: kind},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeScalar,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.True(t, req.FormatOptions.FormatTableResultForUI)
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.True(t, req.FormatOptions.FormatTableResultForUI)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sets FillGaps from the panel visualization", func(t *testing.T) {
|
||||
|
||||
@@ -2,6 +2,8 @@ package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -166,20 +168,21 @@ func (BuilderQuerySpec) JSONSchemaOneOf() []any {
|
||||
type PanelPluginKind string
|
||||
|
||||
const (
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
|
||||
)
|
||||
|
||||
func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap}
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot}
|
||||
}
|
||||
|
||||
func (k PanelPluginKind) rendersWithoutQuery() bool {
|
||||
@@ -329,6 +332,95 @@ type TextPanelSpec struct {
|
||||
HeaderOptions HeaderOptions `json:"headerOptions"`
|
||||
}
|
||||
|
||||
type ScatterPlotPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Dimensions ScatterPlotDimensions `json:"dimensions"`
|
||||
Formatting TableFormatting `json:"formatting"`
|
||||
Axes ScatterPlotAxes `json:"axes"`
|
||||
ChartAppearance ScatterPlotChartAppearance `json:"chartAppearance"`
|
||||
Legend Legend `json:"legend"`
|
||||
Thresholds []ScatterPlotThreshold `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
func (s *ScatterPlotPanelSpec) validateAgainstQuery(query Query, path string) error {
|
||||
composite, err := query.Spec.Plugin.buildV5CompositeQueryFromPlugin()
|
||||
if err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.dimensions: %s", path, err.Error())
|
||||
}
|
||||
keys, known := composite.CollectResultColumnKeys()
|
||||
if !known {
|
||||
return nil
|
||||
}
|
||||
for _, binding := range []struct{ field, key string }{{"x", s.Dimensions.X}, {"y", s.Dimensions.Y}, {"sizeBy", s.Dimensions.SizeBy}} {
|
||||
// Empty is unset: the renderer picks a column or a fixed size.
|
||||
if binding.key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := keys.Aggregations[binding.key]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.dimensions.%s: %q is not a value column of the panel's query; value columns: %s", path, binding.field, binding.key, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Aggregations))))
|
||||
}
|
||||
}
|
||||
for _, label := range s.Dimensions.ColorBy {
|
||||
if _, ok := keys.Groups[label]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.dimensions.colorBy: %q is not a group-by key of the panel's query; group-by keys: %s", path, label, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Groups))))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScatterPlotDimensions binds result columns to what a dot encodes. Value columns
|
||||
// use the Table column key, so formatting.columnUnits carries over between the
|
||||
// two kinds.
|
||||
type ScatterPlotDimensions struct {
|
||||
X string `json:"x" description:"Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column."`
|
||||
Y string `json:"y" description:"Value column key plotted on the y axis. Empty uses the second value column."`
|
||||
SizeBy string `json:"sizeBy" description:"Value column key that scales dot size. Empty draws every dot at the default size."`
|
||||
ColorBy []string `json:"colorBy" description:"Group-by label names (e.g. k8s.namespace.name) whose combined values colour dots and drive the legend. Empty colours by every group-by label."`
|
||||
}
|
||||
|
||||
type ScatterPlotAxes struct {
|
||||
X ScatterPlotAxis `json:"x"`
|
||||
Y ScatterPlotAxis `json:"y"`
|
||||
}
|
||||
|
||||
type ScatterPlotAxis struct {
|
||||
SoftMin *float64 `json:"softMin"`
|
||||
SoftMax *float64 `json:"softMax"`
|
||||
Scale AxisScale `json:"scale"`
|
||||
Label string `json:"label" description:"Axis title. Empty draws none."`
|
||||
}
|
||||
|
||||
type ScatterPlotChartAppearance struct {
|
||||
Points ScatterPlotPoints `json:"points"`
|
||||
}
|
||||
|
||||
// ScatterPlotPoints keeps the fixed size and the size range side by side so
|
||||
// binding or unbinding dimensions.sizeBy restores the other's last setting. Nil
|
||||
// fields resolve to the renderer default.
|
||||
type ScatterPlotPoints struct {
|
||||
Size *PointDiameter `json:"size" description:"Diameter of every dot when dimensions.sizeBy is unset."`
|
||||
MinSize *PointDiameter `json:"minSize" description:"Diameter of the smallest dot when dimensions.sizeBy is set."`
|
||||
MaxSize *PointDiameter `json:"maxSize" description:"Diameter of the largest dot when dimensions.sizeBy is set."`
|
||||
Opacity *PointOpacity `json:"opacity"`
|
||||
}
|
||||
|
||||
func (p *ScatterPlotPoints) UnmarshalJSON(data []byte) error {
|
||||
type alias ScatterPlotPoints
|
||||
var tmp alias
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid points")
|
||||
}
|
||||
*p = ScatterPlotPoints(tmp)
|
||||
return p.validate()
|
||||
}
|
||||
|
||||
func (p ScatterPlotPoints) validate() error {
|
||||
if p.MinSize != nil && p.MaxSize != nil && *p.MinSize > *p.MaxSize {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid points: minSize %d must not exceed maxSize %d", *p.MinSize, *p.MaxSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TextPresentation struct {
|
||||
TextAlign TextAlign `json:"textAlign"`
|
||||
VerticalAlign VerticalAlign `json:"verticalAlign"`
|
||||
@@ -410,6 +502,11 @@ type ComparisonThreshold struct {
|
||||
Format ThresholdFormat `json:"format"`
|
||||
}
|
||||
|
||||
type ScatterPlotThreshold struct {
|
||||
ThresholdWithLabel
|
||||
Axis ThresholdAxis `json:"axis"`
|
||||
}
|
||||
|
||||
type TableThreshold struct {
|
||||
ComparisonThreshold
|
||||
ColumnName string `json:"columnName" validate:"required" required:"true"`
|
||||
@@ -695,6 +792,86 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
// AxisScale `auto` leaves the choice to the renderer; `symlog` is log-like but
|
||||
// defined at and below zero.
|
||||
type AxisScale struct{ valuer.String }
|
||||
|
||||
var (
|
||||
AxisScaleAuto = AxisScale{valuer.NewString("auto")} // default
|
||||
AxisScaleLinear = AxisScale{valuer.NewString("linear")}
|
||||
AxisScaleLog = AxisScale{valuer.NewString("log")}
|
||||
AxisScaleSymlog = AxisScale{valuer.NewString("symlog")}
|
||||
)
|
||||
|
||||
func (AxisScale) Enum() []any {
|
||||
return []any{AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog}
|
||||
}
|
||||
|
||||
func (as AxisScale) ValueOrDefault() string {
|
||||
if as.IsZero() {
|
||||
return AxisScaleAuto.StringValue()
|
||||
}
|
||||
return as.StringValue()
|
||||
}
|
||||
|
||||
func (as AxisScale) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(as.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (as *AxisScale) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid axis scale: must be a string, one of `auto`, `linear`, `log`, or `symlog`")
|
||||
}
|
||||
val := AxisScale{valuer.NewString(v)}
|
||||
switch val {
|
||||
case AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog:
|
||||
*as = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid axis scale %q: must be `auto`, `linear`, `log`, or `symlog`", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ThresholdAxis is the axis a scatter plot threshold line marks: `y` draws it
|
||||
// horizontally, `x` vertically.
|
||||
type ThresholdAxis struct{ valuer.String }
|
||||
|
||||
var (
|
||||
ThresholdAxisX = ThresholdAxis{valuer.NewString("x")}
|
||||
ThresholdAxisY = ThresholdAxis{valuer.NewString("y")} // default
|
||||
)
|
||||
|
||||
func (ThresholdAxis) Enum() []any {
|
||||
return []any{ThresholdAxisX, ThresholdAxisY}
|
||||
}
|
||||
|
||||
func (ta ThresholdAxis) ValueOrDefault() string {
|
||||
if ta.IsZero() {
|
||||
return ThresholdAxisY.StringValue()
|
||||
}
|
||||
return ta.StringValue()
|
||||
}
|
||||
|
||||
func (ta ThresholdAxis) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(ta.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (ta *ThresholdAxis) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid threshold axis: must be a string, one of `x` or `y`")
|
||||
}
|
||||
val := ThresholdAxis{valuer.NewString(v)}
|
||||
switch val {
|
||||
case ThresholdAxisX, ThresholdAxisY:
|
||||
*ta = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid threshold axis %q: must be `x` or `y`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type FillMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
@@ -833,6 +1010,53 @@ func (o *FillOpacity) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
minPointDiameter = 2
|
||||
maxPointDiameter = 40
|
||||
)
|
||||
|
||||
// PointDiameter is a scatter dot's diameter in whole CSS pixels.
|
||||
type PointDiameter int
|
||||
|
||||
func (PointDiameter) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
s.WithMinimum(minPointDiameter).WithMaximum(maxPointDiameter)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *PointDiameter) UnmarshalJSON(data []byte) error {
|
||||
var v int
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point diameter: must be a whole number between %d and %d", minPointDiameter, maxPointDiameter)
|
||||
}
|
||||
if v < minPointDiameter || v > maxPointDiameter {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point diameter %d: must be a whole number between %d and %d", v, minPointDiameter, maxPointDiameter)
|
||||
}
|
||||
*d = PointDiameter(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
const minPointOpacity = 0.1
|
||||
|
||||
// PointOpacity is a scatter dot's fill alpha. The floor keeps a dot visible.
|
||||
type PointOpacity float64
|
||||
|
||||
func (PointOpacity) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
s.WithMinimum(minPointOpacity).WithMaximum(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *PointOpacity) UnmarshalJSON(data []byte) error {
|
||||
var v float64
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point opacity: must be a number between %v and 1", minPointOpacity)
|
||||
}
|
||||
if v < minPointOpacity || v > 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point opacity %v: must be between %v and 1", v, minPointOpacity)
|
||||
}
|
||||
*o = PointOpacity(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
type SpanGaps struct {
|
||||
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
|
||||
FillLessThan string `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`
|
||||
|
||||
134
pkg/types/dashboardtypes/testdata/perses_scatter_plot_panel.json
vendored
Normal file
134
pkg/types/dashboardtypes/testdata/perses_scatter_plot_panel.json
vendored
Normal file
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"display": {
|
||||
"name": "service health",
|
||||
"description": "throughput against tail latency per pod"
|
||||
},
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "throughput vs p99",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/ScatterPlotPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "last_1_hr"
|
||||
},
|
||||
"dimensions": {
|
||||
"x": "A.count()",
|
||||
"y": "A.p99(duration_nano)",
|
||||
"sizeBy": "A.countIf(has_error = true)",
|
||||
"colorBy": ["k8s.namespace.name", "k8s.pod.name"]
|
||||
},
|
||||
"formatting": {
|
||||
"columnUnits": {
|
||||
"A.p99(duration_nano)": "ns"
|
||||
},
|
||||
"decimalPrecision": "1"
|
||||
},
|
||||
"axes": {
|
||||
"x": {
|
||||
"softMin": 0,
|
||||
"softMax": null,
|
||||
"scale": "log",
|
||||
"label": "Throughput"
|
||||
},
|
||||
"y": {
|
||||
"softMin": null,
|
||||
"softMax": 1000,
|
||||
"scale": "symlog",
|
||||
"label": "p99 latency"
|
||||
}
|
||||
},
|
||||
"chartAppearance": {
|
||||
"points": {
|
||||
"size": 8,
|
||||
"minSize": 4,
|
||||
"maxSize": 24,
|
||||
"opacity": 0.1
|
||||
}
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"mode": "list",
|
||||
"customColors": {
|
||||
"checkout": "#1f77b4"
|
||||
}
|
||||
},
|
||||
"thresholds": [
|
||||
{
|
||||
"value": 300,
|
||||
"unit": "ms",
|
||||
"color": "#f00",
|
||||
"label": "p99 SLO",
|
||||
"axis": "y"
|
||||
},
|
||||
{
|
||||
"value": 50,
|
||||
"color": "#0f0",
|
||||
"axis": "x"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"queries": [
|
||||
{
|
||||
"kind": "scalar",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count()"
|
||||
},
|
||||
{
|
||||
"expression": "p99(duration_nano)"
|
||||
},
|
||||
{
|
||||
"expression": "countIf(has_error = true)"
|
||||
}
|
||||
],
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "k8s.namespace.name"
|
||||
},
|
||||
{
|
||||
"name": "k8s.pod.name"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 12,
|
||||
"height": 8,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/p1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -266,6 +266,43 @@ type CompositeQuery struct {
|
||||
Queries []QueryEnvelope `json:"queries"`
|
||||
}
|
||||
|
||||
// ResultColumnKeys is what a scalar query yields per row, keyed the way the
|
||||
// UI keys columns: an aggregation column is the query name, or
|
||||
// queryName.expression when the query has several aggregations; a group
|
||||
// column is a group-by key name.
|
||||
type ResultColumnKeys struct {
|
||||
Aggregations map[string]struct{}
|
||||
Groups map[string]struct{}
|
||||
}
|
||||
|
||||
// CollectResultColumnKeys reports false when a query's columns are only known
|
||||
// at run time, as for ClickHouse SQL and PromQL. A disabled query yields no
|
||||
// aggregation column but keeps its group columns, since a formula over it
|
||||
// still carries them.
|
||||
func (c *CompositeQuery) CollectResultColumnKeys() (ResultColumnKeys, bool) {
|
||||
keys := ResultColumnKeys{Aggregations: map[string]struct{}{}, Groups: map[string]struct{}{}}
|
||||
for _, envelope := range c.Queries {
|
||||
if envelope.GetType() == QueryTypeClickHouseSQL || envelope.GetType() == QueryTypePromQL {
|
||||
return ResultColumnKeys{}, false
|
||||
}
|
||||
for _, key := range envelope.GetGroupBy() {
|
||||
keys.Groups[key.Name] = struct{}{}
|
||||
}
|
||||
if envelope.IsDisabled() {
|
||||
continue
|
||||
}
|
||||
expressions := envelope.GetAggregationExpressions()
|
||||
if len(expressions) <= 1 {
|
||||
keys.Aggregations[envelope.GetQueryName()] = struct{}{}
|
||||
continue
|
||||
}
|
||||
for _, expression := range expressions {
|
||||
keys.Aggregations[envelope.GetQueryName()+"."+expression] = struct{}{}
|
||||
}
|
||||
}
|
||||
return keys, true
|
||||
}
|
||||
|
||||
// PrepareJSONSchema adds description to the CompositeQuery schema.
|
||||
func (c *CompositeQuery) PrepareJSONSchema(schema *jsonschema.Schema) error {
|
||||
schema.WithDescription("Composite query containing one or more query envelopes. Each query envelope specifies its type and corresponding spec.")
|
||||
|
||||
@@ -225,6 +225,34 @@ func (q *QueryEnvelope) GetGroupBy() []GroupByKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAggregationExpressions returns one entry per aggregation. A metric
|
||||
// aggregation has no expression, so it contributes an empty entry.
|
||||
func (q *QueryEnvelope) GetAggregationExpressions() []string {
|
||||
switch spec := q.Spec.(type) {
|
||||
case QueryBuilderTraceOperator:
|
||||
expressions := make([]string, len(spec.Aggregations))
|
||||
for i, aggregation := range spec.Aggregations {
|
||||
expressions[i] = aggregation.Expression
|
||||
}
|
||||
return expressions
|
||||
case QueryBuilderQuery[TraceAggregation]:
|
||||
expressions := make([]string, len(spec.Aggregations))
|
||||
for i, aggregation := range spec.Aggregations {
|
||||
expressions[i] = aggregation.Expression
|
||||
}
|
||||
return expressions
|
||||
case QueryBuilderQuery[LogAggregation]:
|
||||
expressions := make([]string, len(spec.Aggregations))
|
||||
for i, aggregation := range spec.Aggregations {
|
||||
expressions[i] = aggregation.Expression
|
||||
}
|
||||
return expressions
|
||||
case QueryBuilderQuery[MetricAggregation]:
|
||||
return make([]string, len(spec.Aggregations))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFilter returns the filter.
|
||||
func (q *QueryEnvelope) GetFilter() *Filter {
|
||||
switch spec := q.Spec.(type) {
|
||||
|
||||
@@ -1903,3 +1903,63 @@ func TestQueryRangeRequest_StepIntervalForQuery(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeQueryCollectResultColumnKeys(t *testing.T) {
|
||||
testCases := []struct {
|
||||
scenario string
|
||||
composite CompositeQuery
|
||||
expectedKeys ResultColumnKeys
|
||||
expectedKnown bool
|
||||
}{
|
||||
{
|
||||
scenario: "a single aggregation is keyed by the query name",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[TraceAggregation]{
|
||||
Name: "A",
|
||||
Aggregations: []TraceAggregation{{Expression: "count()"}},
|
||||
GroupBy: []GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
}}}},
|
||||
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"A": {}}, Groups: map[string]struct{}{"service.name": {}}},
|
||||
expectedKnown: true,
|
||||
},
|
||||
{
|
||||
scenario: "several aggregations are keyed by query name and expression",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[LogAggregation]{
|
||||
Name: "A",
|
||||
Aggregations: []LogAggregation{{Expression: "count()"}, {Expression: "p99(duration_nano)"}},
|
||||
}}}},
|
||||
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"A.count()": {}, "A.p99(duration_nano)": {}}, Groups: map[string]struct{}{}},
|
||||
expectedKnown: true,
|
||||
},
|
||||
{
|
||||
scenario: "a disabled query yields no aggregation but keeps its groups for the formula over it",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{
|
||||
{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[MetricAggregation]{
|
||||
Name: "A",
|
||||
Disabled: true,
|
||||
Aggregations: []MetricAggregation{{MetricName: "http.server.request.count"}},
|
||||
GroupBy: []GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
}},
|
||||
{Type: QueryTypeFormula, Spec: QueryBuilderFormula{Name: "F1", Expression: "A * 2"}},
|
||||
}},
|
||||
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"F1": {}}, Groups: map[string]struct{}{"service.name": {}}},
|
||||
expectedKnown: true,
|
||||
},
|
||||
{
|
||||
scenario: "clickhouse columns are only known at run time",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeClickHouseSQL, Spec: ClickHouseQuery{
|
||||
Name: "A",
|
||||
Query: "SELECT 1",
|
||||
}}}},
|
||||
expectedKeys: ResultColumnKeys{},
|
||||
expectedKnown: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.scenario, func(t *testing.T) {
|
||||
keys, known := testCase.composite.CollectResultColumnKeys()
|
||||
assert.Equal(t, testCase.expectedKnown, known)
|
||||
assert.Equal(t, testCase.expectedKeys, keys)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
76
tests/fixtures/notification_channel.py
vendored
76
tests/fixtures/notification_channel.py
vendored
@@ -306,82 +306,6 @@ 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
|
||||
|
||||
@@ -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-pushover-{uuid.uuid4().hex[:8]}"
|
||||
name = f"legacy-telegram-{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, "pushover_configs": [{"user_key": "pushover-user-key", "token": "pushover-token"}]})
|
||||
rewrite_channel_as_legacy_receiver(signoz, channel_id, {"name": name, "telegram_configs": [{"chat": 12345, "token": "telegram-bot-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"}],
|
||||
"pushover_configs": [{"user_key": "pushover-user-key", "token": "pushover-token"}],
|
||||
"telegram_configs": [{"chat": 12345, "token": "telegram-bot-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", ""]
|
||||
pushover_id = repair["channels"][1]["id"]
|
||||
cleanup_notification_channels.append(pushover_id)
|
||||
telegram_id = repair["channels"][1]["id"]
|
||||
cleanup_notification_channels.append(telegram_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}/{pushover_id}/repair"),
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{telegram_id}/repair"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
|
||||
@@ -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": "pushover"}, id="kind_outside_the_enum"),
|
||||
pytest.param({"kind": "telegram"}, id="kind_outside_the_enum"),
|
||||
pytest.param({"limit": -1}, id="negative_limit"),
|
||||
pytest.param({"offset": -1}, id="negative_offset"),
|
||||
],
|
||||
|
||||
@@ -75,7 +75,7 @@ def test_get_reflects_a_v2_update_after_a_v1_create(
|
||||
"receiver",
|
||||
[
|
||||
pytest.param(
|
||||
{"pushover_configs": [{"user_key": "pushover-user-key", "token": "pushover-token"}]},
|
||||
{"telegram_configs": [{"chat": 12345, "token": "telegram-bot-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-pushover-{uuid.uuid4().hex[:8]}"
|
||||
name = f"legacy-telegram-{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, "pushover_configs": [{"user_key": "pushover-user-key", "token": "pushover-token"}]})
|
||||
rewrite_channel_as_legacy_receiver(signoz, channel_id, {"name": name, "telegram_configs": [{"chat": 12345, "token": "telegram-bot-token"}]})
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
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"},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -1,100 +0,0 @@
|
||||
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 # telebot'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"}, "telegram: chat not found (400)"),
|
||||
TestChannelCase("unauthorized_401", "tg-tc-401", 401, {"ok": False, "error_code": 401, "description": "Unauthorized"}, "telegram: Unauthorized (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 telebot's error; pin it to distinguish 400 vs 401
|
||||
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}"
|
||||
|
||||
# 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']}"
|
||||
Reference in New Issue
Block a user