mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-19 11:20:39 +01:00
Compare commits
14 Commits
ns/scope-q
...
feat/googl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5367f39b61 | ||
|
|
a86b20925b | ||
|
|
8f89a1abf5 | ||
|
|
7ecf6f4b60 | ||
|
|
709b1a6745 | ||
|
|
b11a633331 | ||
|
|
e12d6f7a42 | ||
|
|
aefe6e76ab | ||
|
|
d27f94abce | ||
|
|
7774544809 | ||
|
|
2f48fc8ef8 | ||
|
|
946058210b | ||
|
|
113943771a | ||
|
|
3c517e5bde |
@@ -18,9 +18,10 @@ jest.mock('periscope/components/DataViewer', () => ({
|
||||
DataViewer: (): JSX.Element => <div data-testid="overview-data-viewer" />,
|
||||
}));
|
||||
|
||||
// Force v2 for these tests regardless of route.
|
||||
jest.mock('../useIsLogDetailsV2', () => ({
|
||||
useIsLogDetailsV2: (): boolean => true,
|
||||
// The flag to be removed later
|
||||
jest.mock('../constants', () => ({
|
||||
...jest.requireActual('../constants'),
|
||||
isLogDetailsV2: true,
|
||||
}));
|
||||
|
||||
const mockLog: ILog = {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// temporary flag to be removed with old log details code.
|
||||
export const isLogDetailsV2 = true;
|
||||
|
||||
export const VIEW_TYPES = {
|
||||
OVERVIEW: 'OVERVIEW',
|
||||
JSON: 'JSON',
|
||||
|
||||
@@ -51,12 +51,11 @@ import { ILogBody } from 'types/api/logs/log';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import { RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
|
||||
import { isLogDetailsV2, RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
|
||||
import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
|
||||
import LogDetailsHeader from './LogDetailsHeader/LogDetailsHeader';
|
||||
import { useLogNavigation } from './LogDetailsHeader/useLogNavigation';
|
||||
import LogHighlights from './LogHighlights/LogHighlights';
|
||||
import { useIsLogDetailsV2 } from './useIsLogDetailsV2';
|
||||
|
||||
import './LogDetails.styles.scss';
|
||||
|
||||
@@ -93,8 +92,6 @@ function LogDetailInner({
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
|
||||
const isLogDetailsV2 = useIsLogDetailsV2();
|
||||
|
||||
// Handle clicks outside to close drawer, except on explicitly ignored regions
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent): void => {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
// v2 is rolled out only on the logs explorer route for now; every other surface
|
||||
// (dashboards, infra monitoring, etc.) keeps the v1 log details view.
|
||||
export function useIsLogDetailsV2(): boolean {
|
||||
const { pathname } = useLocation();
|
||||
return pathname === ROUTES.LOGS_EXPLORER;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { ChevronDown, ChevronRight, Search } from '@signozhq/icons';
|
||||
import { useIsLogDetailsV2 } from 'components/LogDetail/useIsLogDetailsV2';
|
||||
import { isLogDetailsV2 } from 'components/LogDetail/constants';
|
||||
import { DataViewer } from 'periscope/components/DataViewer';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
@@ -69,8 +69,6 @@ function Overview({
|
||||
isListViewPanel,
|
||||
});
|
||||
|
||||
const isLogDetailsV2 = useIsLogDetailsV2();
|
||||
|
||||
if (isLogDetailsV2) {
|
||||
const raw = aggregateAttributesResourcesToObject(logData);
|
||||
const prettyData = buildPrettyViewData(raw);
|
||||
|
||||
@@ -51,28 +51,6 @@
|
||||
},
|
||||
"name": "Region"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "FunctionName",
|
||||
"description": "Name of the Lambda function"
|
||||
},
|
||||
"allowAllValue": true,
|
||||
"allowMultiple": true,
|
||||
"customAllValue": "",
|
||||
"capturingRegexp": "",
|
||||
"sort": "none",
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {
|
||||
"name": "FunctionName",
|
||||
"signal": "metrics"
|
||||
}
|
||||
},
|
||||
"name": "FunctionName"
|
||||
}
|
||||
}
|
||||
],
|
||||
"panels": {
|
||||
@@ -140,7 +118,7 @@
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS)"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
@@ -240,7 +218,7 @@
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS)"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
@@ -340,7 +318,7 @@
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS)"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
@@ -440,7 +418,7 @@
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS)"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
@@ -540,7 +518,7 @@
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS)"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
@@ -640,7 +618,7 @@
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS)"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
@@ -740,7 +718,7 @@
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS)"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
@@ -853,4 +831,4 @@
|
||||
"refreshInterval": "",
|
||||
"links": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,7 +242,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewRestructureAuthDomainConfigFactory(sqlstore),
|
||||
sqlmigration.NewFixSavedViewSelectFieldsFactory(sqlstore),
|
||||
sqlmigration.NewDeleteOrphanUserRolesFactory(),
|
||||
sqlmigration.NewMigrateLambdaDashboardsFactory(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
//go:embed 116_migrate_lambda_dashboards
|
||||
var lambdaDashboardFiles embed.FS
|
||||
|
||||
// These values mirror the cloud integration and dashboard packages but are duplicated
|
||||
// here so this migration keeps targeting and writing the same rows even if those
|
||||
// constants are later renamed or changed.
|
||||
const (
|
||||
lambdaDashboardFile = "116_migrate_lambda_dashboards/aws/lambda/overview.json"
|
||||
|
||||
lambdaDashboardSlug = "aws-lambda-overview"
|
||||
cloudIntegrationDashboardProvider = "cloud_integration"
|
||||
integrationDashboardSource = "integration"
|
||||
dashboardSchemaVersion = "v6"
|
||||
)
|
||||
|
||||
type migrateLambdaDashboards struct{}
|
||||
|
||||
type lambdaDashboardRow struct {
|
||||
bun.BaseModel `bun:"table:dashboard,alias:dashboard"`
|
||||
|
||||
ID string `bun:"id"`
|
||||
Data string `bun:"data"`
|
||||
}
|
||||
|
||||
// lambdaDashboardDefinition is the part of the embedded dashboard this migration reads:
|
||||
// its spec, which is what the cloud integration stores under data.spec.
|
||||
type lambdaDashboardDefinition struct {
|
||||
Spec map[string]any `json:"spec"`
|
||||
}
|
||||
|
||||
func NewMigrateLambdaDashboardsFactory() factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("migrate_lambda_dashboards"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &migrateLambdaDashboards{}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (m *migrateLambdaDashboards) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(m.Up, m.Down)
|
||||
}
|
||||
|
||||
// Up rewrites the spec of every provisioned AWS Lambda overview dashboard to the
|
||||
// embedded revision that added the FunctionName variable. Cloud integration dashboards
|
||||
// are provisioned once and never updated afterwards, so existing installs only pick up
|
||||
// this change through a migration. Only the spec is replaced; the row keeps its id, name,
|
||||
// tags and metadata, so the dashboard is updated in place rather than recreated.
|
||||
func (m *migrateLambdaDashboards) Up(ctx context.Context, db *bun.DB) error {
|
||||
spec, err := m.loadSpec()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var rows []*lambdaDashboardRow
|
||||
if err := tx.NewSelect().
|
||||
Model(&rows).
|
||||
Join("JOIN integration_dashboard AS id ON id.dashboard_id = dashboard.id").
|
||||
Where("id.provider = ?", cloudIntegrationDashboardProvider).
|
||||
Where("id.slug = ?", lambdaDashboardSlug).
|
||||
Where("dashboard.source = ?", integrationDashboardSource).
|
||||
Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
data := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(row.Data), &data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The embedded spec is v6-shaped, so only rewrite a row already carrying a v6 spec;
|
||||
// anything else is left alone rather than turned into a broken mix of versions.
|
||||
if !m.hasV6Spec(data) {
|
||||
continue
|
||||
}
|
||||
data["spec"] = spec
|
||||
|
||||
encoded, err := m.marshalUnescaped(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Skip rows already carrying this spec so a re-run does not needlessly rewrite them.
|
||||
if string(encoded) == row.Data {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := tx.NewUpdate().
|
||||
Model((*lambdaDashboardRow)(nil)).
|
||||
Set("data = ?", string(encoded)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("id = ?", row.ID).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (m *migrateLambdaDashboards) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasV6Spec reports whether the stored data is a v6 dashboard with a spec object, which
|
||||
// is the shape whose spec this migration replaces.
|
||||
func (m *migrateLambdaDashboards) hasV6Spec(data map[string]any) bool {
|
||||
metadata, _ := data["metadata"].(map[string]any)
|
||||
version, _ := metadata["schemaVersion"].(string)
|
||||
if version != dashboardSchemaVersion {
|
||||
return false
|
||||
}
|
||||
_, ok := data["spec"].(map[string]any)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (m *migrateLambdaDashboards) marshalUnescaped(v any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
encoder := json.NewEncoder(&buf)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bytes.TrimRight(buf.Bytes(), "\n"), nil
|
||||
}
|
||||
|
||||
func (m *migrateLambdaDashboards) loadSpec() (map[string]any, error) {
|
||||
raw, err := lambdaDashboardFiles.ReadFile(lambdaDashboardFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var dashboard lambdaDashboardDefinition
|
||||
if err := json.Unmarshal(raw, &dashboard); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dashboard.Spec, nil
|
||||
}
|
||||
@@ -1,856 +0,0 @@
|
||||
{
|
||||
"schemaVersion": "v6",
|
||||
"image": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAwcHgiIGhlaWdodD0iODAwcHgiIHZpZXdCb3g9IjAgMCAxNiAxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiBmaWxsPSJub25lIj48cGF0aCBmaWxsPSIjRkE3RTE0IiBkPSJNNy45ODMgOC4zN2MtLjA1My4wNzMtLjA5OC4xMzMtLjE0MS4xOTRMNS43NzUgMTEuNWMtLjY0LjkxLTEuMjgyIDEuODItMS45MjQgMi43M2EuMTI4LjEyOCAwIDAxLS4wOTIuMDUxYy0uOTA2LS4wMDctMS44MTMtLjAxNy0yLjcxOS0uMDI4LS4wMSAwLS4wMi0uMDAzLS4wNC0uMDA2YS40NTUuNDU1IDAgMDEuMDI1LS4wNTMgMTM5NzcuNDk2IDEzOTc3LjQ5NiAwIDAxNS40NDYtOC4xNDZjLjA5Mi0uMTM4LjE4OC0uMjczLjI3NS0uNDEzYS4xNjUuMTY1IDAgMDAuMDE4LS4xMjRjLS4xNjctLjUxNS0uMzM4LTEuMDMtLjUwOC0xLjU0My0uMDczLS4yMi0uMTUtLjQ0LS4yMTgtLjY2LS4wMjItLjA3Mi0uMDU5LS4wOTQtLjEzNC0uMDkzLS41Ny4wMDItMS4xMzYuMDAxLTEuNzA0LjAwMS0uMTA4IDAtLjEwOCAwLS4xMDgtLjEwMyAwLS42NzQgMC0xLjM0Ny0uMDAyLTIuMDIxIDAtLjA3NS4wMjYtLjA5Mi4wOTktLjA5MiAxLjE0My4wMDIgMi4yODYuMDAyIDMuNDMgMGEuMTEzLjExMyAwIDAxLjA3Ni4wMTcuMTA3LjEwNyAwIDAxLjA0NS4wNjEgMTgyNjYuMTg0IDE4MjY2LjE4NCAwIDAwMy45MiA5LjUxYy4yMTguNTMuNDM4IDEuMDU5LjY1NCAxLjU5LjAyNi4wNjQuMDUzLjA3Ni4xMi4wNTYuNi0uMTc4IDEuMi0uMzUyIDEuOC0uNTMxLjA3NS0uMDIzLjEwMi0uMDA4LjEyNi4wNjQuMjA0LjYyLjQxMiAxLjIzOS42MiAxLjg1OGwuMDIuMDczYy0uMDQzLjAxNS0uMDgzLjAzMi0uMTI0LjA0M2wtNC4wODUgMS4yNWMtLjA2NS4wMi0uMDg1IDAtLjEwNi0uMDU0bC0xLjI1LTMuMDQ4LTEuMjI2LTIuOTg0LS4xODMtLjQ0OWMtLjAxLS4wMjYtLjAyMy0uMDQ4LS4wNDMtLjA4N3oiLz48L3N2Zz4=",
|
||||
"name": "",
|
||||
"generateName": true,
|
||||
"tags": [],
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "AWS Lambda Overview",
|
||||
"description": "Overview of AWS Lambda functions"
|
||||
},
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "Account",
|
||||
"description": "AWS Account"
|
||||
},
|
||||
"allowAllValue": false,
|
||||
"allowMultiple": false,
|
||||
"customAllValue": "",
|
||||
"capturingRegexp": "",
|
||||
"sort": "none",
|
||||
"plugin": {
|
||||
"kind": "signoz/QueryVariable",
|
||||
"spec": {
|
||||
"queryValue": "SELECT JSONExtractString(labels, 'cloud.account.id') as `cloud.account.id`\nFROM signoz_metrics.distributed_time_series_v4_1day\nWHERE \n metric_name like 'aws_Lambda_Invocations_sum'\nGROUP BY `cloud.account.id`\n\n"
|
||||
}
|
||||
},
|
||||
"name": "Account"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "Region",
|
||||
"description": "AWS Region"
|
||||
},
|
||||
"allowAllValue": false,
|
||||
"allowMultiple": false,
|
||||
"customAllValue": "",
|
||||
"capturingRegexp": "",
|
||||
"sort": "none",
|
||||
"plugin": {
|
||||
"kind": "signoz/QueryVariable",
|
||||
"spec": {
|
||||
"queryValue": "SELECT JSONExtractString(labels, 'cloud.region') as `cloud.region`\nFROM signoz_metrics.distributed_time_series_v4_1day\nWHERE \n metric_name like 'aws_Lambda_Invocations_sum'\n and JSONExtractString(labels, 'cloud.account.id') IN {{.Account}}\nGROUP BY `cloud.region`\n"
|
||||
}
|
||||
},
|
||||
"name": "Region"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "FunctionName",
|
||||
"description": "Name of the Lambda function"
|
||||
},
|
||||
"allowAllValue": true,
|
||||
"allowMultiple": true,
|
||||
"customAllValue": "",
|
||||
"capturingRegexp": "",
|
||||
"sort": "none",
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {
|
||||
"name": "FunctionName",
|
||||
"signal": "metrics"
|
||||
}
|
||||
},
|
||||
"name": "FunctionName"
|
||||
}
|
||||
}
|
||||
],
|
||||
"panels": {
|
||||
"2516c785-b025-49b3-aeb4-a4735ccb2709": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "Errors",
|
||||
"description": "The number of invocations that result in a function error. Function errors include exceptions that your code throws and exceptions that the Lambda runtime throws. The runtime returns errors for issues such as timeouts and configuration errors. To calculate the error rate, divide the value of Errors by the value of Invocations. Note that the timestamp on an error metric reflects when the function was invoked, not when the error occurred.\n\nSee more at https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics-types.html"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time",
|
||||
"fillSpans": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "none",
|
||||
"decimalPrecision": "2"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"lineInterpolation": "spline",
|
||||
"showPoints": false,
|
||||
"lineStyle": "solid",
|
||||
"fillMode": "none",
|
||||
"spanGaps": {
|
||||
"fillOnlyBelow": false,
|
||||
"fillLessThan": ""
|
||||
}
|
||||
},
|
||||
"axes": {
|
||||
"softMin": 0,
|
||||
"softMax": 0,
|
||||
"isLogScale": false
|
||||
},
|
||||
"legend": {
|
||||
"position": "bottom",
|
||||
"mode": "list",
|
||||
"customColors": null
|
||||
},
|
||||
"thresholds": null
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"stepInterval": 60,
|
||||
"signal": "metrics",
|
||||
"source": "",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "aws_Lambda_Errors_sum",
|
||||
"temporality": "",
|
||||
"timeAggregation": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "avg"
|
||||
}
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "cloud.account.id",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "cloud.region",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "FunctionName",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"order": [],
|
||||
"having": {
|
||||
"expression": ""
|
||||
},
|
||||
"functions": [],
|
||||
"legend": "{{FunctionName}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
"4119a1e5-32a8-4859-96e9-a5451114782b": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "Async events dropped",
|
||||
"description": "The number of events that are dropped without successfully executing the function. If you configure a dead-letter queue (DLQ) or OnFailure destination, then events are sent there before they're dropped. Events are dropped for various reasons. For example, events can exceed the maximum event age or exhaust the maximum retry attempts, or reserved concurrency might be set to 0. To troubleshoot why events are dropped, look at the Errors metric to identify function errors and the Throttles metric to identify concurrency issues.\n\nSee more at https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics-types.html"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time",
|
||||
"fillSpans": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "none",
|
||||
"decimalPrecision": "2"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"lineInterpolation": "spline",
|
||||
"showPoints": false,
|
||||
"lineStyle": "solid",
|
||||
"fillMode": "none",
|
||||
"spanGaps": {
|
||||
"fillOnlyBelow": false,
|
||||
"fillLessThan": ""
|
||||
}
|
||||
},
|
||||
"axes": {
|
||||
"softMin": 0,
|
||||
"softMax": 0,
|
||||
"isLogScale": false
|
||||
},
|
||||
"legend": {
|
||||
"position": "bottom",
|
||||
"mode": "list",
|
||||
"customColors": null
|
||||
},
|
||||
"thresholds": null
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"stepInterval": 60,
|
||||
"signal": "metrics",
|
||||
"source": "",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "aws_Lambda_AsyncEventsDropped_sum",
|
||||
"temporality": "",
|
||||
"timeAggregation": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "avg"
|
||||
}
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "cloud.account.id",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "cloud.region",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "FunctionName",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"order": [],
|
||||
"having": {
|
||||
"expression": ""
|
||||
},
|
||||
"functions": [],
|
||||
"legend": "{{FunctionName}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
"6354ea62-e82b-4323-a33d-eef92519e843": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "Throttles",
|
||||
"description": "The number of invocation requests that are throttled. When all function instances are processing requests and no concurrency is available to scale up, Lambda rejects additional requests with a TooManyRequestsException error. Throttled requests and other invocation errors don't count as either Invocations or Errors.\n\nSee more at https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics-types.html"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time",
|
||||
"fillSpans": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "none",
|
||||
"decimalPrecision": "2"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"lineInterpolation": "spline",
|
||||
"showPoints": false,
|
||||
"lineStyle": "solid",
|
||||
"fillMode": "none",
|
||||
"spanGaps": {
|
||||
"fillOnlyBelow": false,
|
||||
"fillLessThan": ""
|
||||
}
|
||||
},
|
||||
"axes": {
|
||||
"softMin": 0,
|
||||
"softMax": 0,
|
||||
"isLogScale": false
|
||||
},
|
||||
"legend": {
|
||||
"position": "bottom",
|
||||
"mode": "list",
|
||||
"customColors": null
|
||||
},
|
||||
"thresholds": null
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"stepInterval": 60,
|
||||
"signal": "metrics",
|
||||
"source": "",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "aws_Lambda_Throttles_sum",
|
||||
"temporality": "",
|
||||
"timeAggregation": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "avg"
|
||||
}
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "cloud.account.id",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "cloud.region",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "FunctionName",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"order": [],
|
||||
"having": {
|
||||
"expression": ""
|
||||
},
|
||||
"functions": [],
|
||||
"legend": "{{FunctionName}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
"853d3a92-b396-4064-8762-18d7487989e0": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "Async events received",
|
||||
"description": "The number of events that Lambda successfully queues for processing. This metric provides insight into the number of events that a Lambda function receives. Monitor this metric and set alarms for thresholds to check for issues. For example, to detect an undesirable number of events sent to Lambda, and to quickly diagnose issues resulting from incorrect trigger or function configurations. Mismatches between AsyncEventsReceived and Invocations can indicate a disparity in processing, events being dropped, or a potential queue backlog.\n\nSee more at https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics-types.html"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time",
|
||||
"fillSpans": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "none",
|
||||
"decimalPrecision": "2"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"lineInterpolation": "spline",
|
||||
"showPoints": false,
|
||||
"lineStyle": "solid",
|
||||
"fillMode": "none",
|
||||
"spanGaps": {
|
||||
"fillOnlyBelow": false,
|
||||
"fillLessThan": ""
|
||||
}
|
||||
},
|
||||
"axes": {
|
||||
"softMin": 0,
|
||||
"softMax": 0,
|
||||
"isLogScale": false
|
||||
},
|
||||
"legend": {
|
||||
"position": "bottom",
|
||||
"mode": "list",
|
||||
"customColors": null
|
||||
},
|
||||
"thresholds": null
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"stepInterval": 60,
|
||||
"signal": "metrics",
|
||||
"source": "",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "aws_Lambda_AsyncEventsReceived_sum",
|
||||
"temporality": "",
|
||||
"timeAggregation": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "avg"
|
||||
}
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "cloud.account.id",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "cloud.region",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "FunctionName",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"order": [],
|
||||
"having": {
|
||||
"expression": ""
|
||||
},
|
||||
"functions": [],
|
||||
"legend": "{{FunctionName}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
"877bb5c8-331c-492f-b666-2054c2ae39bd": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "Invocations",
|
||||
"description": "The number of times that your function code is invoked, including successful invocations and invocations that result in a function error. Invocations aren't recorded if the invocation request is throttled or otherwise results in an invocation error. The value of Invocations equals the number of requests billed.\n\nSee more at https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics-types.html"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time",
|
||||
"fillSpans": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "none",
|
||||
"decimalPrecision": "2"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"lineInterpolation": "spline",
|
||||
"showPoints": false,
|
||||
"lineStyle": "solid",
|
||||
"fillMode": "none",
|
||||
"spanGaps": {
|
||||
"fillOnlyBelow": false,
|
||||
"fillLessThan": ""
|
||||
}
|
||||
},
|
||||
"axes": {
|
||||
"softMin": 0,
|
||||
"softMax": 0,
|
||||
"isLogScale": false
|
||||
},
|
||||
"legend": {
|
||||
"position": "bottom",
|
||||
"mode": "list",
|
||||
"customColors": null
|
||||
},
|
||||
"thresholds": null
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"stepInterval": 60,
|
||||
"signal": "metrics",
|
||||
"source": "",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "aws_Lambda_Invocations_sum",
|
||||
"temporality": "",
|
||||
"timeAggregation": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "avg"
|
||||
}
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "cloud.account.id",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "cloud.region",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "FunctionName",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"order": [],
|
||||
"having": {
|
||||
"expression": ""
|
||||
},
|
||||
"functions": [],
|
||||
"legend": "{{FunctionName}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
"ae6d7c81-d921-4d4c-95ec-6b42d900ea45": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "Max Async Event Age",
|
||||
"description": "The time between when Lambda successfully queues the event and when the function is invoked. The value of this metric increases when events are being retried due to invocation failures or throttling. Monitor this metric and set alarms for thresholds on different statistics for when a queue buildup occurs. To troubleshoot an increase in this metric, look at the Errors metric to identify function errors and the Throttles metric to identify concurrency issues.\n\nSee more at https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics-types.html"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time",
|
||||
"fillSpans": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "ms",
|
||||
"decimalPrecision": "2"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"lineInterpolation": "spline",
|
||||
"showPoints": false,
|
||||
"lineStyle": "solid",
|
||||
"fillMode": "none",
|
||||
"spanGaps": {
|
||||
"fillOnlyBelow": false,
|
||||
"fillLessThan": ""
|
||||
}
|
||||
},
|
||||
"axes": {
|
||||
"softMin": 0,
|
||||
"softMax": 0,
|
||||
"isLogScale": false
|
||||
},
|
||||
"legend": {
|
||||
"position": "bottom",
|
||||
"mode": "list",
|
||||
"customColors": null
|
||||
},
|
||||
"thresholds": null
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"stepInterval": 60,
|
||||
"signal": "metrics",
|
||||
"source": "",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "aws_Lambda_AsyncEventAge_max",
|
||||
"temporality": "",
|
||||
"timeAggregation": "max",
|
||||
"spaceAggregation": "max",
|
||||
"reduceTo": "avg"
|
||||
}
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "cloud.account.id",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "cloud.region",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "FunctionName",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"order": [],
|
||||
"having": {
|
||||
"expression": ""
|
||||
},
|
||||
"functions": [],
|
||||
"legend": "{{FunctionName}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
"b038520d-0756-4e46-a915-12a2f19a0254": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "Max Duration",
|
||||
"description": "The amount of time that your function code spends processing an event. The billed duration for an invocation is the value of Duration rounded up to the nearest millisecond. Duration does not include cold start time.\n\nSee more at https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics-types.html"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time",
|
||||
"fillSpans": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "ms",
|
||||
"decimalPrecision": "2"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"lineInterpolation": "spline",
|
||||
"showPoints": false,
|
||||
"lineStyle": "solid",
|
||||
"fillMode": "none",
|
||||
"spanGaps": {
|
||||
"fillOnlyBelow": false,
|
||||
"fillLessThan": ""
|
||||
}
|
||||
},
|
||||
"axes": {
|
||||
"softMin": 0,
|
||||
"softMax": 0,
|
||||
"isLogScale": false
|
||||
},
|
||||
"legend": {
|
||||
"position": "bottom",
|
||||
"mode": "list",
|
||||
"customColors": null
|
||||
},
|
||||
"thresholds": null
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"stepInterval": 60,
|
||||
"signal": "metrics",
|
||||
"source": "",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "aws_Lambda_Duration_max",
|
||||
"temporality": "",
|
||||
"timeAggregation": "max",
|
||||
"spaceAggregation": "max",
|
||||
"reduceTo": "avg"
|
||||
}
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "cloud.account.id",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "cloud.region",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "FunctionName",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"order": [],
|
||||
"having": {
|
||||
"expression": ""
|
||||
},
|
||||
"functions": [],
|
||||
"legend": "{{FunctionName}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/877bb5c8-331c-492f-b666-2054c2ae39bd"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/b038520d-0756-4e46-a915-12a2f19a0254"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 6,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/2516c785-b025-49b3-aeb4-a4735ccb2709"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 6,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/6354ea62-e82b-4323-a33d-eef92519e843"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 12,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/853d3a92-b396-4064-8762-18d7487989e0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 12,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/ae6d7c81-d921-4d4c-95ec-6b42d900ea45"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 18,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/4119a1e5-32a8-4859-96e9-a5451114782b"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"duration": "",
|
||||
"refreshInterval": "",
|
||||
"links": []
|
||||
}
|
||||
}
|
||||
@@ -262,14 +262,6 @@ func adjustTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, query
|
||||
// adjustTraceKey resolves a single TelemetryFieldKey against the keys map.
|
||||
func adjustTraceKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []string {
|
||||
|
||||
// Scope keys are resolved entirely by the field mapper's scope handling. The intrinsic
|
||||
// and calculated field tables are all span-context, so matching a scope key against them
|
||||
// by name alone would wrongly rewrite e.g. {name, scope} to the span `name` column.
|
||||
// Skip the intrinsic override and let resolution keep the key in scope.
|
||||
if key.FieldContext == telemetrytypes.FieldContextScope {
|
||||
return querybuilder.AdjustKey(key, keys, nil)
|
||||
}
|
||||
|
||||
// for recording actions taken
|
||||
actions := []string{}
|
||||
/*
|
||||
|
||||
@@ -675,64 +675,6 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "List query selecting and filtering scope fields",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "scope.name = 'otelcol'",
|
||||
},
|
||||
Limit: 10,
|
||||
SelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{
|
||||
Name: "scope.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
{
|
||||
Name: "telemetry.sdk.language",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.name::String <> '', scope.name::String, NULL) AS `__SELECT_KEY_3_scope.name`, multiIf(scope.attributes.`telemetry.sdk.language` IS NOT NULL, scope.attributes.`telemetry.sdk.language`::String, NULL) AS `__SELECT_KEY_4_telemetry.sdk.language` FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.name::String = ? AND scope.name::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"otelcol", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
// Short scope names (`name`/`version`) collide with span intrinsics; adjustTraceKeys
|
||||
// must keep them in scope and resolve the declared paths, not the span `name` column.
|
||||
name: "List query selecting short scope declared names",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Limit: 10,
|
||||
SelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{
|
||||
Name: "name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
{
|
||||
Name: "version",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.name::String <> '', scope.name::String, NULL) AS `__SELECT_KEY_3_scope.name`, multiIf(scope.version::String <> '', scope.version::String, NULL) AS `__SELECT_KEY_4_scope.version` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
}
|
||||
|
||||
fl := flaggertest.New(t)
|
||||
|
||||
@@ -180,7 +180,7 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
|
||||
`CASE
|
||||
// WHEN tagType = 'spanfield' THEN 1
|
||||
WHEN tagType = 'resource' THEN 2
|
||||
WHEN tagType = 'scope' THEN 3
|
||||
// WHEN tagType = 'scope' THEN 3
|
||||
WHEN tagType = 'tag' THEN 4
|
||||
ELSE 5
|
||||
END as priority`,
|
||||
|
||||
@@ -585,97 +585,3 @@ func TestConditionForSynthesizedKeys(t *testing.T) {
|
||||
assert.NotContains(t, sql, "mapContains")
|
||||
})
|
||||
}
|
||||
|
||||
// TestConditionForScope covers filters on the scope JSON column: declared paths, scope
|
||||
// attributes, exists semantics, and the attribute-first union when a scope attribute
|
||||
// shares a declared path's name.
|
||||
func TestConditionForScope(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fm := NewFieldMapper(flaggertest.New(t))
|
||||
cb := NewConditionBuilder(fm, flaggertest.New(t))
|
||||
|
||||
scopeName := IntrinsicFields["scope.name"]
|
||||
declared := map[string][]*telemetrytypes.TelemetryFieldKey{"scope.name": {&scopeName}}
|
||||
|
||||
build := func(key telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOperator, value any) (string, []any) {
|
||||
t.Helper()
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
conds, _, err := cb.ConditionFor(ctx, valuer.UUID{}, 0, 0, &key, keys, qbtypes.ConditionBuilderOptions{}, op, value, sb)
|
||||
require.NoError(t, err)
|
||||
sb.Where(sb.Or(conds...))
|
||||
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
}
|
||||
|
||||
t.Run("declared scope.name equality is exists-guarded", func(t *testing.T) {
|
||||
key := telemetrytypes.TelemetryFieldKey{Name: "name", FieldContext: telemetrytypes.FieldContextScope}
|
||||
sql, args := build(key, declared, qbtypes.FilterOperatorEqual, "otelcol")
|
||||
assert.Contains(t, sql, "scope.name::String = ?")
|
||||
assert.Contains(t, sql, "scope.name::String <> ''")
|
||||
assert.Contains(t, args, "otelcol")
|
||||
})
|
||||
|
||||
t.Run("declared scope.name exists", func(t *testing.T) {
|
||||
key := telemetrytypes.TelemetryFieldKey{Name: "name", FieldContext: telemetrytypes.FieldContextScope}
|
||||
sql, _ := build(key, declared, qbtypes.FilterOperatorExists, nil)
|
||||
assert.Contains(t, sql, "scope.name::String <> ''")
|
||||
})
|
||||
|
||||
t.Run("scope attribute equality guards the raw JSON path", func(t *testing.T) {
|
||||
key := telemetrytypes.TelemetryFieldKey{Name: "telemetry.sdk.language", FieldContext: telemetrytypes.FieldContextScope}
|
||||
keys := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"telemetry.sdk.language": {{Name: "telemetry.sdk.language", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextScope, FieldDataType: telemetrytypes.FieldDataTypeString}},
|
||||
}
|
||||
sql, args := build(key, keys, qbtypes.FilterOperatorEqual, "python")
|
||||
assert.Contains(t, sql, "scope.attributes.`telemetry.sdk.language`::String = ?")
|
||||
assert.Contains(t, sql, "scope.attributes.`telemetry.sdk.language` IS NOT NULL")
|
||||
assert.Contains(t, args, "python")
|
||||
assert.NotContains(t, sql, "scope.`scope.")
|
||||
})
|
||||
|
||||
t.Run("short name unions attribute and declared path", func(t *testing.T) {
|
||||
key := telemetrytypes.TelemetryFieldKey{Name: "name", FieldContext: telemetrytypes.FieldContextScope}
|
||||
keys := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"scope.name": {&scopeName},
|
||||
"name": {{Name: "name", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextScope, FieldDataType: telemetrytypes.FieldDataTypeString}},
|
||||
}
|
||||
sql, _ := build(key, keys, qbtypes.FilterOperatorEqual, "x")
|
||||
assert.Contains(t, sql, "scope.attributes.`name`::String = ?")
|
||||
assert.Contains(t, sql, "scope.name::String = ?")
|
||||
})
|
||||
|
||||
t.Run("declared scope.version equality", func(t *testing.T) {
|
||||
scopeVersion := IntrinsicFields["scope.version"]
|
||||
key := telemetrytypes.TelemetryFieldKey{Name: "version", FieldContext: telemetrytypes.FieldContextScope}
|
||||
keys := map[string][]*telemetrytypes.TelemetryFieldKey{"scope.version": {&scopeVersion}}
|
||||
sql, args := build(key, keys, qbtypes.FilterOperatorEqual, "1.2.3")
|
||||
assert.Contains(t, sql, "scope.version::String = ?")
|
||||
assert.Contains(t, sql, "scope.version::String <> ''")
|
||||
assert.Contains(t, args, "1.2.3")
|
||||
})
|
||||
|
||||
t.Run("negative operator on declared path does not add existence guard", func(t *testing.T) {
|
||||
key := telemetrytypes.TelemetryFieldKey{Name: "name", FieldContext: telemetrytypes.FieldContextScope}
|
||||
sql, _ := build(key, declared, qbtypes.FilterOperatorNotEqual, "otelcol")
|
||||
assert.Contains(t, sql, "scope.name::String <> ?")
|
||||
assert.NotContains(t, sql, "= ''")
|
||||
})
|
||||
|
||||
t.Run("IN on a scope attribute", func(t *testing.T) {
|
||||
key := telemetrytypes.TelemetryFieldKey{Name: "telemetry.sdk.language", FieldContext: telemetrytypes.FieldContextScope}
|
||||
keys := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"telemetry.sdk.language": {{Name: "telemetry.sdk.language", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextScope, FieldDataType: telemetrytypes.FieldDataTypeString}},
|
||||
}
|
||||
sql, _ := build(key, keys, qbtypes.FilterOperatorIn, []any{"python", "go"})
|
||||
assert.Contains(t, sql, "scope.attributes.`telemetry.sdk.language`::String = ?")
|
||||
assert.Contains(t, sql, "scope.attributes.`telemetry.sdk.language` IS NOT NULL")
|
||||
})
|
||||
|
||||
t.Run("numeric operand on a scope attribute coerces the string path to float", func(t *testing.T) {
|
||||
key := telemetrytypes.TelemetryFieldKey{Name: "sampler.ratio", FieldContext: telemetrytypes.FieldContextScope}
|
||||
keys := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"sampler.ratio": {{Name: "sampler.ratio", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextScope, FieldDataType: telemetrytypes.FieldDataTypeString}},
|
||||
}
|
||||
sql, _ := build(key, keys, qbtypes.FilterOperatorGreaterThan, float64(0.5))
|
||||
assert.Contains(t, sql, "toFloat64OrNull(scope.attributes.`sampler.ratio`::String) > ?")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -121,20 +121,6 @@ var (
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
"scope.name": {
|
||||
Name: "scope.name",
|
||||
Description: "Instrumentation scope name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
"scope.version": {
|
||||
Name: "scope.version",
|
||||
Description: "Instrumentation scope version",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
}
|
||||
IntrinsicFieldsDeprecated = map[string]telemetrytypes.TelemetryFieldKey{
|
||||
"traceID": {
|
||||
|
||||
@@ -53,7 +53,6 @@ var (
|
||||
ValueType: schema.ColumnTypeString,
|
||||
}},
|
||||
"resource": {Name: "resource", Type: schema.JSONColumnType{}},
|
||||
"scope": {Name: "scope", Type: schema.JSONColumnType{}},
|
||||
|
||||
"events": {Name: "events", Type: schema.ArrayColumnType{
|
||||
ElementType: schema.ColumnTypeString,
|
||||
@@ -182,7 +181,7 @@ func (m *fieldMapper) getColumn(
|
||||
case telemetrytypes.FieldContextResource:
|
||||
return []*schema.Column{indexV3Columns["resource"], indexV3Columns["resources_string"]}, nil
|
||||
case telemetrytypes.FieldContextScope:
|
||||
return []*schema.Column{indexV3Columns["scope"]}, nil
|
||||
return []*schema.Column{}, qbtypes.ErrColumnNotFound
|
||||
case telemetrytypes.FieldContextAttribute:
|
||||
switch key.FieldDataType {
|
||||
case telemetrytypes.FieldDataTypeString:
|
||||
@@ -293,24 +292,14 @@ func (m *fieldMapper) resolveColumnExprs(
|
||||
|
||||
switch column.Type.GetType() {
|
||||
case schema.ColumnTypeEnumJSON:
|
||||
// The ::String cast is required because ClickHouse rejects Variant/Dynamic
|
||||
// types in GROUP BY; revisit once the clickHouse dependency is updated.
|
||||
switch key.FieldContext {
|
||||
case telemetrytypes.FieldContextResource:
|
||||
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
|
||||
case telemetrytypes.FieldContextScope:
|
||||
if isDeclaredScopePath(key.Name) {
|
||||
// declared typed String paths are non-Nullable: absent reads '' not NULL.
|
||||
exprs = append(exprs, fmt.Sprintf("%s::String", key.Name))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s::String <> ''", key.Name))
|
||||
} else {
|
||||
exprs = append(exprs, fmt.Sprintf("%s.attributes.`%s`::String", columnName, key.Name))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s.attributes.`%s` IS NOT NULL", columnName, key.Name))
|
||||
}
|
||||
default:
|
||||
return nil, nil, nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource and scope context fields are supported for json columns, got %s", key.FieldContext.String)
|
||||
// json is only supported for resource context as of now
|
||||
if key.FieldContext != telemetrytypes.FieldContextResource {
|
||||
return nil, nil, nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource context fields are supported for json columns, got %s", key.FieldContext.String)
|
||||
}
|
||||
// have to add ::string as clickHouse throws an error :- data types Variant/Dynamic are not allowed in GROUP BY
|
||||
// once clickHouse dependency is updated, we need to check if we can remove it.
|
||||
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
|
||||
case schema.ColumnTypeEnumString,
|
||||
schema.ColumnTypeEnumUInt64,
|
||||
schema.ColumnTypeEnumUInt32,
|
||||
@@ -428,40 +417,23 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
|
||||
// Resolve the candidate logical field(s).
|
||||
var candidates []*telemetrytypes.LogicalField
|
||||
switch field.FieldContext {
|
||||
case telemetrytypes.FieldContextScope:
|
||||
// FieldFor resolves any scope key to a single expression, so the probe below
|
||||
// would skip the union. Resolve scope the way the filter path does instead:
|
||||
// MatchingLogicalFields returns a same-named scope attribute (attribute-first)
|
||||
// alongside the declared path, and CandidateKeys synthesizes an attribute when
|
||||
// metadata knows neither.
|
||||
matches := querybuilder.MatchingLogicalFields(ctx, orgID, m.fl, field, keys)
|
||||
candidates, _ = querybuilder.ResolveLogicalFields(field, matches)
|
||||
if len(candidates) == 0 {
|
||||
candidates = querybuilder.WrapAsLogicalFields(field.Name, m.CandidateKeys(ctx, orgID, field, nil, keys))
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name)
|
||||
switch _, err := m.FieldFor(ctx, orgID, startNs, endNs, field); {
|
||||
case err == nil:
|
||||
// A directly-resolvable key upgrades to its family when the metadata
|
||||
// map proves membership; otherwise it stays single-member.
|
||||
candidates = []*telemetrytypes.LogicalField{m.logicalForResolvedColumn(ctx, orgID, field, keys)}
|
||||
case errors.Is(err, qbtypes.ErrColumnNotFound):
|
||||
// The legacy candidate flow, unchanged: column (when the bare name is
|
||||
// one) plus metadata matches, else synthesized type-variant keys. The
|
||||
// family step below only swaps candidates for their family; it never
|
||||
// changes candidate order or non-family behavior.
|
||||
raw := m.CandidateKeys(ctx, orgID, field, nil, keys)
|
||||
if len(raw) == 0 {
|
||||
return "", errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
|
||||
}
|
||||
candidates = m.upgradeToFamilies(ctx, orgID, field, querybuilder.WrapAsLogicalFields(field.Name, raw), keys)
|
||||
default:
|
||||
switch _, err := m.FieldFor(ctx, orgID, startNs, endNs, field); {
|
||||
case err == nil:
|
||||
// A directly-resolvable key upgrades to its family when the metadata
|
||||
// map proves membership; otherwise it stays single-member.
|
||||
candidates = []*telemetrytypes.LogicalField{m.logicalForResolvedColumn(ctx, orgID, field, keys)}
|
||||
case errors.Is(err, qbtypes.ErrColumnNotFound):
|
||||
// The legacy candidate flow, unchanged: column (when the bare name is
|
||||
// one) plus metadata matches, else synthesized type-variant keys. The
|
||||
// family step below only swaps candidates for their family; it never
|
||||
// changes candidate order or non-family behavior.
|
||||
raw := m.CandidateKeys(ctx, orgID, field, nil, keys)
|
||||
if len(raw) == 0 {
|
||||
return "", errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
|
||||
}
|
||||
candidates = m.upgradeToFamilies(ctx, orgID, field, querybuilder.WrapAsLogicalFields(field.Name, raw), keys)
|
||||
default:
|
||||
return "", err
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Group-by/order (String) and aggregation (String/Float64): every candidate is
|
||||
@@ -512,9 +484,7 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
}
|
||||
|
||||
// Multiple candidates (collision / synth): multiIf picks the first that exists,
|
||||
// stringified so branches share a type. Scope value expressions are already
|
||||
// ::String, so they skip the redundant toString wrap.
|
||||
scopeContext := field.FieldContext == telemetrytypes.FieldContextScope
|
||||
// stringified so branches share a type.
|
||||
args := make([]string, 0, len(candidates))
|
||||
for _, logical := range candidates {
|
||||
value, err := querybuilder.LogicalValueExpr(ctx, orgID, startNs, endNs, m, logical)
|
||||
@@ -525,11 +495,7 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if scopeContext {
|
||||
args = append(args, fmt.Sprintf("%s, %s", guard, value))
|
||||
} else {
|
||||
args = append(args, fmt.Sprintf("%s, toString(%s)", guard, value))
|
||||
}
|
||||
args = append(args, fmt.Sprintf("%s, toString(%s)", guard, value))
|
||||
}
|
||||
return fmt.Sprintf("multiIf(%s, NULL)", strings.Join(args, ", ")), nil
|
||||
}
|
||||
@@ -633,59 +599,11 @@ func (m *fieldMapper) CandidateKeys(ctx context.Context, _ valuer.UUID, field *t
|
||||
// strict context honored as-is: stripped interpretation first, literal spelling second
|
||||
literal := telemetrytypes.NewTelemetryFieldKey(field.FieldContext.StringValue()+"."+field.Name, field.FieldContext, field.FieldDataType)
|
||||
return append(querybuilder.SynthesizeKeys(field, value), querybuilder.SynthesizeKeys(literal, value)...)
|
||||
case telemetrytypes.FieldContextScope:
|
||||
// A declared path resolves to itself even without metadata, whether referenced
|
||||
// fully-qualified (`scope.name`) or by its short name (`name`); any other name is
|
||||
// a scope attribute on the JSON column. This must not depend on the intrinsic
|
||||
// being present in the metadata map.
|
||||
if isDeclaredScopePath(field.Name) {
|
||||
return []*telemetrytypes.TelemetryFieldKey{telemetrytypes.NewTelemetryFieldKey(field.Name, telemetrytypes.FieldContextScope, telemetrytypes.FieldDataTypeString)}
|
||||
}
|
||||
if declaredName := telemetrytypes.FieldContextScope.StringValue() + "." + field.Name; isDeclaredScopePath(declaredName) {
|
||||
return []*telemetrytypes.TelemetryFieldKey{telemetrytypes.NewTelemetryFieldKey(declaredName, telemetrytypes.FieldContextScope, telemetrytypes.FieldDataTypeString)}
|
||||
}
|
||||
return []*telemetrytypes.TelemetryFieldKey{synthScopeAttributeKey(field)}
|
||||
}
|
||||
// contexts that don't exist on spans (log, body, …) have nothing to synthesize
|
||||
// contexts that don't exist on spans (log, body, scope, …) have nothing to synthesize
|
||||
return nil
|
||||
}
|
||||
|
||||
// synthScopeAttributeKey guesses a scope attribute (scope.attributes.<name>) for a name
|
||||
// absent from metadata — the scope analog of querybuilder.SynthesizeKeys.
|
||||
func synthScopeAttributeKey(field *telemetrytypes.TelemetryFieldKey) *telemetrytypes.TelemetryFieldKey {
|
||||
return telemetrytypes.NewTelemetryFieldKey(field.Name, telemetrytypes.FieldContextScope, telemetrytypes.FieldDataTypeString)
|
||||
}
|
||||
|
||||
// isDeclaredScopePath reports whether name is a declared typed sub-path of the scope JSON
|
||||
// column (scope.name / scope.version), as opposed to an entry in scope.attributes.
|
||||
func isDeclaredScopePath(name string) bool {
|
||||
f, ok := IntrinsicFields[name]
|
||||
return ok && f.FieldContext == telemetrytypes.FieldContextScope
|
||||
}
|
||||
|
||||
// scopeJSONExistsExpression renders the presence predicate for a scope JSON key, whose
|
||||
// two homes differ: declared typed paths are non-Nullable (absent reads ”), while
|
||||
// scope.attributes.* are Dynamic/Nullable. Returns ok=false for non-scope keys so the
|
||||
// caller falls back to the generic exists expression.
|
||||
func scopeJSONExistsExpression(key *telemetrytypes.TelemetryFieldKey, fieldExpression string, exists bool) (string, bool) {
|
||||
if key.FieldContext != telemetrytypes.FieldContextScope {
|
||||
return "", false
|
||||
}
|
||||
if isDeclaredScopePath(key.Name) {
|
||||
if exists {
|
||||
return fieldExpression + " <> ''", true
|
||||
}
|
||||
return fieldExpression + " = ''", true
|
||||
}
|
||||
// The value expression casts the JSON path to String, folding a missing key's NULL to
|
||||
// '', so presence must test the raw path — drop the ::String cast.
|
||||
path := strings.TrimSuffix(fieldExpression, "::String")
|
||||
if exists {
|
||||
return path + " IS NOT NULL", true
|
||||
}
|
||||
return path + " IS NULL", true
|
||||
}
|
||||
|
||||
// ExistsFor implements the per-key existence primitive of qbtypes.FieldMapper.
|
||||
func (m *fieldMapper) ExistsFor(
|
||||
ctx context.Context,
|
||||
@@ -702,8 +620,5 @@ func (m *fieldMapper) ExistsFor(
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if expr, ok := scopeJSONExistsExpression(key, fieldExpression, exists); ok {
|
||||
return expr, nil
|
||||
}
|
||||
return querybuilder.ExistsExpression(columns, key, tsStart, tsEnd, fieldExpression, exists)
|
||||
}
|
||||
|
||||
@@ -304,160 +304,3 @@ func TestColumnExpressionForTimestampAttributeCollision(t *testing.T) {
|
||||
assert.Contains(t, result, "attributes_number['timestamp']")
|
||||
})
|
||||
}
|
||||
|
||||
// scopeKey builds a TelemetryFieldKey the way the API boundary would after Normalize.
|
||||
func scopeKey(name string) telemetrytypes.TelemetryFieldKey {
|
||||
return telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
}
|
||||
}
|
||||
|
||||
// declaredScopeKeys injects the scope.name/scope.version intrinsics into the metadata map
|
||||
// the way metadata.go does at query time; resolution of the declared paths depends on it.
|
||||
func declaredScopeKeys() map[string][]*telemetrytypes.TelemetryFieldKey {
|
||||
scopeName := IntrinsicFields["scope.name"]
|
||||
scopeVersion := IntrinsicFields["scope.version"]
|
||||
return map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"scope.name": {&scopeName},
|
||||
"scope.version": {&scopeVersion},
|
||||
}
|
||||
}
|
||||
|
||||
func scopeAttribute(name string) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
}
|
||||
|
||||
// TestColumnExpressionForScope covers the scope resolution matrix from PR #10920: declared
|
||||
// paths, scope attributes, and the attribute-first union when a scope attribute shares its
|
||||
// name with a declared path.
|
||||
func TestColumnExpressionForScope(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tsStart := uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano())
|
||||
tsEnd := uint64(time.Date(2024, 6, 5, 0, 0, 0, 0, time.UTC).UnixNano())
|
||||
fm := NewFieldMapper(flaggertest.New(t))
|
||||
|
||||
run := func(field telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) string {
|
||||
t.Helper()
|
||||
result, err := fm.ColumnExpressionFor(ctx, valuer.UUID{}, tsStart, tsEnd, &field, telemetrytypes.FieldDataTypeUnspecified, keys)
|
||||
require.NoError(t, err)
|
||||
return result
|
||||
}
|
||||
|
||||
t.Run("short name binds to declared scope.name when no attribute exists", func(t *testing.T) {
|
||||
assert.Equal(t,
|
||||
"multiIf(scope.name::String <> '', scope.name::String, NULL)",
|
||||
run(scopeKey("name"), declaredScopeKeys()))
|
||||
})
|
||||
|
||||
t.Run("fully-qualified scope.name isolates the declared path", func(t *testing.T) {
|
||||
assert.Equal(t,
|
||||
"multiIf(scope.name::String <> '', scope.name::String, NULL)",
|
||||
run(scopeKey("scope.name"), declaredScopeKeys()))
|
||||
})
|
||||
|
||||
t.Run("short version binds to declared scope.version", func(t *testing.T) {
|
||||
assert.Equal(t,
|
||||
"multiIf(scope.version::String <> '', scope.version::String, NULL)",
|
||||
run(scopeKey("version"), declaredScopeKeys()))
|
||||
})
|
||||
|
||||
t.Run("plain scope attribute", func(t *testing.T) {
|
||||
keys := declaredScopeKeys()
|
||||
keys["testing.env"] = []*telemetrytypes.TelemetryFieldKey{scopeAttribute("testing.env")}
|
||||
assert.Equal(t,
|
||||
"multiIf(scope.attributes.`testing.env` IS NOT NULL, scope.attributes.`testing.env`::String, NULL)",
|
||||
run(scopeKey("testing.env"), keys))
|
||||
})
|
||||
|
||||
t.Run("scope attribute synthesized when absent from metadata", func(t *testing.T) {
|
||||
assert.Equal(t,
|
||||
"multiIf(scope.attributes.`testing.env` IS NOT NULL, scope.attributes.`testing.env`::String, NULL)",
|
||||
run(scopeKey("testing.env"), declaredScopeKeys()))
|
||||
})
|
||||
|
||||
t.Run("short name unions attribute (first) with declared path", func(t *testing.T) {
|
||||
keys := declaredScopeKeys()
|
||||
keys["name"] = []*telemetrytypes.TelemetryFieldKey{scopeAttribute("name")}
|
||||
assert.Equal(t,
|
||||
"multiIf(scope.attributes.`name` IS NOT NULL, scope.attributes.`name`::String, scope.name::String <> '', scope.name::String, NULL)",
|
||||
run(scopeKey("name"), keys))
|
||||
})
|
||||
|
||||
t.Run("fully-qualified scope.version isolates declared even with conflicting attribute", func(t *testing.T) {
|
||||
keys := declaredScopeKeys()
|
||||
keys["version"] = []*telemetrytypes.TelemetryFieldKey{scopeAttribute("version")}
|
||||
assert.Equal(t,
|
||||
"multiIf(scope.version::String <> '', scope.version::String, NULL)",
|
||||
run(scopeKey("scope.version"), keys))
|
||||
})
|
||||
|
||||
t.Run("group by short name unions attribute and declared without toString", func(t *testing.T) {
|
||||
keys := declaredScopeKeys()
|
||||
keys["name"] = []*telemetrytypes.TelemetryFieldKey{scopeAttribute("name")}
|
||||
result, err := fm.ColumnExpressionFor(ctx, valuer.UUID{}, tsStart, tsEnd, &[]telemetrytypes.TelemetryFieldKey{scopeKey("name")}[0], telemetrytypes.FieldDataTypeString, keys)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
"multiIf(scope.attributes.`name` IS NOT NULL, scope.attributes.`name`::String, scope.name::String <> '', scope.name::String, NULL)",
|
||||
result)
|
||||
})
|
||||
}
|
||||
|
||||
// TestFieldForScope covers the per-key SQL for a resolved scope key.
|
||||
func TestFieldForScope(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tsStart := uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano())
|
||||
tsEnd := uint64(time.Date(2024, 6, 5, 0, 0, 0, 0, time.UTC).UnixNano())
|
||||
fm := NewFieldMapper(flaggertest.New(t))
|
||||
|
||||
cases := map[string]string{
|
||||
"scope.name": "scope.name::String",
|
||||
"scope.version": "scope.version::String",
|
||||
"custom.attr": "scope.attributes.`custom.attr`::String",
|
||||
}
|
||||
for name, want := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
key := scopeKey(name)
|
||||
got, err := fm.FieldFor(ctx, valuer.UUID{}, tsStart, tsEnd, &key)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, got)
|
||||
// A scope path must never double-prefix the JSON column.
|
||||
assert.NotContains(t, got, "scope.`scope.")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExistsForScope covers the presence predicates: declared paths test <> ” (non-Nullable),
|
||||
// scope attributes test the raw JSON path IS NOT NULL.
|
||||
func TestExistsForScope(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tsStart := uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano())
|
||||
tsEnd := uint64(time.Date(2024, 6, 5, 0, 0, 0, 0, time.UTC).UnixNano())
|
||||
fm := NewFieldMapper(flaggertest.New(t))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
key string
|
||||
exists bool
|
||||
want string
|
||||
}{
|
||||
{"declared exists", "scope.name", true, "scope.name::String <> ''"},
|
||||
{"declared not exists", "scope.name", false, "scope.name::String = ''"},
|
||||
{"attribute exists", "exception.type", true, "scope.attributes.`exception.type` IS NOT NULL"},
|
||||
{"attribute not exists", "exception.type", false, "scope.attributes.`exception.type` IS NULL"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
key := scopeKey(tc.key)
|
||||
got, err := fm.ExistsFor(ctx, valuer.UUID{}, tsStart, tsEnd, &key, tc.exists)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,21 +128,6 @@ func BuildCompleteFieldKeyMap(releaseTime time.Time) map[string][]*telemetrytype
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
// declared scope paths, mirroring the intrinsics metadata.go injects at query time
|
||||
"scope.name": {
|
||||
{
|
||||
Name: "scope.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
"scope.version": {
|
||||
{
|
||||
Name: "scope.version",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, keys := range keysMap {
|
||||
for _, key := range keys {
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// - Use `scope.` prefix to explicitly indicate and enforce scope context. Example
|
||||
// - `scope.name`
|
||||
// - `scope.version`
|
||||
// - `scope.my.custom.attribute` resolves to the `my.custom.attribute` scope attribute
|
||||
// - `scope.my.custom.attribute` and `scope.attribute.my.custom.attribute` resolve to same attribute
|
||||
//
|
||||
// - Use `attribute.` to explicitly indicate and enforce attribute context. Example
|
||||
// - `attribute.http.method`
|
||||
@@ -190,7 +190,7 @@ func (FieldContext) Enum() []any {
|
||||
FieldContextSpan,
|
||||
FieldContextTrace,
|
||||
FieldContextResource,
|
||||
FieldContextScope,
|
||||
// FieldContextScope,
|
||||
FieldContextAttribute,
|
||||
// FieldContextEvent,
|
||||
FieldContextBody,
|
||||
|
||||
@@ -35,14 +35,6 @@ func TestGetFieldKeyFromKeyText(t *testing.T) {
|
||||
FieldDataType: FieldDataTypeUnspecified,
|
||||
},
|
||||
},
|
||||
{
|
||||
keyText: "scope.custom.attr:string",
|
||||
expected: TelemetryFieldKey{
|
||||
Name: "custom.attr",
|
||||
FieldContext: FieldContextScope,
|
||||
FieldDataType: FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
{
|
||||
keyText: "attribute.http.method",
|
||||
expected: TelemetryFieldKey{
|
||||
|
||||
34
tests/fixtures/alerts.py
vendored
34
tests/fixtures/alerts.py
vendored
@@ -339,20 +339,37 @@ def verify_webhook_notification_expectation(
|
||||
notification_channel: types.TestContainerDocker,
|
||||
validation_data: dict,
|
||||
) -> bool:
|
||||
"""Check if wiremock received a request at the given path
|
||||
whose JSON body is a superset of the expected json_body."""
|
||||
"""Check that wiremock received the expected request(s) at the given path.
|
||||
|
||||
validation_data supports (all optional except path):
|
||||
- path: request url path (matched as urlPath, so query strings are ignored)
|
||||
- json_body: expected JSON subset of the request body
|
||||
- count: exact number of requests required at the path
|
||||
- min_count: minimum number of requests required (e.g. retries)
|
||||
The body constraint must be satisfied by a single request; count constraints
|
||||
apply to the total at the path."""
|
||||
path = validation_data["path"]
|
||||
json_body = validation_data["json_body"]
|
||||
json_body = validation_data.get("json_body")
|
||||
|
||||
url = notification_channel.host_configs["8080"].get("__admin/requests/find")
|
||||
try:
|
||||
res = requests.post(url, json={"method": "POST", "url": path}, timeout=10)
|
||||
# urlPath ignores query strings; real webhook urls may carry their own (e.g. key/token).
|
||||
res = requests.post(url, json={"method": "POST", "urlPath": path}, timeout=10)
|
||||
except requests.exceptions.RequestException:
|
||||
return False
|
||||
if res.status_code != HTTPStatus.OK:
|
||||
return False
|
||||
|
||||
for req in res.json()["requests"]:
|
||||
reqs = res.json()["requests"]
|
||||
if "count" in validation_data and len(reqs) != validation_data["count"]:
|
||||
return False
|
||||
if "min_count" in validation_data and len(reqs) < validation_data["min_count"]:
|
||||
return False
|
||||
|
||||
if json_body is None:
|
||||
return True
|
||||
|
||||
for req in reqs:
|
||||
body = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
|
||||
if _is_json_subset(json_body, body):
|
||||
return True
|
||||
@@ -416,7 +433,7 @@ def _received_notifications(
|
||||
continue
|
||||
url = notification_channel.host_configs["8080"].get("__admin/requests/find")
|
||||
try:
|
||||
res = requests.post(url, json={"method": "POST", "url": validation.validation_data["path"]}, timeout=10)
|
||||
res = requests.post(url, json={"method": "POST", "urlPath": validation.validation_data["path"]}, timeout=10)
|
||||
webhook_bodies.extend(json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8")) for req in res.json()["requests"])
|
||||
except requests.exceptions.RequestException as exc:
|
||||
webhook_bodies.append(f"<failed to fetch wiremock journal: {exc}>")
|
||||
@@ -455,4 +472,9 @@ def update_raw_channel_config(
|
||||
path = urlparse(original_url).path
|
||||
entry[url_field] = notification_channel.container_configs["8080"].get(path)
|
||||
|
||||
# Google Chat validates the webhook host
|
||||
for entry in config.get("googlechat_configs", []):
|
||||
https = notification_channel.container_configs["443"]
|
||||
entry["webhook_url"] = f"{https.scheme}://{https.address}{urlparse(entry['webhook_url']).path}"
|
||||
|
||||
return config
|
||||
|
||||
140
tests/fixtures/notification_channel.py
vendored
140
tests/fixtures/notification_channel.py
vendored
@@ -1,23 +1,33 @@
|
||||
# pylint: disable=line-too-long
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
|
||||
import docker
|
||||
import docker.errors
|
||||
import pytest
|
||||
import requests
|
||||
from testcontainers.core.container import Network
|
||||
from wiremock.resources.mappings import HttpMethods, Mapping, MappingRequest, MappingResponse
|
||||
from wiremock.testing.testcontainer import WireMockContainer
|
||||
|
||||
from fixtures import reuse, types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logger import setup_logger
|
||||
from fixtures.maildev import MAILDEV_INCOMING_PASS, SMTP_TEST_FROM
|
||||
from fixtures.tls import CA_ID_LABEL, KEYSTORE_PASSWORD, ca_id, issue_server_keystore
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# Google Chat validates the webhook host, so the WireMock container joins the
|
||||
# network under this alias and serves HTTPS on 443 with a certificate issued by
|
||||
# the integration CA that signoz trusts; channels point at https://<host>/...
|
||||
GOOGLE_CHAT_HOST = "chat.googleapis.com"
|
||||
|
||||
|
||||
EMAIL_TRANSPORT_KEYS = [
|
||||
"from",
|
||||
@@ -124,9 +134,77 @@ email_default_config = {
|
||||
}
|
||||
|
||||
|
||||
def googlechat_config(space: str) -> dict:
|
||||
"""Google Chat channel config for a per-test WireMock space path. Title/text are
|
||||
omitted so the backend applies its default templates. The host is injected at
|
||||
runtime by update_raw_channel_config."""
|
||||
return {
|
||||
"googlechat_configs": [
|
||||
{
|
||||
"webhook_url": f"/v1/spaces/{space}/messages", # host set on runtime
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def googlechat_ok_mappings(path: str) -> list[Mapping]:
|
||||
return [
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=path),
|
||||
response=MappingResponse(status=200, json_body={"name": "spaces/x/messages/x"}),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def googlechat_retry_mappings(path: str) -> list[Mapping]:
|
||||
"""429 on the first call then 200, via a wiremock scenario transition."""
|
||||
scenario = f"gc-retry-{path}"
|
||||
return [
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=path),
|
||||
response=MappingResponse(status=429, json_body={"error": {"code": 429, "status": "RESOURCE_EXHAUSTED"}}),
|
||||
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={"name": "spaces/x/messages/x"}),
|
||||
scenario_name=scenario,
|
||||
required_scenario_state="ok",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def googlechat_card_subset(alertname: str, buttons: list[tuple[str, str]]) -> dict:
|
||||
"""A cardsV2 subset asserting title, firing banner, rendered body, and each
|
||||
button's text AND deep-link url (as a regex), so a broken link is caught too.
|
||||
buttons: list of (text, url_regex)."""
|
||||
return {
|
||||
"text": f"[FIRING:1] {alertname}",
|
||||
"cardsV2": [
|
||||
{
|
||||
"cardId": "signoz-alert",
|
||||
"card": {
|
||||
"header": {"title": f"[FIRING:1] {alertname}"},
|
||||
"sections": [
|
||||
# firing banner
|
||||
{"widgets": [{"textParagraph": {"text": re.compile("FIRING")}}]},
|
||||
# rendered alert body mentions the alertname
|
||||
{"widgets": [{"textParagraph": {"text": re.compile(re.escape(alertname))}}]},
|
||||
]
|
||||
+ [{"widgets": [{"buttonList": {"buttons": [{"text": text, "onClick": {"openLink": {"url": re.compile(url)}}}]}}]} for text, url in buttons],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(name="notification_channel", scope="package")
|
||||
def notification_channel(
|
||||
def notification_channel( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
network: Network,
|
||||
tls: types.TLS,
|
||||
tmpfs: Callable[[str], Path],
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.TestContainerDocker:
|
||||
@@ -135,9 +213,25 @@ def notification_channel(
|
||||
"""
|
||||
|
||||
def create() -> types.TestContainerDocker:
|
||||
# http:8080 for admin API + plain webhook delivery; https:443 aliased as
|
||||
# chat.googleapis.com with a CA-issued cert so Google Chat's validated
|
||||
# webhook host routes here over real TLS (signoz trusts the integration CA).
|
||||
keystore_path = issue_server_keystore(tls, tmpfs("notification-channel-certs"), GOOGLE_CHAT_HOST)
|
||||
|
||||
container = WireMockContainer(image="wiremock/wiremock:2.35.1-1", secure=False)
|
||||
container.with_volume_mapping(str(keystore_path.parent), "/certs", "ro")
|
||||
container.with_network(network)
|
||||
container.start()
|
||||
container.with_network_aliases(GOOGLE_CHAT_HOST)
|
||||
container.with_kwargs(labels={CA_ID_LABEL: ca_id(tls)})
|
||||
|
||||
try:
|
||||
container.start(f"--port 8080 --https-port 443 --https-keystore /certs/keystore.p12 --keystore-type PKCS12 --keystore-password {KEYSTORE_PASSWORD}")
|
||||
except Exception:
|
||||
# Ryuk is disabled: a started-but-unready container would survive and
|
||||
# keep squatting on the chat.googleapis.com alias, poisoning DNS for
|
||||
# any replacement on the shared network.
|
||||
container.stop()
|
||||
raise
|
||||
|
||||
return types.TestContainerDocker(
|
||||
id=container.get_wrapped_container().id,
|
||||
@@ -148,7 +242,11 @@ def notification_channel(
|
||||
container.get_exposed_port(8080),
|
||||
)
|
||||
},
|
||||
container_configs={"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080)},
|
||||
container_configs={
|
||||
"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080),
|
||||
# Google Chat delivery: https to the validated host via the network alias.
|
||||
"443": types.TestContainerUrlConfig("https", GOOGLE_CHAT_HOST, 443),
|
||||
},
|
||||
)
|
||||
|
||||
def delete(container: types.TestContainerDocker):
|
||||
@@ -165,6 +263,16 @@ def notification_channel(
|
||||
def restore(cache: dict) -> types.TestContainerDocker:
|
||||
return types.TestContainerDocker.from_cache(cache)
|
||||
|
||||
def stale(container: types.TestContainerDocker) -> bool:
|
||||
# A container built against a rotated/absent CA can't serve a cert signoz
|
||||
# trusts; recreate it instead of failing TLS opaquely.
|
||||
client = docker.from_env()
|
||||
try:
|
||||
labels = client.containers.get(container_id=container.id).attrs["Config"]["Labels"]
|
||||
except docker.errors.NotFound:
|
||||
return True
|
||||
return labels.get(CA_ID_LABEL) != ca_id(tls)
|
||||
|
||||
return reuse.wrap(
|
||||
request,
|
||||
pytestconfig,
|
||||
@@ -173,6 +281,7 @@ def notification_channel(
|
||||
create,
|
||||
delete,
|
||||
restore,
|
||||
stale=stale,
|
||||
)
|
||||
|
||||
|
||||
@@ -248,6 +357,31 @@ def create_webhook_notification_channel(
|
||||
return _create_webhook_notification_channel
|
||||
|
||||
|
||||
def wait_for_org_registration(signoz: types.SigNoz, token: str, notification_channel: types.TestContainerDocker, wait_seconds: int = 60) -> None:
|
||||
"""Polls until the org's alertmanager server is registered (one poll tick).
|
||||
|
||||
channels/test 404s until then, before reaching any notifier. The sentinel
|
||||
receiver posts to its own unstubbed wiremock path, so request journals
|
||||
asserted by tests stay clean."""
|
||||
sentinel = {
|
||||
"name": str(uuid.uuid4()),
|
||||
"webhook_configs": [{"url": notification_channel.container_configs["8080"].get("/org-registration-sentinel")}],
|
||||
}
|
||||
deadline = time.time() + wait_seconds
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
last = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
|
||||
json=sentinel,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
if last.status_code != HTTPStatus.NOT_FOUND:
|
||||
return
|
||||
time.sleep(2)
|
||||
raise AssertionError(f"org alertmanager did not register within {wait_seconds}s, last response: {last.status_code} {last.text}")
|
||||
|
||||
|
||||
def send_test_notification(signoz: types.SigNoz, token: str, receiver: dict, wait_seconds: int = 90) -> None:
|
||||
deadline = time.time() + wait_seconds
|
||||
last = None
|
||||
|
||||
187
tests/integration/tests/alertmanager/04_googlechat.py
Normal file
187
tests/integration/tests/alertmanager/04_googlechat.py
Normal file
@@ -0,0 +1,187 @@
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from wiremock.resources.mappings import Mapping
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.alerts import (
|
||||
get_testdata_file_path,
|
||||
update_raw_channel_config,
|
||||
update_rule_channel_name,
|
||||
verify_notification_expectation,
|
||||
)
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logger import setup_logger
|
||||
from fixtures.notification_channel import (
|
||||
googlechat_card_subset,
|
||||
googlechat_config,
|
||||
googlechat_ok_mappings,
|
||||
googlechat_retry_mappings,
|
||||
wait_for_org_registration,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
METRICS_DATA = "alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
|
||||
METRICS_RULE = "alerts/test_scenarios/threshold_above_at_least_once/rule.json"
|
||||
LOGS_DATA = "alerts/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"
|
||||
LOGS_RULE = "alerts/test_scenarios/threshold_below_at_least_once/rule.json"
|
||||
TRACES_DATA = "alerts/test_scenarios/threshold_above_average/alert_data.jsonl"
|
||||
TRACES_RULE = "alerts/test_scenarios/threshold_above_average/rule.json"
|
||||
|
||||
|
||||
GOOGLECHAT_CASES = [
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="googlechat_default_metrics_firing",
|
||||
rule_path=METRICS_RULE,
|
||||
alert_data=[types.AlertData(type="metrics", data_path=METRICS_DATA)],
|
||||
channel_config=googlechat_config("gc-metrics"),
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=60,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": "/v1/spaces/gc-metrics/messages",
|
||||
"count": 1,
|
||||
"json_body": googlechat_card_subset("threshold_above_at_least_once", [("Open in SigNoz", r"/alerts/overview\?ruleId=")]),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="googlechat_rich_card_logs",
|
||||
rule_path=LOGS_RULE,
|
||||
alert_data=[types.AlertData(type="logs", data_path=LOGS_DATA)],
|
||||
channel_config=googlechat_config("gc-logs"),
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=60,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": "/v1/spaces/gc-logs/messages",
|
||||
"count": 1,
|
||||
"json_body": googlechat_card_subset(
|
||||
"threshold_below_at_least_once",
|
||||
[("View Related Logs", r"/logs/logs-explorer\?"), ("Open in SigNoz", r"/alerts/overview\?ruleId=")],
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="googlechat_rich_card_traces",
|
||||
rule_path=TRACES_RULE,
|
||||
alert_data=[types.AlertData(type="traces", data_path=TRACES_DATA)],
|
||||
channel_config=googlechat_config("gc-traces"),
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=60,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": "/v1/spaces/gc-traces/messages",
|
||||
"count": 1,
|
||||
"json_body": googlechat_card_subset(
|
||||
"threshold_above_average",
|
||||
[("View Related Traces", r"traces-explorer\?"), ("Open in SigNoz", r"/alerts/overview\?ruleId=")],
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"gc_test_case",
|
||||
GOOGLECHAT_CASES,
|
||||
ids=lambda c: c.name,
|
||||
)
|
||||
def test_googlechat_notifier( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
create_notification_channel: Callable[[dict], str],
|
||||
create_alert_rule: Callable[[dict], str],
|
||||
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
|
||||
maildev: types.TestContainerDocker,
|
||||
gc_test_case: types.AlertManagerNotificationTestCase,
|
||||
) -> None:
|
||||
channel_name = str(uuid.uuid4())
|
||||
path = gc_test_case.notification_expectation.notification_validations[0].validation_data["path"]
|
||||
|
||||
channel_config = update_raw_channel_config(gc_test_case.channel_config, channel_name, notification_channel)
|
||||
|
||||
make_http_mocks(notification_channel, googlechat_ok_mappings(path))
|
||||
|
||||
create_notification_channel(channel_config)
|
||||
wait_for_org_registration(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), notification_channel)
|
||||
|
||||
insert_alert_data(gc_test_case.alert_data, base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
|
||||
|
||||
with open(get_testdata_file_path(gc_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, gc_test_case.notification_expectation)
|
||||
|
||||
|
||||
def test_googlechat_retry_429_then_200( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
create_notification_channel: Callable[[dict], 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 = "/v1/spaces/gc-retry/messages"
|
||||
|
||||
channel_config = update_raw_channel_config(googlechat_config("gc-retry"), channel_name, notification_channel)
|
||||
|
||||
make_http_mocks(notification_channel, googlechat_retry_mappings(path))
|
||||
|
||||
create_notification_channel(channel_config)
|
||||
wait_for_org_registration(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), 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": {"cardsV2": [{"cardId": "signoz-alert"}]},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -13,6 +13,7 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
tls: types.TLS,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
maildev: types.TestContainerDocker,
|
||||
@@ -24,6 +25,7 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
tls=tls,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz_alertmanager",
|
||||
|
||||
114
tests/integration/tests/alerts/02_googlechat_test_channel.py
Normal file
114
tests/integration/tests/alerts/02_googlechat_test_channel.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
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.alerts import update_raw_channel_config
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logger import setup_logger
|
||||
from fixtures.notification_channel import googlechat_config
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
# channel test (POST /api/v1/channels/test) drives the notifier once, synchronously,
|
||||
# with a hardcoded test alert and no retry — the deterministic place to assert
|
||||
# permanent-failure behaviour. Rich cards + retry are covered in alertmanager/04_googlechat.py.
|
||||
class TestChannelCase(NamedTuple):
|
||||
__test__ = False
|
||||
name: str
|
||||
space: str
|
||||
status: int # stub status
|
||||
body: dict # stub body
|
||||
expect_delivered: bool # expect channels/test 204
|
||||
|
||||
|
||||
TEST_CHANNEL_CASES = [
|
||||
TestChannelCase("success", "gc-tc-ok", 200, {"name": "spaces/x/messages/x"}, True),
|
||||
TestChannelCase("permanent_400", "gc-tc-400", 400, {"error": {"code": 400, "status": "INVALID_ARGUMENT", "message": "Message cannot be empty."}}, False),
|
||||
TestChannelCase("permission_403", "gc-tc-403", 403, {"error": {"code": 403, "status": "PERMISSION_DENIED", "message": "Method doesn't allow unregistered callers"}}, False),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
TEST_CHANNEL_CASES,
|
||||
ids=lambda c: c.name,
|
||||
)
|
||||
def test_googlechat_test_channel( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
|
||||
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 = f"/v1/spaces/{case.space}/messages"
|
||||
make_http_mocks(
|
||||
notification_channel,
|
||||
[
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=path),
|
||||
response=MappingResponse(status=case.status, json_body=case.body),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
channel_name = str(uuid.uuid4())
|
||||
receiver = update_raw_channel_config(googlechat_config(case.space), channel_name, notification_channel)
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# channels/test 404s until the org's alertmanager registers (one poll tick),
|
||||
# without reaching the notifier — so the first non-404 response is the single
|
||||
# authoritative delivery attempt and the count == 1 assertion below holds
|
||||
deadline = time.time() + 60
|
||||
while True:
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
|
||||
json=receiver,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code != HTTPStatus.NOT_FOUND or time.time() > deadline:
|
||||
break
|
||||
time.sleep(2)
|
||||
|
||||
if case.expect_delivered:
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
|
||||
else:
|
||||
# a downstream 400/403 surfaces as a 500 (untyped notify error) whose body
|
||||
# carries the real downstream status code; pin it to distinguish 400 vs 403
|
||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, f"expected 500, got {response.status_code}: {response.text}"
|
||||
assert f"unexpected status code {case.status}" in response.text, f"expected downstream {case.status} 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}"
|
||||
|
||||
if case.expect_delivered:
|
||||
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 configured webhook url is posted verbatim, nothing appended
|
||||
assert req["url"] == path, f"expected webhook url {path} posted verbatim, got {req['url']}"
|
||||
# cardsV2 shape with the hardcoded test alert
|
||||
card = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
|
||||
assert card["cardsV2"][0]["cardId"] == "signoz-alert"
|
||||
assert re.search(r"Test Alert \(", card["cardsV2"][0]["card"]["header"]["title"])
|
||||
Reference in New Issue
Block a user